diff --git a/.deny.toml b/.deny.toml index 4d69e70b..b6680ba6 100644 --- a/.deny.toml +++ b/.deny.toml @@ -60,7 +60,9 @@ allow-git = [ "https://github.com/logos-blockchain/logos-blockchain.git", "https://github.com/logos-blockchain/logos-blockchain-circuits.git", "https://github.com/logos-blockchain/logos-blockchain-rust-rapidsnark.git", + "https://github.com/logos-blockchain/sponges", "https://github.com/arkworks-rs/spongefish.git", + "https://github.com/keycard-tech/keycard-rs", ] unknown-git = "deny" unknown-registry = "deny" diff --git a/.github/actions/install-risc0/action.yml b/.github/actions/install-risc0/action.yml deleted file mode 100644 index fef3a467..00000000 --- a/.github/actions/install-risc0/action.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: Install risc0 -description: Installs risc0 in the environment -runs: - using: "composite" - steps: - - name: Install risc0 - run: | - curl -L https://risczero.com/install | bash - /home/runner/.risc0/bin/rzup install - shell: bash diff --git a/.github/actions/install-system-deps/action.yml b/.github/actions/install-system-deps/action.yml deleted file mode 100644 index 28c4b41c..00000000 --- a/.github/actions/install-system-deps/action.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: Install system dependencies -description: Installs system dependencies in the environment -runs: - using: "composite" - steps: - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y build-essential clang libclang-dev libssl-dev pkg-config - shell: bash diff --git a/.github/actions/run-in-ci-image/action.yml b/.github/actions/run-in-ci-image/action.yml new file mode 100644 index 00000000..3fafe38c --- /dev/null +++ b/.github/actions/run-in-ci-image/action.yml @@ -0,0 +1,54 @@ +name: Run in CI image +description: > + Runs a command inside the CI image on a plain runner, for jobs that need the + host's Docker daemon. `container:` cannot work for those: it puts the + workspace on a path the host daemon cannot resolve, and it forbids + `--network host`. + +inputs: + image: + description: CI image reference, from the ci-image workflow's output. + required: true + run: + description: Command to run inside the image. + required: true + env: + description: Newline-separated NAME=VALUE pairs to pass into the container. + required: false + default: "" + +runs: + using: "composite" + steps: + - name: Run in CI image + shell: bash + env: + INPUT_IMAGE: ${{ inputs.image }} + INPUT_RUN: ${{ inputs.run }} + INPUT_ENV: ${{ inputs.env }} + run: | + set -euo pipefail + + env_args=() + while IFS= read -r pair; do + [[ -z "$pair" ]] && continue + env_args+=(--env "$pair") + done <<< "$INPUT_ENV" + + # Cargo's registry is the only part of CARGO_HOME worth sharing with the + # host: mounting all of it would shadow the tools baked into the image. + mkdir -p "$HOME/.cargo/registry" + + # Mounting the workspace on its own path is what makes this work. The + # daemon resolves compose bind mounts against the host, and the test + # binaries bake CARGO_MANIFEST_DIR in at compile time, so the path has + # to mean the same thing inside and outside the container. + docker run --rm \ + --network host \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume "$PWD:$PWD" \ + --volume "$HOME/.cargo/registry:/usr/local/cargo/registry" \ + --workdir "$PWD" \ + "${env_args[@]}" \ + "$INPUT_IMAGE" \ + bash -e -o pipefail -c "$INPUT_RUN" diff --git a/.github/docker/ci.Dockerfile b/.github/docker/ci.Dockerfile new file mode 100644 index 00000000..7d5dd538 --- /dev/null +++ b/.github/docker/ci.Dockerfile @@ -0,0 +1,98 @@ +# Image behind the `container:` of the CI jobs. Everything the jobs used to +# install per-run is baked in here instead, so a CI run no longer asks the risc0 +# install servers for anything. +# +# The image tag is a hash of this file plus rust-toolchain.toml, so editing +# either rebuilds the image on the PR that changes it. See ci-image.yml. +# +# Keep the base tag in sync with rust-toolchain.toml. +FROM rust:1.94.0-trixie + +# GitHub sets HOME=/github/home inside container jobs, which hides anything we +# bake into the image's ~. rzup defaults to $HOME/.risc0, so pin it to a fixed +# path; RUSTUP_HOME and CARGO_HOME already point at /usr/local from the base +# image, and rzup honours all three. +ENV RISC0_HOME=/usr/local/risc0 +ENV PATH=/usr/local/risc0/bin:$PATH + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + clang \ + libclang-dev \ + libssl-dev \ + pkg-config \ + libpcsclite-dev \ + curl \ + git \ + jq \ + python3-dev \ + && rm -rf /var/lib/apt/lists/* + +# The runner pre-creates the workspace as another uid, so git in a container job +# calls it dubious. checkout's own fix is --global, which only holds while HOME +# still points at the gitconfig it wrote; --system holds regardless. +RUN git config --system --add safe.directory '*' + +# The base image already has this toolchain at the minimal profile, so the +# `profile = "default"` in rust-toolchain.toml is a no-op here: rustup sees the +# toolchain as installed and never applies it. Add what the jobs need by hand. +COPY rust-toolchain.toml /tmp/toolchain/rust-toolchain.toml +RUN cd /tmp/toolchain \ + && rustup toolchain install \ + && rustup component add clippy rustfmt \ + && rm -rf /tmp/toolchain + +# For `cargo +nightly fmt` in the fmt-rs job. +RUN rustup toolchain install nightly --profile minimal --component rustfmt + +# The bootstrap script only ever drops rzup in $HOME/.risc0/bin, so run it +# against the build-time HOME and keep just the binary. rzup itself then honours +# RISC0_HOME and installs each component under /usr/local. +# +# Versions pinned to the set currently validated in local dev (`rzup show`); +# bare `rzup install` would float all four default components to latest. r0vm +# and cargo-risczero track each other and the risc0-zkvm crate version. +RUN curl -L https://risczero.com/install | bash \ + && mv /root/.risc0/bin/rzup /usr/local/bin/rzup \ + && rm -rf /root/.risc0 \ + && rzup install rust 1.94.1 \ + && rzup install cpp 2024.1.5 \ + && rzup install r0vm 3.0.5 \ + && rzup install cargo-risczero 3.0.5 + +# Copying docker. Static Go binaries, so the alpine-built ones run fine here. +COPY --from=docker:29.6.1-cli /usr/local/bin/docker /usr/local/bin/docker +COPY --from=docker:29.6.1-cli /usr/local/libexec/docker/cli-plugins/docker-compose \ + /usr/local/libexec/docker/cli-plugins/docker-compose +COPY --from=docker:29.6.1-cli /usr/local/libexec/docker/cli-plugins/docker-buildx \ + /usr/local/libexec/docker/cli-plugins/docker-buildx + +# Prebuilt binaries; compiling these from source would dominate the image build. +# Versions pinned so a rebuild of the same image ships the same tools. +ENV BINSTALL_VERSION=1.21.0 +RUN curl -L --proto '=https' --tlsv1.2 -sSf \ + https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash \ + && cargo binstall --no-confirm \ + cargo-nextest@0.9.140 \ + taplo-cli@0.10.0 \ + cargo-machete@0.9.2 \ + cargo-deny@0.20.2 \ + just@1.56.0 + +# Smoke-test every tool the jobs use. A bad install should fail here, not later +# in some CI job that reuses this cached image. pyo3's `auto-initialize` links +# libpython, and the base image ships python3 without it, so check the .so too. +RUN cargo --version \ + && cargo +nightly fmt --version \ + && cargo clippy --version \ + && ls /usr/lib/*/libpython3*.so \ + && r0vm --version \ + && cargo risczero --version \ + && cargo nextest --version \ + && taplo --version \ + && cargo machete --version \ + && cargo deny --version \ + && just --version \ + && docker --version \ + && docker compose version \ + && docker buildx version diff --git a/.github/workflows/bench-regression.yml b/.github/workflows/bench-regression.yml index d5d9568a..89c18e32 100644 --- a/.github/workflows/bench-regression.yml +++ b/.github/workflows/bench-regression.yml @@ -9,12 +9,25 @@ on: permissions: contents: read pull-requests: write + packages: read name: bench-regression jobs: + ci-image: + permissions: + contents: read + packages: write + uses: ./.github/workflows/ci-image.yml + crypto-primitives: + needs: ci-image runs-on: ubuntu-latest + container: + image: ${{ needs.ci-image.outputs.image }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} timeout-minutes: 60 steps: - uses: actions/checkout@v5 @@ -24,17 +37,6 @@ jobs: # working tree, so we need the full history. fetch-depth: 0 - - uses: ./.github/actions/install-system-deps - - - uses: ./.github/actions/install-risc0 - - - uses: ./.github/actions/install-logos-blockchain-circuits - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Install active toolchain - run: rustup install - - name: Run criterion-compare against base branch uses: boa-dev/criterion-compare-action@v3 with: diff --git a/.github/workflows/ci-image.yml b/.github/workflows/ci-image.yml new file mode 100644 index 00000000..c93782de --- /dev/null +++ b/.github/workflows/ci-image.yml @@ -0,0 +1,75 @@ +name: CI image + +concurrency: + group: ci-image-${{ github.sha }} + cancel-in-progress: false + +# Resolves the CI image the calling workflow's jobs run in, building and pushing +# it only when it isn't published yet. +on: + workflow_call: + outputs: + image: + description: Image reference to pass to a job's `container:`. + value: ${{ jobs.ci-image.outputs.image }} + +jobs: + ci-image: + runs-on: ubuntu-latest + timeout-minutes: 60 + outputs: + image: ${{ steps.image-ref.outputs.image }} + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} + + # Tagging by content hash means a PR that touches the Dockerfile or the + # toolchain builds and tests against its own image, while every other PR + # reuses the one already in the registry. + - name: Compute image reference + id: image-ref + run: | + repo="$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')" + tag="$(cat .github/docker/ci.Dockerfile rust-toolchain.toml | sha256sum | cut -c1-16)" + echo "image=ghcr.io/${repo}/ci:${tag}" >> "$GITHUB_OUTPUT" + echo "CI image: ghcr.io/${repo}/ci:${tag}" + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Check whether the image is already published + id: probe + run: | + if docker manifest inspect "${{ steps.image-ref.outputs.image }}" > /dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Image already published, skipping build." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Image not published yet, building it." + fi + + - name: Set up Docker Buildx + if: steps.probe.outputs.exists == 'false' + uses: docker/setup-buildx-action@v3 + + # A pull_request from a fork gets a read-only GITHUB_TOKEN, so this step + # fails there. It only runs when the fork changed the image, which is the + # case that needs a maintainer's eyes anyway. + - name: Build and push CI image + if: steps.probe.outputs.exists == 'false' + uses: docker/build-push-action@v5 + with: + context: . + file: ./.github/docker/ci.Dockerfile + push: true + tags: ${{ steps.image-ref.outputs.image }} + # Links the package to the repo, so it shows up there and inherits its + # access rules. + labels: org.opencontainers.image.source=https://github.com/${{ github.repository }} + cache-from: type=gha,scope=ci-image + cache-to: type=gha,mode=max,scope=ci-image diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8c588b0..88be5dab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ on: push: branches: - main + - dev paths-ignore: - "**.md" - "!.github/workflows/*.yml" @@ -14,67 +15,91 @@ on: permissions: contents: read pull-requests: read + packages: read name: General jobs: + ci-image: + permissions: + contents: read + packages: write + uses: ./.github/workflows/ci-image.yml + fmt-rs: + needs: ci-image runs-on: ubuntu-latest + container: + image: ${{ needs.ci-image.outputs.image }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v5 with: ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - - name: Install nightly toolchain for rustfmt - run: rustup install nightly --profile minimal --component rustfmt - - name: Check Rust files are formatted run: cargo +nightly fmt --check fmt-toml: + needs: ci-image runs-on: ubuntu-latest + container: + image: ${{ needs.ci-image.outputs.image }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v5 with: ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - - name: Install taplo-cli - run: cargo install --locked taplo-cli - + # No path argument: taplo reads a `.` as a literal file, excludes it for + # not being a .toml, and checks nothing. - name: Check TOML files are formatted - run: taplo fmt --check . + run: taplo fmt --check machete: + needs: ci-image runs-on: ubuntu-latest + container: + image: ${{ needs.ci-image.outputs.image }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v5 with: ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - - name: Install active toolchain - run: rustup install - - - name: Install cargo-machete - run: cargo install cargo-machete - - name: Check for unused dependencies run: cargo machete deny: + needs: ci-image runs-on: ubuntu-latest + container: + image: ${{ needs.ci-image.outputs.image }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v5 with: ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - - name: Install cargo-deny - run: cargo install --locked cargo-deny - - name: Check licenses and advisories run: cargo deny check lint: + needs: ci-image runs-on: ubuntu-latest + container: + image: ${{ needs.ci-image.outputs.image }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} timeout-minutes: 60 name: lint @@ -83,18 +108,11 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - - uses: ./.github/actions/install-system-deps - - - uses: ./.github/actions/install-risc0 - - - name: Install active toolchain - run: rustup install - - name: Restore Rust cache uses: Swatinem/rust-cache@v2 with: shared-key: ci-rust-cache - save-if: ${{ github.ref == 'refs/heads/main' }} + save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' }} - name: Lint workspace env: @@ -107,6 +125,35 @@ jobs: run: cargo clippy -p "*program" -- -D warnings unit-tests: + needs: ci-image + runs-on: ubuntu-latest + container: + image: ${{ needs.ci-image.outputs.image }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} + + - name: Restore Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: ci-rust-cache + save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' }} + + - name: Run tests + env: + RISC0_DEV_MODE: "1" + RUST_LOG: "info" + run: cargo nextest run --workspace --exclude integration_tests --exclude test_fixtures --all-features + + # Not a `container:` job: the tests drive the host's Docker daemon through + # testcontainers, so they run in the CI image via `run-in-ci-image` instead. + test-fixtures-tests: + needs: ci-image runs-on: ubuntu-latest timeout-minutes: 60 steps: @@ -114,29 +161,33 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - - uses: ./.github/actions/install-system-deps - - - uses: ./.github/actions/install-risc0 - - - name: Install active toolchain - run: rustup install + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Restore Rust cache uses: Swatinem/rust-cache@v2 with: shared-key: ci-rust-cache - save-if: ${{ github.ref == 'refs/heads/main' }} + save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' }} - - name: Install nextest - run: cargo install --locked cargo-nextest - - - name: Run tests - env: - RISC0_DEV_MODE: "1" - RUST_LOG: "info" - run: cargo nextest run --workspace --exclude integration_tests --all-features + - name: Run test_fixtures tests + uses: ./.github/actions/run-in-ci-image + with: + image: ${{ needs.ci-image.outputs.image }} + env: | + RISC0_DEV_MODE=1 + RUST_LOG=info + run: cargo nextest run -p test_fixtures + # Not a `container:` job: the test binaries bake CARGO_MANIFEST_DIR in at + # compile time, so the archive has to be built on the same path the matrix + # jobs later run it from. integration-tests-prebuild: + needs: ci-image runs-on: ubuntu-latest outputs: targets: ${{ steps.discover-targets.outputs.targets }} @@ -145,26 +196,25 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - - uses: ./.github/actions/install-system-deps - - - uses: ./.github/actions/install-risc0 - - - name: Install active toolchain - run: rustup install + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Restore Rust cache uses: Swatinem/rust-cache@v2 with: shared-key: ci-rust-cache - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Install nextest - run: cargo install --locked cargo-nextest + save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' }} - name: Build integration test archive - env: - RISC0_DEV_MODE: "1" - run: cargo nextest archive -p integration_tests --archive-file integration-tests.tar.zst --no-pager + uses: ./.github/actions/run-in-ci-image + with: + image: ${{ needs.ci-image.outputs.image }} + env: RISC0_DEV_MODE=1 + run: cargo nextest archive -p integration_tests --archive-file integration-tests.tar.zst --no-pager - name: Upload integration test archive uses: actions/upload-artifact@v4 @@ -172,15 +222,22 @@ jobs: name: integration-tests-archive path: integration-tests.tar.zst + - name: List integration test binaries + uses: ./.github/actions/run-in-ci-image + with: + image: ${{ needs.ci-image.outputs.image }} + run: | + cargo nextest list \ + --archive-file integration-tests.tar.zst \ + --list-type binaries-only \ + --message-format json \ + --no-pager > integration-tests-binaries.json + + # $GITHUB_OUTPUT is outside the mounted workspace, so the container cannot + # write to it. jq is on the runner, so do this pass here. - name: Discover integration test targets from archive id: discover-targets run: | - cargo nextest list \ - --archive-file integration-tests.tar.zst \ - --list-type binaries-only \ - --message-format json \ - --no-pager > integration-tests-binaries.json - targets_json="$(jq -c '[."rust-binaries" | to_entries[] | select(.value.kind == "test" and .value."binary-name" != "tps") | .value."binary-name"] | sort | unique' integration-tests-binaries.json)" if [[ "$targets_json" == "[]" ]]; then @@ -192,7 +249,7 @@ jobs: echo "Discovered integration targets: $targets_json" integration-tests: - needs: integration-tests-prebuild + needs: [ci-image, test-fixtures-tests, integration-tests-prebuild] runs-on: ubuntu-latest timeout-minutes: 90 strategy: @@ -205,54 +262,61 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - - uses: ./.github/actions/install-system-deps - - - uses: ./.github/actions/install-risc0 - - - name: Install active toolchain - run: rustup install + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Download integration test archive uses: actions/download-artifact@v4 with: name: integration-tests-archive - - name: Install nextest - run: cargo install --locked cargo-nextest - - name: Run tests - env: - RISC0_DEV_MODE: "1" - RUST_LOG: "info" - run: cargo nextest run --archive-file integration-tests.tar.zst -E "binary(${{ matrix.target }})" + uses: ./.github/actions/run-in-ci-image + with: + image: ${{ needs.ci-image.outputs.image }} + env: | + RISC0_DEV_MODE=1 + RUST_LOG=info + run: cargo nextest run --archive-file integration-tests.tar.zst -E "binary(${{ matrix.target }})" valid-proof-test: + needs: ci-image + if: github.event_name == 'push' runs-on: ubuntu-latest - timeout-minutes: 90 + timeout-minutes: 150 steps: - uses: actions/checkout@v5 with: ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - - uses: ./.github/actions/install-system-deps - - - uses: ./.github/actions/install-risc0 - - - name: Install active toolchain - run: rustup install + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Restore Rust cache uses: Swatinem/rust-cache@v2 with: shared-key: ci-rust-cache - save-if: ${{ github.ref == 'refs/heads/main' }} + save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' }} - name: Test valid proof - env: - RUST_LOG: "info" - run: cargo test -p integration_tests -- --exact private::private_transfer_to_owned_account + uses: ./.github/actions/run-in-ci-image + with: + image: ${{ needs.ci-image.outputs.image }} + env: RUST_LOG=info + run: cargo test -p integration_tests -- --exact private::private_transfer_to_owned_account + # `just build-artifacts` drives the host's Docker daemon via `cargo risczero + # build`, so it goes through the image rather than `container:`. artifacts: + needs: ci-image runs-on: ubuntu-latest timeout-minutes: 60 @@ -262,19 +326,24 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - - uses: ./.github/actions/install-risc0 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Restore Rust cache uses: Swatinem/rust-cache@v2 with: shared-key: ci-rust-cache - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Install just - run: cargo install --locked just + save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' }} - name: Build artifacts - run: just build-artifacts + uses: ./.github/actions/run-in-ci-image + with: + image: ${{ needs.ci-image.outputs.image }} + run: just build-artifacts - name: Check if artifacts match repository run: | diff --git a/.gitignore b/.gitignore index 4c1a18c8..f32b258c 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,5 @@ result wallet-ffi/wallet_ffi.h bedrock_signing_key integration_tests/configs/debug/ -venv/ - -keycard_wallet/python/__pycache__/ -keycard_wallet/python/keycard-py/ .DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 284e3798..b15693b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,12 +57,16 @@ Before merging a PR, consider squashing non-meaningful commits. E.g.: Could be squashed to an empty commit if they belong to the same PR. +## Default branch + +By default all PRs must be directed into the `dev` branch. This helps us to keep releases stable. + ## Branch workflow -When bringing your feature branch up to date, prefer rebasing on top of `main`. +When bringing your feature branch up to date, prefer rebasing on top of `dev`. -- Preferred: `git rebase main` -- Avoid: `git merge main` in feature branches +- Preferred: `git rebase dev` +- Avoid: `git merge dev` in feature branches This keeps commit history cleaner and makes reviews easier. diff --git a/Cargo.lock b/Cargo.lock index 84b5e1d6..e4eab65f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,6 +45,16 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "aes" version = "0.8.4" @@ -56,16 +66,27 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "aes-gcm" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes", + "aead 0.5.2", + "aes 0.8.4", "cipher 0.4.4", - "ctr", + "ctr 0.9.2", "ghash", "subtle", ] @@ -268,7 +289,7 @@ dependencies = [ "digest 0.10.7", "fnv", "merlin", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -478,7 +499,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom 7.1.3", + "nom", "num-traits", "rusticata-macros", "thiserror 2.0.18", @@ -716,7 +737,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ - "base64", + "base64 0.22.1", "http 1.4.1", "log", "url", @@ -813,7 +834,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core 0.5.6", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -910,6 +931,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base256emoji" version = "1.0.2" @@ -926,6 +953,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6107fe1be6682a68940da878d9e9f5e90ca5745b3dec9fd1bb393c8777d4f581" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -1048,6 +1081,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bollard" version = "0.20.2" @@ -1055,7 +1097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" dependencies = [ "async-stream", - "base64", + "base64 0.22.1", "bitflags 2.12.1", "bollard-buildkit-proto", "bollard-stubs", @@ -1112,7 +1154,7 @@ version = "1.52.1-rc.29.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" dependencies = [ - "base64", + "base64 0.22.1", "bollard-buildkit-proto", "bytes", "prost 0.14.3", @@ -1151,14 +1193,33 @@ name = "bridge_core" version = "0.1.0" dependencies = [ "lee_core", + "risc0-zkvm", "serde", ] +[[package]] +name = "bridge_lock_core" +version = "0.1.0" +dependencies = [ + "lee_core", + "serde", +] + +[[package]] +name = "bridge_lock_program" +version = "0.1.0" +dependencies = [ + "bridge_lock_core", + "cross_zone_outbox_core", + "lee_core", + "risc0-zkvm", + "wrapped_token_core", +] + [[package]] name = "bridge_program" version = "0.1.0" dependencies = [ - "authenticated_transfer_core", "bridge_core", "lee_core", "vault_core", @@ -1279,6 +1340,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "cbindgen" version = "0.29.3" @@ -1310,6 +1380,18 @@ dependencies = [ "shlex 2.0.1", ] +[[package]] +name = "ccm" +version = "0.6.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4edea5ea70a1285565ac264767613d6c88351a9a0557e7af793a0942590baaed" +dependencies = [ + "aead 0.6.1", + "cipher 0.5.2", + "ctr 0.10.1", + "subtle", +] + [[package]] name = "cesu8" version = "1.1.0" @@ -1322,7 +1404,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom 7.1.3", + "nom", ] [[package]] @@ -1349,6 +1431,26 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chain_state" +version = "0.1.0" +dependencies = [ + "anyhow", + "borsh", + "common", + "futures", + "lee", + "lee_core", + "log", + "logos-blockchain-core", + "logos-blockchain-zone-sdk", + "serde", + "serde_json", + "testnet_initial_state", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "chkstk_stub" version = "0.1.0" @@ -1541,7 +1643,7 @@ version = "0.1.0" dependencies = [ "anyhow", "authenticated_transfer_core", - "base64", + "base64 0.22.1", "borsh", "clock_core", "hex", @@ -1552,7 +1654,7 @@ dependencies = [ "programs", "serde", "serde_with", - "sha2", + "sha2 0.10.9", "system_accounts", "thiserror 2.0.18", ] @@ -1765,6 +1867,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1833,6 +1941,78 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "cross_zone" +version = "0.1.0" +dependencies = [ + "bridge_lock_core", + "cross_zone_inbox_core", + "lee", + "lee_core", + "ping_core", + "programs", + "risc0-zkvm", + "serde", + "wrapped_token_core", +] + +[[package]] +name = "cross_zone_chat" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum 0.8.9", + "common", + "cross_zone_inbox_core", + "cross_zone_outbox_core", + "env_logger", + "lee", + "log", + "ping_core", + "programs", + "risc0-zkvm", + "sequencer_service_rpc", + "serde", + "test_fixtures", + "tokio", +] + +[[package]] +name = "cross_zone_inbox_core" +version = "0.1.0" +dependencies = [ + "borsh", + "lee_core", + "risc0-zkvm", + "serde", +] + +[[package]] +name = "cross_zone_inbox_program" +version = "0.1.0" +dependencies = [ + "cross_zone_inbox_core", + "lee_core", +] + +[[package]] +name = "cross_zone_outbox_core" +version = "0.1.0" +dependencies = [ + "borsh", + "lee_core", + "risc0-zkvm", + "serde", +] + +[[package]] +name = "cross_zone_outbox_program" +version = "0.1.0" +dependencies = [ + "cross_zone_outbox_core", + "lee_core", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1854,9 +2034,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -1885,6 +2065,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.2", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1914,7 +2110,6 @@ dependencies = [ "criterion", "key_protocol", "lee_core", - "rand 0.8.6", ] [[package]] @@ -1926,6 +2121,15 @@ dependencies = [ "cipher 0.4.4", ] +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1933,6 +2137,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -2109,7 +2314,7 @@ checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ "asn1-rs", "displaydoc", - "nom 7.1.3", + "nom", "num-bigint 0.4.6", "num-traits", "rusticata-macros", @@ -2231,7 +2436,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", + "const-oid 0.10.2", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -2310,7 +2517,7 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" dependencies = [ - "base64", + "base64 0.22.1", "serde", "serde_json", ] @@ -2361,13 +2568,28 @@ checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der 0.7.10", "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "serdect", - "signature", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "serdect 0.2.0", + "signature 2.2.0", "spki 0.7.3", ] +[[package]] +name = "ecdsa" +version = "0.17.0-rc.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c72d1455753a703ad4b90ed2a759f2bc4562024a303176439cf6e593b5ade4" +dependencies = [ + "der 0.8.0", + "digest 0.11.3", + "elliptic-curve 0.14.0", + "rfc6979 0.6.0-pre.0", + "signature 3.0.0", + "spki 0.8.0", + "zeroize", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -2376,7 +2598,7 @@ checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8 0.10.2", "serde", - "signature", + "signature 2.2.0", ] [[package]] @@ -2389,7 +2611,7 @@ dependencies = [ "ed25519", "rand_core 0.6.4", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -2434,17 +2656,38 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", - "crypto-bigint", + "base16ct 0.2.0", + "crypto-bigint 0.5.5", "digest 0.10.7", - "ff", + "ff 0.13.1", "generic-array 0.14.7", - "group", + "group 0.13.0", "pem-rfc7468", "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", - "serdect", + "sec1 0.7.3", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3273f1195b6f6253ebda493d6742c8baa9b26a291674cd96d92a0f09e90e9b46" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff 0.14.0", + "group 0.14.0", + "hkdf 0.13.0", + "hybrid-array", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", "subtle", "zeroize", ] @@ -2724,6 +2967,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "ff_derive" version = "0.13.1" @@ -2783,15 +3036,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared 0.1.1", -] - [[package]] name = "foreign-types" version = "0.5.0" @@ -2799,7 +3043,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared 0.3.1", + "foreign-types-shared", ] [[package]] @@ -2813,12 +3057,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -3137,11 +3375,22 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "guardian" version = "1.3.0" @@ -3349,7 +3598,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -3361,6 +3619,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "hmac-sha512" version = "1.1.12" @@ -3481,7 +3748,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "ctutils", + "subtle", "typenum", + "zeroize", ] [[package]] @@ -3567,29 +3836,13 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -3601,11 +3854,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.4", - "system-configuration 0.7.0", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -3850,21 +4101,28 @@ dependencies = [ "anyhow", "arc-swap", "async-stream", - "authenticated_transfer_core", "borsh", + "chain_state", "common", + "cross_zone", + "cross_zone_inbox_core", "futures", + "hex", "humantime-serde", "lee", "lee_core", "log", "logos-blockchain-core", "logos-blockchain-zone-sdk", + "ping_core", + "programs", + "risc0-zkvm", "serde", "serde_json", "storage", "tempfile", "testnet_initial_state", + "thiserror 2.0.18", "tokio", "url", ] @@ -3909,9 +4167,10 @@ version = "0.1.0" dependencies = [ "anyhow", "base58", - "base64", + "base64 0.22.1", "common", "hex", + "indexer_core", "lee", "lee_core", "schemars 1.2.1", @@ -3981,6 +4240,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ + "block-padding", "hybrid-array", ] @@ -4010,12 +4270,13 @@ dependencies = [ "anyhow", "associated_token_account_core", "authenticated_transfer_core", - "borsh", "bridge_core", + "bridge_lock_core", "bytesize", "common", + "cross_zone_inbox_core", + "cross_zone_outbox_core", "faucet_core", - "futures", "hex", "indexer_ffi", "indexer_service_protocol", @@ -4025,12 +4286,10 @@ dependencies = [ "lee_core", "log", "logos-blockchain-core", - "logos-blockchain-http-api-common", "logos-blockchain-key-management-system-service", - "logos-blockchain-zone-sdk", - "num-bigint 0.4.6", + "ping_core", "programs", - "reqwest", + "risc0-zkvm", "sequencer_core", "sequencer_service_rpc", "serde_json", @@ -4038,11 +4297,13 @@ dependencies = [ "tempfile", "test_fixtures", "test_programs", + "testnet_initial_state", "token_core", "tokio", "vault_core", "wallet", "wallet-ffi", + "wrapped_token_core", ] [[package]] @@ -4266,7 +4527,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf36eb27f8e13fa93dcb50ccb44c417e25b818cfa1a481b5470cd07b19c60b98" dependencies = [ - "base64", + "base64 0.22.1", "futures-channel", "futures-util", "gloo-net", @@ -4319,7 +4580,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790bedefcec85321e007ff3af84b4e417540d5c87b3c9779b9e247d1bcc3dab8" dependencies = [ - "base64", + "base64 0.22.1", "http-body", "hyper", "hyper-rustls", @@ -4421,12 +4682,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ "cfg-if", - "ecdsa", - "elliptic-curve", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", "once_cell", - "serdect", - "sha2", - "signature", + "serdect 0.2.0", + "sha2 0.10.9", + "signature 2.2.0", +] + +[[package]] +name = "k256" +version = "0.14.0-rc.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "905d38bdbb43bb506efa0a428b3e969ff244549832a86b18591492f503adfe37" +dependencies = [ + "cpubits", + "ecdsa 0.17.0-rc.22", + "elliptic-curve 0.14.0", + "primeorder", + "sha2 0.11.0", + "signature 3.0.0", + "wnaf", ] [[package]] @@ -4441,11 +4717,11 @@ dependencies = [ [[package]] name = "keccak" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +source = "git+https://github.com/logos-blockchain/sponges?rev=3a56e99771beedf04946eab21a4a62adc2951377#3a56e99771beedf04946eab21a4a62adc2951377" dependencies = [ "cfg-if", "cpufeatures 0.3.0", + "risc0-zkvm", ] [[package]] @@ -4471,25 +4747,53 @@ dependencies = [ "hex", "hmac-sha512", "itertools 0.14.0", - "k256", + "k256 0.13.4", "lee", "lee_core", "ml-kem", "rand 0.8.6", "serde", - "sha2", + "sha2 0.10.9", "thiserror 2.0.18", ] +[[package]] +name = "keycard-rs" +version = "0.1.0" +source = "git+https://github.com/keycard-tech/keycard-rs?rev=9535a657ba04b1e6916de51777e22b4837c1a84d#9535a657ba04b1e6916de51777e22b4837c1a84d" +dependencies = [ + "aes 0.9.1", + "base64 0.21.7", + "cbc", + "ccm", + "generic-array 0.14.7", + "getrandom 0.2.17", + "getrandom 0.4.2", + "hkdf 0.13.0", + "hmac 0.13.0", + "k256 0.14.0-rc.14", + "pbkdf2", + "pcsc", + "rand_core 0.6.4", + "sha2 0.11.0", + "sha3 0.12.0", + "thiserror 1.0.69", + "typenum", + "zeroize", +] + [[package]] name = "keycard_wallet" version = "0.1.0" dependencies = [ + "bip39", + "hex", + "keycard-rs", "lee", "log", - "pyo3", - "serde", - "serde_json", + "pcsc", + "rand 0.8.6", + "thiserror 2.0.18", "zeroize", ] @@ -4553,10 +4857,9 @@ dependencies = [ "anyhow", "borsh", "build_utils", - "env_logger", "hex", "hex-literal 1.1.0", - "k256", + "k256 0.13.4", "lee_core", "log", "rand 0.8.6", @@ -4564,11 +4867,10 @@ dependencies = [ "risc0-zkvm", "serde", "serde_with", - "sha2", + "sha2 0.10.9", "test-case", "test_methods", "thiserror 2.0.18", - "token_core", ] [[package]] @@ -4595,7 +4897,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efa3982e7fe36c1de68f91f3c9083124f389a975523881f3d7e3363362feda41" dependencies = [ "any_spawner", - "base64", + "base64 0.22.1", "cfg-if", "either_of", "futures", @@ -4797,7 +5099,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da974775c5ccbb6bd64be7f53f75e8321542e28f21563a416574dbe4d5447eae" dependencies = [ "any_spawner", - "base64", + "base64 0.22.1", "codee", "futures", "hydration_context", @@ -4988,7 +5290,7 @@ checksum = "d558548fa3b5a8e9b66392f785921e363c57c05dcadfda4db0d41ae82d313e4a" dependencies = [ "async-channel", "asynchronous-codec", - "base64", + "base64 0.22.1", "byteorder", "bytes", "either", @@ -5007,7 +5309,7 @@ dependencies = [ "rand 0.8.6", "regex", "serde", - "sha2", + "sha2 0.10.9", "tracing", "web-time", ] @@ -5042,13 +5344,13 @@ dependencies = [ "asn1_der", "bs58", "ed25519-dalek", - "hkdf", - "k256", + "hkdf 0.12.4", + "k256 0.13.4", "multihash", "prost 0.14.3", "rand 0.8.6", "serde", - "sha2", + "sha2 0.10.9", "thiserror 2.0.18", "tracing", "zeroize", @@ -5074,7 +5376,7 @@ dependencies = [ "quick-protobuf-codec", "rand 0.8.6", "serde", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror 2.0.18", "tracing", @@ -5329,10 +5631,20 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" +[[package]] +name = "logos-blockchain-blake2btree" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" +dependencies = [ + "blake2", + "logos-blockchain-dynamic-merkle", + "logos-blockchain-merkle-tree", +] + [[package]] name = "logos-blockchain-blend-crypto" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "blake2", "logos-blockchain-groth16", @@ -5345,8 +5657,8 @@ dependencies = [ [[package]] name = "logos-blockchain-blend-message" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "blake2", "derivative", @@ -5354,6 +5666,7 @@ dependencies = [ "itertools 0.14.0", "logos-blockchain-blend-crypto", "logos-blockchain-blend-proofs", + "logos-blockchain-codec", "logos-blockchain-core", "logos-blockchain-cryptarchia-engine", "logos-blockchain-groth16", @@ -5369,13 +5682,14 @@ dependencies = [ [[package]] name = "logos-blockchain-blend-proofs" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "ed25519-dalek", "generic-array 1.4.3", "hex", "logos-blockchain-blend-crypto", + "logos-blockchain-codec", "logos-blockchain-groth16", "logos-blockchain-pol", "logos-blockchain-poq", @@ -5389,8 +5703,8 @@ dependencies = [ [[package]] name = "logos-blockchain-chain-broadcast-service" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "async-trait", "derivative", @@ -5403,8 +5717,8 @@ dependencies = [ [[package]] name = "logos-blockchain-chain-service" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "async-trait", "bytes", @@ -5484,8 +5798,8 @@ dependencies = [ [[package]] name = "logos-blockchain-circuits-prover" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "rust-rapidsnark", ] @@ -5509,10 +5823,33 @@ dependencies = [ "libc", ] +[[package]] +name = "logos-blockchain-codec" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" +dependencies = [ + "hex", + "logos-blockchain-codec-macros", + "logos-blockchain-groth16", + "logos-blockchain-utils", + "thiserror 2.0.18", +] + +[[package]] +name = "logos-blockchain-codec-macros" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" +dependencies = [ + "hex", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "logos-blockchain-common-http-client" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "futures", "hex", @@ -5534,17 +5871,18 @@ dependencies = [ [[package]] name = "logos-blockchain-core" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "ark-ff", "bincode", "blake2", "bytes", "const-hex", - "futures", "hex", + "logos-blockchain-blake2btree", "logos-blockchain-blend-proofs", + "logos-blockchain-codec", "logos-blockchain-cryptarchia-engine", "logos-blockchain-groth16", "logos-blockchain-key-management-system-keys", @@ -5556,9 +5894,7 @@ dependencies = [ "logos-blockchain-utils", "logos-blockchain-utxotree", "multiaddr", - "nom 8.0.0", "num-bigint 0.4.6", - "rpds", "serde", "strum", "thiserror 2.0.18", @@ -5568,11 +5904,13 @@ dependencies = [ [[package]] name = "logos-blockchain-cryptarchia-engine" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ + "logos-blockchain-codec", "logos-blockchain-pol", "logos-blockchain-utils", + "rpds", "serde", "serde_with", "thiserror 2.0.18", @@ -5583,8 +5921,8 @@ dependencies = [ [[package]] name = "logos-blockchain-cryptarchia-sync" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "bytes", "futures", @@ -5600,10 +5938,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "logos-blockchain-dynamic-merkle" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" +dependencies = [ + "rpds", + "serde", +] + [[package]] name = "logos-blockchain-groth16" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "ark-bn254", "ark-ec", @@ -5613,6 +5960,7 @@ dependencies = [ "generic-array 1.4.3", "hex", "num-bigint 0.4.6", + "rand 0.8.6", "serde", "serde_json", "thiserror 2.0.18", @@ -5620,8 +5968,8 @@ dependencies = [ [[package]] name = "logos-blockchain-http-api-common" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "axum 0.7.9", "logos-blockchain-core", @@ -5641,14 +5989,15 @@ dependencies = [ [[package]] name = "logos-blockchain-key-management-system-keys" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "async-trait", "bytes", "ed25519-dalek", "generic-array 1.4.3", "hex", + "logos-blockchain-codec", "logos-blockchain-groth16", "logos-blockchain-key-management-system-macros", "logos-blockchain-log-targets", @@ -5668,8 +6017,8 @@ dependencies = [ [[package]] name = "logos-blockchain-key-management-system-macros" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "proc-macro2", "quote", @@ -5678,8 +6027,8 @@ dependencies = [ [[package]] name = "logos-blockchain-key-management-system-operators" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "async-trait", "logos-blockchain-blend-proofs", @@ -5688,6 +6037,7 @@ dependencies = [ "logos-blockchain-key-management-system-keys", "logos-blockchain-log-targets", "logos-blockchain-poseidon2", + "logos-blockchain-utils", "logos-blockchain-utxotree", "tokio", "tracing", @@ -5695,8 +6045,8 @@ dependencies = [ [[package]] name = "logos-blockchain-key-management-system-service" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "async-trait", "logos-blockchain-key-management-system-keys", @@ -5712,8 +6062,8 @@ dependencies = [ [[package]] name = "logos-blockchain-ledger" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "derivative", "logos-blockchain-blend-crypto", @@ -5738,8 +6088,8 @@ dependencies = [ [[package]] name = "logos-blockchain-libp2p" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "async-trait", "backon", @@ -5767,30 +6117,42 @@ dependencies = [ [[package]] name = "logos-blockchain-log-targets" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "logos-blockchain-log-targets-macros", ] [[package]] name = "logos-blockchain-log-targets-macros" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "logos-blockchain-merkle-tree" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" +dependencies = [ + "logos-blockchain-dynamic-merkle", + "rpds", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "logos-blockchain-mmr" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "ark-ff", "logos-blockchain-groth16", "logos-blockchain-poseidon2", + "logos-blockchain-utils", "rpds", "serde", "thiserror 2.0.18", @@ -5798,8 +6160,8 @@ dependencies = [ [[package]] name = "logos-blockchain-network-service" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "async-trait", "futures", @@ -5808,6 +6170,7 @@ dependencies = [ "logos-blockchain-libp2p", "logos-blockchain-log-targets", "logos-blockchain-tracing", + "logos-blockchain-utils", "overwatch", "rand 0.8.6", "rand_chacha 0.3.1", @@ -5819,8 +6182,8 @@ dependencies = [ [[package]] name = "logos-blockchain-poc" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "logos-blockchain-circuits-poc-sys", "logos-blockchain-circuits-prover", @@ -5836,8 +6199,8 @@ dependencies = [ [[package]] name = "logos-blockchain-pol" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "astro-float", "logos-blockchain-circuits-pol-sys", @@ -5856,8 +6219,8 @@ dependencies = [ [[package]] name = "logos-blockchain-poq" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "logos-blockchain-circuits-poq-sys", "logos-blockchain-circuits-prover", @@ -5875,8 +6238,8 @@ dependencies = [ [[package]] name = "logos-blockchain-poseidon2" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "ark-bn254", "ark-ff", @@ -5886,8 +6249,8 @@ dependencies = [ [[package]] name = "logos-blockchain-proofs-error" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "logos-blockchain-circuits-types", "logos-blockchain-groth16", @@ -5897,24 +6260,24 @@ dependencies = [ [[package]] name = "logos-blockchain-services-utils" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "async-trait", + "bytes", "futures", "log", "logos-blockchain-log-targets", "overwatch", "serde", - "serde_json", "thiserror 2.0.18", "tracing", ] [[package]] name = "logos-blockchain-storage-service" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "async-trait", "bytes", @@ -5922,7 +6285,9 @@ dependencies = [ "logos-blockchain-core", "logos-blockchain-cryptarchia-engine", "logos-blockchain-log-targets", + "logos-blockchain-services-utils", "logos-blockchain-tracing", + "logos-blockchain-utils", "overwatch", "serde", "thiserror 2.0.18", @@ -5932,8 +6297,8 @@ dependencies = [ [[package]] name = "logos-blockchain-time-service" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "async-trait", "futures", @@ -5955,8 +6320,8 @@ dependencies = [ [[package]] name = "logos-blockchain-tracing" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "flate2", "logos-blockchain-log-targets", @@ -5981,15 +6346,17 @@ dependencies = [ [[package]] name = "logos-blockchain-utils" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "async-trait", "blake2", "cipher 0.4.4", "const-hex", + "futures", "humantime", "logos-blockchain-log-targets", + "multiaddr", "overwatch", "rand 0.8.6", "serde", @@ -5998,27 +6365,27 @@ dependencies = [ "serde_yaml", "thiserror 2.0.18", "time", + "tokio", "tracing", ] [[package]] name = "logos-blockchain-utxotree" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "ark-ff", - "logos-blockchain-groth16", + "logos-blockchain-dynamic-merkle", + "logos-blockchain-merkle-tree", "logos-blockchain-poseidon2", - "num-bigint 0.4.6", "rpds", "serde", - "thiserror 2.0.18", ] [[package]] name = "logos-blockchain-zksign" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "logos-blockchain-circuits-prover", "logos-blockchain-circuits-signature-sys", @@ -6029,6 +6396,7 @@ dependencies = [ "logos-blockchain-proofs-error", "num-bigint 0.4.6", "serde", + "serde-big-array", "serde_json", "thiserror 2.0.18", "tracing", @@ -6036,8 +6404,8 @@ dependencies = [ [[package]] name = "logos-blockchain-zone-sdk" -version = "0.1.2" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=d8711bbc3d43d3ef9755ef9b73af32fd0f703160#d8711bbc3d43d3ef9755ef9b73af32fd0f703160" +version = "0.0.0" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=0dc34e2c5a6e2ff772140378659489a602ee06bc#0dc34e2c5a6e2ff772140378659489a602ee06bc" dependencies = [ "async-trait", "futures", @@ -6269,7 +6637,7 @@ dependencies = [ "bitflags 2.12.1", "block", "core-graphics-types", - "foreign-types 0.5.0", + "foreign-types", "log", "objc", "paste", @@ -6432,23 +6800,6 @@ dependencies = [ "unsigned-varint 0.7.2", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "natpmp" version = "0.5.0" @@ -6613,15 +6964,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -6842,49 +7184,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "openssl" -version = "0.10.80" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" -dependencies = [ - "bitflags 2.12.1", - "cfg-if", - "foreign-types 0.3.2", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.116" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "opentelemetry" version = "0.31.0" @@ -7003,7 +7308,7 @@ checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" [[package]] name = "overwatch" version = "0.1.0" -source = "git+https://github.com/logos-co/Overwatch?rev=448c192#448c192895b8311c742b1726a1bb12ee314ad95c" +source = "git+https://github.com/logos-co/Overwatch?rev=ae887f41f5a626c341179026ad7f03953ff2072e#ae887f41f5a626c341179026ad7f03953ff2072e" dependencies = [ "async-trait", "futures", @@ -7018,10 +7323,10 @@ dependencies = [ [[package]] name = "overwatch-derive" version = "0.1.0" -source = "git+https://github.com/logos-co/Overwatch?rev=448c192#448c192895b8311c742b1726a1bb12ee314ad95c" +source = "git+https://github.com/logos-co/Overwatch?rev=ae887f41f5a626c341179026ad7f03953ff2072e#ae887f41f5a626c341179026ad7f03953ff2072e" dependencies = [ "convert_case 0.8.0", - "proc-macro-error2", + "manyhow", "proc-macro2", "quote", "syn 2.0.117", @@ -7109,13 +7414,42 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest 0.11.3", + "hmac 0.13.0", +] + +[[package]] +name = "pcsc" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd833ecf8967e65934c49d3521a175929839bf6d0e497f3bd0d3a2ca08943da" +dependencies = [ + "bitflags 2.12.1", + "pcsc-sys", +] + +[[package]] +name = "pcsc-sys" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ef017e15d2e5592a9e39a346c1dbaea5120bab7ed7106b210ef58ebd97003" +dependencies = [ + "pkg-config", +] + [[package]] name = "pem" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -7177,6 +7511,31 @@ dependencies = [ "token_core", ] +[[package]] +name = "ping_core" +version = "0.1.0" +dependencies = [ + "lee_core", + "serde", +] + +[[package]] +name = "ping_receiver_program" +version = "0.1.0" +dependencies = [ + "lee_core", + "ping_core", +] + +[[package]] +name = "ping_sender_program" +version = "0.1.0" +dependencies = [ + "cross_zone_outbox_core", + "lee_core", + "ping_core", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -7330,6 +7689,32 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0-rc.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e56e6d67fdf5744e9e245ae571450fe584b91f5af261d0e40163b618e53a1f6" +dependencies = [ + "elliptic-curve 0.14.0", + "once_cell", + "primefield", + "serdect 0.4.3", +] + [[package]] name = "privacy_preserving_circuit_program" version = "0.1.0" @@ -7449,15 +7834,20 @@ dependencies = [ "associated_token_account_program", "authenticated_transfer_core", "bridge_core", + "bridge_lock_core", "build_utils", "clock_core", + "cross_zone_inbox_core", + "cross_zone_outbox_core", "faucet_core", "lee", "lee_core", + "ping_core", "risc0-zkvm", "token_core", "token_program", "vault_core", + "wrapped_token_core", ] [[package]] @@ -7575,63 +7965,6 @@ dependencies = [ "parking_lot", ] -[[package]] -name = "pyo3" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c" -dependencies = [ - "libc", - "once_cell", - "portable-atomic", - "pyo3-build-config", - "pyo3-ffi", - "pyo3-macros", -] - -[[package]] -name = "pyo3-build-config" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078" -dependencies = [ - "target-lexicon", -] - -[[package]] -name = "pyo3-ffi" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b" -dependencies = [ - "libc", - "pyo3-build-config", -] - -[[package]] -name = "pyo3-macros" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771" -dependencies = [ - "proc-macro2", - "pyo3-macros-backend", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pyo3-macros-backend" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "quick-protobuf" version = "0.8.1" @@ -8012,9 +8345,9 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", - "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -8023,12 +8356,9 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -8039,7 +8369,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -8065,10 +8394,20 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] +[[package]] +name = "rfc6979" +version = "0.6.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9935425142ac6e252364413291d96c8bc9898d0876a801824c7af4eae397b689" +dependencies = [ + "ctutils", + "hmac 0.13.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -8145,7 +8484,7 @@ dependencies = [ "directories", "hex", "rayon", - "sha2", + "sha2 0.10.9", "tempfile", ] @@ -8205,7 +8544,7 @@ dependencies = [ "risc0-sys", "risc0-zkp", "serde", - "sha2", + "sha2 0.10.9", "tracing", "zip", ] @@ -8342,7 +8681,7 @@ dependencies = [ "bytemuck", "cfg-if", "digest 0.10.7", - "ff", + "ff 0.13.1", "hex", "hex-literal 0.4.1", "metal", @@ -8356,7 +8695,7 @@ dependencies = [ "risc0-sys", "risc0-zkvm-platform", "serde", - "sha2", + "sha2 0.10.9", "stability", "tracing", ] @@ -8403,7 +8742,7 @@ dependencies = [ "rzup", "semver", "serde", - "sha2", + "sha2 0.10.9", "stability", "tempfile", "tracing", @@ -8507,7 +8846,7 @@ dependencies = [ "pkcs1", "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", + "signature 2.2.0", "spki 0.7.3", "subtle", "zeroize", @@ -8558,9 +8897,9 @@ dependencies = [ [[package]] name = "ruint" -version = "1.17.2" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a" +checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970" dependencies = [ "borsh", "proptest", @@ -8617,7 +8956,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom 7.1.3", + "nom", ] [[package]] @@ -8751,7 +9090,7 @@ dependencies = [ "semver", "serde", "serde_with", - "sha2", + "sha2 0.10.9", "strum", "tempfile", "thiserror 2.0.18", @@ -8835,11 +9174,25 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", + "base16ct 0.2.0", "der 0.7.10", "generic-array 0.14.7", "pkcs8 0.10.2", - "serdect", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.0", + "hybrid-array", "subtle", "zeroize", ] @@ -8894,21 +9247,27 @@ dependencies = [ "borsh", "bridge_core", "bytesize", + "chain_state", "chrono", "common", + "cross_zone", + "cross_zone_inbox_core", "faucet_core", "futures", "hex", "humantime-serde", + "itertools 0.14.0", "key_protocol", "lee", "lee_core", "log", "logos-blockchain-core", + "logos-blockchain-http-api-common", "logos-blockchain-key-management-system-service", "logos-blockchain-zone-sdk", "mempool", "num-bigint 0.4.6", + "ping_core", "programs", "rand 0.8.6", "risc0-zkvm", @@ -8921,6 +9280,7 @@ dependencies = [ "testnet_initial_state", "token_core", "tokio", + "tokio-util", "url", "vault_core", ] @@ -8936,6 +9296,7 @@ dependencies = [ "common", "env_logger", "futures", + "hex", "jsonrpsee", "lee", "log", @@ -8977,6 +9338,15 @@ dependencies = [ "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_arrays" version = "0.2.0" @@ -9109,7 +9479,7 @@ version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ - "base64", + "base64 0.22.1", "bs58", "chrono", "hex", @@ -9154,7 +9524,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" dependencies = [ - "base16ct", + "base16ct 0.2.0", + "serde", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", "serde", ] @@ -9165,7 +9545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d60e4c1dfccd91fe0990141f69f1d5cf5679797ad53aa1b45e5bd658eb119f0" dependencies = [ "axum 0.8.9", - "base64", + "base64 0.22.1", "bytes", "const-str 1.1.0", "const_format", @@ -9245,6 +9625,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha3" version = "0.10.9" @@ -9265,6 +9656,17 @@ dependencies = [ "keccak 0.2.0", ] +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.0", + "sponge-cursor", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -9306,6 +9708,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -9375,7 +9787,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e859df029d160cb88608f5d7df7fb4753fd20fdfb4de5644f3d8b8440841721" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures", "http 1.4.1", @@ -9387,9 +9799,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -9414,6 +9826,12 @@ dependencies = [ "der 0.8.0", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "spongefish" version = "0.2.0" @@ -9461,11 +9879,13 @@ dependencies = [ "borsh", "common", "lee", + "log", "programs", "rocksdb", "system_accounts", "tempfile", "thiserror 2.0.18", + "zstd", ] [[package]] @@ -9682,12 +10102,6 @@ dependencies = [ "xattr", ] -[[package]] -name = "target-lexicon" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" - [[package]] name = "tempfile" version = "3.27.0" @@ -9739,16 +10153,20 @@ name = "test_fixtures" version = "0.1.0" dependencies = [ "anyhow", + "bip39", "bytesize", "common", "env_logger", "futures", + "hex", "indexer_service", "jsonrpsee", "key_protocol", "lee", "lee_core", "log", + "logos-blockchain-key-management-system-service", + "num-bigint 0.4.6", "programs", "sequencer_core", "sequencer_service", @@ -9757,7 +10175,9 @@ dependencies = [ "serde_json", "tempfile", "testcontainers", + "time", "tokio", + "tokio-util", "url", "wallet", ] @@ -10020,16 +10440,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -10200,7 +10610,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum 0.8.9", - "base64", + "base64 0.22.1", "bytes", "h2", "http 1.4.1", @@ -10651,7 +11061,7 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ - "base64", + "base64 0.22.1", "flate2", "log", "percent-encoding", @@ -10668,7 +11078,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" dependencies = [ - "base64", + "base64 0.22.1", "http 1.4.1", "httparse", "log", @@ -10858,13 +11268,12 @@ dependencies = [ "log", "optfield", "programs", - "pyo3", "rand 0.8.6", "rpassword", "sequencer_service_rpc", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "system_accounts", "tempfile", "testnet_initial_state", @@ -10882,15 +11291,12 @@ version = "0.1.0" dependencies = [ "bip39", "cbindgen", - "common", "key_protocol", "lee", "lee_core", "programs", "risc0-zkvm", - "sequencer_service_rpc", "serde_json", - "tempfile", "tokio", "vault_core", "wallet", @@ -11050,7 +11456,7 @@ checksum = "d0a659ffe5c7f4538aa6357c07e3d73221cc61eba03bd9a081e14bc91ed09b8c" dependencies = [ "base16", "quote", - "sha2", + "sha2 0.10.9", "syn 2.0.117", ] @@ -11550,6 +11956,34 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wnaf" +version = "0.14.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86421f2a70c9e6cab8d84c99fb62d8761d355bd1285443a7e7ccad15aa515f2" +dependencies = [ + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", +] + +[[package]] +name = "wrapped_token_core" +version = "0.1.0" +dependencies = [ + "lee_core", + "risc0-zkvm", + "serde", +] + +[[package]] +name = "wrapped_token_program" +version = "0.1.0" +dependencies = [ + "lee_core", + "wrapped_token_core", +] + [[package]] name = "writeable" version = "0.6.3" @@ -11587,7 +12021,7 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom 7.1.3", + "nom", "oid-registry", "rusticata-macros", "thiserror 2.0.18", @@ -11802,3 +12236,31 @@ dependencies = [ "log", "simd-adler32", ] + +[[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.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 3e71f293..397eb965 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "lez", "lez/system_accounts", + "lez/chain_state", "lez/sequencer/core", "lez/sequencer/service", "lez/sequencer/service/protocol", @@ -43,6 +44,13 @@ members = [ "lez/programs/pinata_token", "lez/programs/token", "lez/programs/vault", + "lez/programs/cross_zone_inbox", + "lez/programs/cross_zone_outbox", + "lez/programs/bridge_lock", + "lez/programs/wrapped_token", + "lez/programs/ping_sender", + "lez/programs/ping_receiver", + "lez/cross_zone", "test_programs", "test_programs/guest", @@ -58,12 +66,14 @@ members = [ "tools/cycle_bench", "tools/crypto_primitives_bench", "tools/integration_bench", + "tools/cross_zone_chat", ] [workspace.dependencies] lee = { path = "lee/state_machine" } lee_core = { path = "lee/state_machine/core" } common = { path = "lez/common" } +chain_state = { path = "lez/chain_state" } mempool = { path = "lez/mempool" } storage = { path = "lez/storage" } key_protocol = { path = "lee/key_protocol" } @@ -94,6 +104,12 @@ authenticated_transfer_core = { path = "lez/programs/authenticated_transfer/core faucet_core = { path = "lez/programs/faucet/core" } bridge_core = { path = "lez/programs/bridge/core" } vault_core = { path = "lez/programs/vault/core" } +cross_zone_inbox_core = { path = "lez/programs/cross_zone_inbox/core" } +cross_zone_outbox_core = { path = "lez/programs/cross_zone_outbox/core" } +bridge_lock_core = { path = "lez/programs/bridge_lock/core" } +wrapped_token_core = { path = "lez/programs/wrapped_token/core" } +ping_core = { path = "lez/programs/ping_core" } +cross_zone = { path = "lez/cross_zone" } build_utils = { path = "build_utils" } test_programs = { path = "test_programs" } testnet_initial_state = { path = "lez/testnet_initial_state" } @@ -116,6 +132,7 @@ openssl = { version = "0.10", features = ["vendored"] } openssl-probe = { version = "0.1.2" } serde = { version = "1.0.60", default-features = false, features = ["derive"] } serde_json = "1.0.81" +serde_yaml = "0.9.34" serde_with = "3.16.1" actix = "0.13.0" actix-cors = "0.7.1" @@ -146,7 +163,9 @@ base64 = "0.22.1" bip39 = "2.2.0" hmac-sha512 = "1.1.7" chrono = "0.4.41" +time = "0.3" borsh = "1.5.7" +zstd = "0.13" base58 = "0.2.0" itertools = "0.14.0" num-bigint = "0.4.6" @@ -155,19 +174,23 @@ tokio-retry = "0.3.0" schemars = "1.2" async-stream = "0.3.6" -logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "d8711bbc3d43d3ef9755ef9b73af32fd0f703160" } -logos-blockchain-key-management-system-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "d8711bbc3d43d3ef9755ef9b73af32fd0f703160" } -logos-blockchain-core = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "d8711bbc3d43d3ef9755ef9b73af32fd0f703160" } -logos-blockchain-chain-broadcast-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "d8711bbc3d43d3ef9755ef9b73af32fd0f703160" } -logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "d8711bbc3d43d3ef9755ef9b73af32fd0f703160" } -logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "d8711bbc3d43d3ef9755ef9b73af32fd0f703160" } -logos-blockchain-http-api-common = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "d8711bbc3d43d3ef9755ef9b73af32fd0f703160" } +logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "0dc34e2c5a6e2ff772140378659489a602ee06bc" } +logos-blockchain-key-management-system-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "0dc34e2c5a6e2ff772140378659489a602ee06bc" } +logos-blockchain-codec = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "0dc34e2c5a6e2ff772140378659489a602ee06bc" } +logos-blockchain-core = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "0dc34e2c5a6e2ff772140378659489a602ee06bc" } +logos-blockchain-chain-broadcast-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "0dc34e2c5a6e2ff772140378659489a602ee06bc" } +logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "0dc34e2c5a6e2ff772140378659489a602ee06bc" } +logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "0dc34e2c5a6e2ff772140378659489a602ee06bc" } +logos-blockchain-http-api-common = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "0dc34e2c5a6e2ff772140378659489a602ee06bc" } + +keycard-rs = { git = "https://github.com/keycard-tech/keycard-rs", rev = "9535a657ba04b1e6916de51777e22b4837c1a84d" } rocksdb = { version = "0.24.0", default-features = false, features = [ "snappy", "bindgen-runtime", ] } rand = { version = "0.8.5", features = ["std", "std_rng", "getrandom"] } +pcsc = "2" k256 = { version = "0.13.3", features = [ "ecdsa-core", "arithmetic", @@ -181,9 +204,9 @@ elliptic-curve = { version = "0.13.8", features = ["arithmetic"] } actix-web = { version = "4.13.0", default-features = false, features = [ "macros", ] } +axum = "0.8.4" clap = { version = "4.5.42", features = ["derive", "env"] } reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] } -pyo3 = { version = "0.29", features = ["auto-initialize"] } zeroize = "1" criterion = { version = "0.8", features = ["html_reports"] } @@ -194,6 +217,10 @@ opt-level = 'z' lto = true codegen-units = 1 +# Keccak speedup for in-guest ML KEM +[patch.crates-io] +keccak = { git = "https://github.com/logos-blockchain/sponges", rev = "3a56e99771beedf04946eab21a4a62adc2951377" } + # Keep backtraces but drop full DWARF type info to avoid LLD OOM/SIGBUS when # linking large integration-test binaries on resource-constrained CI runners. [profile.dev] diff --git a/Justfile b/Justfile index afd3d9b7..d436689d 100644 --- a/Justfile +++ b/Justfile @@ -6,20 +6,33 @@ default: # ---- Configuration ---- ARTIFACTS := "artifacts" -# Build risc0 program artifacts. +# On macOS the integration-test binary links pyo3 against the CommandLineTools +# Python framework with no embedded rpath, so it needs this to launch. Empty on +# Linux/CI, which is unaffected. +DEMO_ENV := if os() == "macos" { "DYLD_FALLBACK_FRAMEWORK_PATH=/Library/Developer/CommandLineTools/Library/Frameworks" } else { "" } + +# Build risc0 program artifacts and test fixture. build-artifacts: @echo "🔨 Building artifacts" @rm -rf {{ARTIFACTS}} @just build-artifact lee/privacy_preserving_circuit @just build-artifact lez/programs programs + @if [ "${GITHUB_ACTIONS:-}" = "true" ]; then \ + echo "Skipping test fixture regeneration because CI doesn't need it"; \ + else \ + just regenerate-test-fixture; \ + fi + +RISC0_DOCKER_CONTAINER_TAG := "r0.1.91.1" + build-artifact methods_path features="": @echo "Building artifacts for {{methods_path}}" @rm -rf target/{{methods_path}}/riscv32im-risc0-zkvm-elf/docker/*.bin @if [ "{{features}}" = "" ]; then \ - CARGO_TARGET_DIR=target/{{methods_path}} cargo risczero build --manifest-path {{methods_path}}/Cargo.toml; \ + RISC0_DOCKER_CONTAINER_TAG={{RISC0_DOCKER_CONTAINER_TAG}} CARGO_TARGET_DIR=target/{{methods_path}} cargo risczero build --manifest-path {{methods_path}}/Cargo.toml; \ else \ - CARGO_TARGET_DIR=target/{{methods_path}} cargo risczero build --no-default-features --features {{features}} --manifest-path {{methods_path}}/Cargo.toml; \ + RISC0_DOCKER_CONTAINER_TAG={{RISC0_DOCKER_CONTAINER_TAG}} CARGO_TARGET_DIR=target/{{methods_path}} cargo risczero build --no-default-features --features {{features}} --manifest-path {{methods_path}}/Cargo.toml; \ fi @mkdir -p {{ARTIFACTS}}/{{methods_path}} @cp target/{{methods_path}}/riscv32im-risc0-zkvm-elf/docker/*.bin {{ARTIFACTS}}/{{methods_path}} @@ -35,6 +48,11 @@ test: @echo "🧪 Running tests" RISC0_DEV_MODE=1 cargo nextest run --no-fail-fast +# Regenerate the prebuilt sequencer db dump for fast TestContext::new() (needs Docker; commit the dump). +regenerate-test-fixture: + @echo "🧪 Regenerating test fixture" + RISC0_DEV_MODE=1 cargo run -p test_fixtures --bin regenerate_test_fixture + # Run criterion benches: fast crypto primitives, then the slow PPE verify (real proving setup). bench: @echo "📊 Running criterion benches" @@ -48,15 +66,17 @@ run-bedrock: docker compose up # Run Sequencer. Run with RISC0_DEV_MODE=1 to disable proof verification for faster iteration. +# Optional home/port let a second instance run off the same config, e.g. +# `just run-sequencer "" "$TMPDIR/lez-sequencer2" 3041` for the multi-sequencer demo. [working-directory: 'lez/sequencer/service'] -run-sequencer standalone="": +run-sequencer standalone="" home="" port="3040": @echo "🧠 Running sequencer" @if [ "{{standalone}}" = "standalone" ]; then \ echo "🧪 Running in standalone mode"; \ - RUST_LOG=info cargo run --features standalone --release -p sequencer_service configs/debug/sequencer_config.json; \ + RUST_LOG=info cargo run --features standalone --release -p sequencer_service -- configs/debug/sequencer_config.json --port {{port}} {{ if home != "" { "--home " + quote(home) } else { "" } }}; \ else \ echo "🚀 Running in normal mode"; \ - RUST_LOG=info cargo run --release -p sequencer_service configs/debug/sequencer_config.json; \ + RUST_LOG=info cargo run --release -p sequencer_service -- configs/debug/sequencer_config.json --port {{port}} {{ if home != "" { "--home " + quote(home) } else { "" } }}; \ fi # Run Indexer. Run with RISC0_DEV_MODE=1 to disable proof verification for faster iteration. @@ -94,6 +114,26 @@ wallet-import-test-accounts: just run-wallet account list +# Demo: cross-zone ping. Boots two zones on one Bedrock and sends a message from +# zone A to zone B, where the indexer re-derives and verifies it (Option B) +# before ping_receiver records it. Dev mode, no proving. +demo-cross-zone-ping: + @echo "📡 Cross-zone ping demo (message A → B, indexer-verified)" + {{DEMO_ENV}} RISC0_DEV_MODE=1 cargo test -p integration_tests --release --test cross_zone_verified -- --nocapture + +# Demo: cross-zone wrapped-token bridge. Locks a balance on zone A and mints the +# wrapped token to a recipient on zone B over the same verified spine. +demo-cross-zone-bridge: + @echo "🌉 Cross-zone bridge demo (lock on A, mint on B)" + {{DEMO_ENV}} RISC0_DEV_MODE=1 cargo test -p integration_tests --release --test cross_zone_bridge -- --nocapture + +# Demo: interactive cross-zone chat. Boots two zones on one Bedrock and serves a +# local two-column web UI; type in one zone and watch the message cross into the +# other. Two people can chat across the zones. Dev mode, no proving. +cross-zone-chat: + @echo "💬 Cross-zone chat demo — open the printed localhost URL" + {{DEMO_ENV}} RISC0_DEV_MODE=1 cargo run -p cross_zone_chat --release + # Clean runtime data clean: @echo "🧹 Cleaning run artifacts" @@ -101,5 +141,6 @@ clean: rm -rf lez/sequencer/service/rocksdb rm -rf lez/indexer/service/rocksdb* rm -rf lez/wallet/configs/debug/storage.json + rm -rf lez/wallet/configs/debug/statistics.json rm -rf rocksdb* cd bedrock && docker compose down -v diff --git a/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin b/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin index 8db9385b..b46f817a 100644 Binary files a/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin and b/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin differ diff --git a/artifacts/lez/programs/amm.bin b/artifacts/lez/programs/amm.bin index 00f5343d..819b2af3 100644 Binary files a/artifacts/lez/programs/amm.bin and b/artifacts/lez/programs/amm.bin differ diff --git a/artifacts/lez/programs/associated_token_account.bin b/artifacts/lez/programs/associated_token_account.bin index 8f4b95ed..985b8b0d 100644 Binary files a/artifacts/lez/programs/associated_token_account.bin and b/artifacts/lez/programs/associated_token_account.bin differ diff --git a/artifacts/lez/programs/authenticated_transfer.bin b/artifacts/lez/programs/authenticated_transfer.bin index 4f56b0f7..6bf6e53d 100644 Binary files a/artifacts/lez/programs/authenticated_transfer.bin and b/artifacts/lez/programs/authenticated_transfer.bin differ diff --git a/artifacts/lez/programs/bridge.bin b/artifacts/lez/programs/bridge.bin index e4e4ec5e..60c983f1 100644 Binary files a/artifacts/lez/programs/bridge.bin and b/artifacts/lez/programs/bridge.bin differ diff --git a/artifacts/lez/programs/bridge_lock.bin b/artifacts/lez/programs/bridge_lock.bin new file mode 100644 index 00000000..e0854454 Binary files /dev/null and b/artifacts/lez/programs/bridge_lock.bin differ diff --git a/artifacts/lez/programs/clock.bin b/artifacts/lez/programs/clock.bin index 663cc59b..4eeb115c 100644 Binary files a/artifacts/lez/programs/clock.bin and b/artifacts/lez/programs/clock.bin differ diff --git a/artifacts/lez/programs/cross_zone_inbox.bin b/artifacts/lez/programs/cross_zone_inbox.bin new file mode 100644 index 00000000..a95c1998 Binary files /dev/null and b/artifacts/lez/programs/cross_zone_inbox.bin differ diff --git a/artifacts/lez/programs/cross_zone_outbox.bin b/artifacts/lez/programs/cross_zone_outbox.bin new file mode 100644 index 00000000..b81340ec Binary files /dev/null and b/artifacts/lez/programs/cross_zone_outbox.bin differ diff --git a/artifacts/lez/programs/faucet.bin b/artifacts/lez/programs/faucet.bin index b26cfc6f..91e7aeeb 100644 Binary files a/artifacts/lez/programs/faucet.bin and b/artifacts/lez/programs/faucet.bin differ diff --git a/artifacts/lez/programs/pinata.bin b/artifacts/lez/programs/pinata.bin index f93e2c37..d235bd9f 100644 Binary files a/artifacts/lez/programs/pinata.bin and b/artifacts/lez/programs/pinata.bin differ diff --git a/artifacts/lez/programs/pinata_token.bin b/artifacts/lez/programs/pinata_token.bin index 1ffa430d..e278ac48 100644 Binary files a/artifacts/lez/programs/pinata_token.bin and b/artifacts/lez/programs/pinata_token.bin differ diff --git a/artifacts/lez/programs/ping_receiver.bin b/artifacts/lez/programs/ping_receiver.bin new file mode 100644 index 00000000..2ea1889a Binary files /dev/null and b/artifacts/lez/programs/ping_receiver.bin differ diff --git a/artifacts/lez/programs/ping_sender.bin b/artifacts/lez/programs/ping_sender.bin new file mode 100644 index 00000000..38a006d6 Binary files /dev/null and b/artifacts/lez/programs/ping_sender.bin differ diff --git a/artifacts/lez/programs/token.bin b/artifacts/lez/programs/token.bin index 210c7c3a..156ea2da 100644 Binary files a/artifacts/lez/programs/token.bin and b/artifacts/lez/programs/token.bin differ diff --git a/artifacts/lez/programs/vault.bin b/artifacts/lez/programs/vault.bin index e6766101..0f19b084 100644 Binary files a/artifacts/lez/programs/vault.bin and b/artifacts/lez/programs/vault.bin differ diff --git a/artifacts/lez/programs/wrapped_token.bin b/artifacts/lez/programs/wrapped_token.bin new file mode 100644 index 00000000..aa0de9ee Binary files /dev/null and b/artifacts/lez/programs/wrapped_token.bin differ diff --git a/bedrock/deployment-settings.yaml b/bedrock/deployment-settings.yaml index 9c21ee28..005beeb4 100644 --- a/bedrock/deployment-settings.yaml +++ b/bedrock/deployment-settings.yaml @@ -1,48 +1,47 @@ blend: common: - num_blend_layers: 3 + num_blend_layers: 1 minimum_network_size: 30 - protocol_name: /blend/integration-tests - data_replication_factor: 0 + protocol_name: /logos-blockchain-LEZ-DEV/blend/1.0.0 + data_replication_factor: 1 core: scheduler: cover: message_frequency_per_round: 1.0 delayer: - maximum_release_delay_in_rounds: 3 + maximum_release_delay_in_rounds: 1 minimum_messages_coefficient: 1 normalization_constant: 1.03 activity_threshold_sensitivity: 1 network: - kademlia_protocol_name: /integration/logos-blockchain/kad/1.0.0 - identify_protocol_name: /integration/logos-blockchain/identify/1.0.0 - chain_sync_protocol_name: /integration/logos-blockchain/chainsync/1.0.0 + kademlia_protocol_name: /logos-blockchain-LEZ-DEV/kad/1.0.0 + identify_protocol_name: /logos-blockchain-LEZ-DEV/identify/1.0.0 + chain_sync_protocol_name: /logos-blockchain-LEZ-DEV/chainsync/1.0.0 cryptarchia: epoch_config: epoch_stake_distribution_stabilization: 3 epoch_period_nonce_buffer: 3 epoch_period_nonce_stabilization: 4 - security_param: 10 + security_param: 5 slot_activation_coeff: numerator: 1 denominator: 2 - learning_rate: 0.1 + learning_rate: 0.5 sdp_config: service_params: BN: - inactivity_period: 1 - retention_period: 1 + inactivity_period: 2 epoch: 0 min_stake: threshold: 1 timestamp: 0 - gossipsub_protocol: /integration/logos-blockchain/cryptarchia/proto/1.0.0 + gossipsub_protocol: /logos-blockchain-LEZ-DEV/cryptarchia/1.0.0 genesis_block: header: version: Bedrock parent_block: '0000000000000000000000000000000000000000000000000000000000000000' slot: 0 - block_root: b5f8787ac23674822414c70eea15d842da38f2e806ede1a73cf7b5cf0277da07 + block_root: cb5951ac1ffa1aa5d0e585fb54e784bd9c025b28d752324e98b3837f34648692 proof_of_leadership: proof: '0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' entropy_contribution: '0000000000000000000000000000000000000000000000000000000000000000' @@ -56,24 +55,134 @@ cryptarchia: payload: inputs: [] outputs: - - value: 1 - pk: d204000000000000000000000000000000000000000000000000000000000000 - - value: 100 + - value: 1000000 pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' - - value: 1 - pk: ed266e6e887b9b97059dc1aa1b7b2e19b934291753c6336a163fe4ebaa28e717 + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1000000 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 100000 + pk: '6b2bcd3029fba573cff0c332dc4de7430faf5e261383d693d8dbb5b97665660a' + - value: 18446744073709551615 + pk: c2a6a4a0981d5bdcf8ddeb8d7934fd8c5510efeb1053f613b45871670b6f7b19 - opcode: 17 payload: channel_id: '0000000000000000000000000000000000000000000000000000000000000000' - # chain_id_len=12 (u64_le), chain_id=logos-devnet (utf-8), - # genesis_time=2026-01-10T07:47:56Z (u64_le), epoch_nonce=[0u8; 32] - inscription: '0c000000000000006c6f676f732d6465766e65742c046269000000000000000000000000000000000000000000000000000000000000000000000000' + inscription: '05302e322e3123766c6a2d2ddf918544bca603c5a291c7dd1b902d6769ff4b00021506780e075c06051a' parent: '0000000000000000000000000000000000000000000000000000000000000000' signer: '0000000000000000000000000000000000000000000000000000000000000000' + - opcode: 32 + payload: + service_type: BN + locators: + - /ip4/65.109.51.37/udp/3400/quic-v1 + provider_id: '59c662860b737f4e2515599adb3434856db8070b373a449ff66955ad3da6b473' + zk_id: '6b2bcd3029fba573cff0c332dc4de7430faf5e261383d693d8dbb5b97665660a' + locked_note_id: '7e449a14172fc90679f6fca7b49a2d58c305ebf7ac42ef20202e533c31115222' ops_proofs: + - !ZkSig + pi_a: '0000000000000000000000000000000000000000000000000000000000000000' + pi_b: '00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' + pi_c: '0000000000000000000000000000000000000000000000000000000000000000' - !Ed25519Sig '00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' - - !Ed25519Sig '00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' + - !ZkAndEd25519Sigs + zk_sig: + pi_a: '0000000000000000000000000000000000000000000000000000000000000000' + pi_b: '00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' + pi_c: '0000000000000000000000000000000000000000000000000000000000000000' + ed25519_sig: '00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' + faucet_pk: c2a6a4a0981d5bdcf8ddeb8d7934fd8c5510efeb1053f613b45871670b6f7b19 time: - slot_duration: '1.0' + slot_duration: '1.000000000' mempool: - pubsub_topic: mantle_e2e_tests + pubsub_topic: /logos-blockchain-LEZ-DEV/mempool/1.0.0 diff --git a/bedrock/docker-compose.yml b/bedrock/docker-compose.yml index e476a8ef..5b3e9168 100644 --- a/bedrock/docker-compose.yml +++ b/bedrock/docker-compose.yml @@ -1,7 +1,7 @@ services: logos-blockchain-node-0: - image: ghcr.io/logos-blockchain/logos-blockchain@sha256:91d6c5bf07e07fcfba5e7cf07d21ee686a6bc4b9f6210f2d28bffbcad9a3729f + image: ghcr.io/logos-blockchain/logos-blockchain:0.2.1-lssa ports: - "${PORT:-18080}:18080/tcp" volumes: diff --git a/bedrock/scripts/run_logos_blockchain_node.sh b/bedrock/scripts/run_logos_blockchain_node.sh index ffa02e6d..c513d6a3 100755 --- a/bedrock/scripts/run_logos_blockchain_node.sh +++ b/bedrock/scripts/run_logos_blockchain_node.sh @@ -7,14 +7,6 @@ export POL_PROOF_DEV_MODE=true # Use static configs mounted from host. Both node-config.yaml and # deployment-settings.yaml have matching validator keys so the node # can produce blocks as a single-validator network. -# Copy deployment-settings to a writable path because sed -i can't -# rename on a bind-mounted file. -cp /etc/logos-blockchain/deployment-settings.yaml /deployment-settings.yaml - -# Set chain_start_time to "now" so the chain starts immediately. -sed -i "s/PLACEHOLDER_CHAIN_START_TIME/$(date -u '+%Y-%m-%d %H:%M:%S.000000 +00:00:00')/" \ - /deployment-settings.yaml - exec /usr/bin/logos-blockchain-node \ /etc/logos-blockchain/node-config.yaml \ - --deployment /deployment-settings.yaml + --deployment /etc/logos-blockchain/deployment-settings.yaml diff --git a/docs/LEZ testnet v0.1 tutorials/keycard.md b/docs/LEZ testnet v0.1 tutorials/keycard.md index 47573f12..66d5aacf 100644 --- a/docs/LEZ testnet v0.1 tutorials/keycard.md +++ b/docs/LEZ testnet v0.1 tutorials/keycard.md @@ -6,46 +6,45 @@ This tutorial walks you through using Keycard with Wallet CLI. Keycard is option ### Required hardware - Keycard (Blank) - a Keycard, directly, from Keycard.tech cannot (currently) be updated to support LEE. - Smartcard reader -- Applets (`math.cap` and `LEE_keycard.cap`). Eventually, both of these applets will be available in separate repos. - - `math.cap` is an applet to speed up computations on Keycard; developed by Bitgamma (Keycard-tech team). - - `LEE_keycard.cap` is an applet that contains LEE keycard protocol; developed by Bitgamma (Keycard-tech team) ### Firmware installation -Installation: -1. Install math applet on your keycard; this process only needs to be done once. In the root of repo: - ``` - sudo apt-get install -y default-jdk - wget https://github.com/martinpaljak/GlobalPlatformPro/releases/download/v25.10.20/gp.jar -P lez/keycard_wallet/keycard_applets - cd lez/keycard_wallet/keycard_applets - java -jar gp.jar --key c212e073ff8b4bbfaff4de8ab655221f --load math.cap - ``` -2. Install `keycard-desktop` from [github](https://github.com/choppu/keycard-desktop) - - Keycard Desktop is used to install the LEE key protocol to a blank keycard. - - Select (Re)Install Applet and upload the key binary (`lez/keycard_wallet/keycard_applets/LEE_keycard.cap`). - ![keycard-desktop.png](keycard-desktop.png) - - **Important:** keycard can only connect with one application at a time; if Keycard-Desktop is using keycard then Wallet CLI cannot access the same keycard, and vice-versa. - -## Wallet with Keycard -Keycard functionality is available to Wallet CLI by setting up the following Python virtual environment. The steps below can also be run via `lez/keycard_wallet/wallet_with_keycard.sh`. +LEE key protocol support (on top of standard Status Keycard commands) is built from source, from [`keycard-tech/status-keycard`](https://github.com/keycard-tech/status-keycard)'s default branch: ```bash -# Install appropriate version of `keycard-py`. -git clone --branch lee-schnorr --single-branch https://github.com/bitgamma/keycard-py.git lez/keycard_wallet/python/keycard-py - -# Set up virtual environment. -python3 -m venv venv -source venv/bin/activate -pip install pyscard mnemonic ecdsa pyaes -pip install -e lez/keycard_wallet/python/keycard-py +git clone --recurse-submodules https://github.com/keycard-tech/status-keycard.git +cd status-keycard ``` -**Important**: Keycard wallet commands only work within the virtual environment. +The build requires **OpenJDK 11 specifically** (newer JDKs aren't compatible with its Gradle/plugin versions): + ```bash -# In the root of LEE repo: -source venv/bin/activate +sudo apt-get install -y openjdk-11-jdk +export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64 ``` +Gradle's default heap is too small for this build and will OOM during `buildSrc` compilation; bump it once: + +```bash +echo "org.gradle.jvmargs=-Xmx2g" >> gradle.properties +``` + +Build and install onto a connected, blank card (disconnect all other card readers first): + +```bash +./gradlew install +``` + +This uses the GlobalPlatform default keys (`404142434445464748494a4b4c4d4e4f`) or the Keycard development-card key (`c212e073ff8b4bbfaff4de8ab655221f`) to load it onto the card. + +**Warning: `./gradlew install` uninstalls and reinstalls the applet, which erases any existing personalization.** If you run this against a card that's already personalized (identity certificate, PIN, PUK, and any loaded keys), all of that is wiped, regardless of whether the firmware source changed at all — reinstalling the exact same build twice has the same effect. + +### Personalizing your card + +**Personalization is mandatory, not optional — every card requires it before any command will work, immediately after installing the firmware.** A freshly installed (or freshly reinstalled) card has no identity certificate, and refuses every command. + +**Important:** keycard can only connect with one application at a time; if another tool is using the keycard then Wallet CLI cannot access the same keycard, and vice-versa. + ## PIN entry Each Keycard command prompts for a PIN interactively. To avoid re-entering it across multiple commands, export it as an environment variable: @@ -60,43 +59,36 @@ Unset it when done: unset KEYCARD_PIN ``` -## Pairing password +## Default CA public key -The pairing password is used to establish a secure channel between the wallet and the card. It is set permanently on the card during `wallet keycard init` and must match on every subsequent re-pair. +`keycard-rs` verifies every card's identity certificate against a trusted CA public key before anything else happens — no match, no commands, regardless of whether the firmware or PIN is correct. The baked-in default is: -The default password (`KeycardDefaultPairing`) is [recommended](https://docs.keycard.tech/en/developers/core) for most users. Wallet CLI allows advance users the flexibility to set their own pairing password. - -To use a custom pairing password, set it before `init`: - -```bash -# Note: Keep the leading space before this command. -# Leading space prevents this command from being stored in shell history -# (when HISTCONTROL=ignorespace is enabled). - export KEYCARD_PAIRING_PASSWORD=my-custom-password -wallet keycard init +``` +029ab99ee1e7a71bdf45b3f9c58c99866ff1294d2c1e304e228a86e10c3343501c ``` -After a successful initializaation, subsequent commands (`connect`, transfers) use the cached pairing index and key — the pairing password is not needed again until the pairing is cleared. - -**Important:** if you initialized with a custom password, `KEYCARD_PAIRING_PASSWORD` must be set in every session where re-pairing can occur (after `disconnect`, or on a new machine). If the env var is missing then wallet CLI will attempt to use the default password. As a result, pairing will fail. - -Unset the pairing password variable when done: +Cards personalized for development/testing (see "Personalizing the card" above) are signed by a different, throwaway CA instead, so the wallet needs to be told to trust it explicitly: ```bash -unset KEYCARD_PAIRING_PASSWORD +export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743 +# unset KEYCARD_CA_PUBLIC_KEY when done testing against a dev card ``` +If the card's certificate doesn't match whichever CA is in effect, every command reports the card as simply "not available." + ## Keycard Commands +Keycard uses Secure Channel V2 (applet version >= 4.0) — the wallet authenticates the card via its identity certificate and opens a fresh ECDHE-derived channel every session. There's no pairing step and nothing cached between commands; you'll enter your PIN each time you connect. + ### Keycard | Command | Description | |----------------------------------|-----------------------------------------------------------------------| | `wallet keycard available` | Checks whether a Keycard reader and card are accessible | | `wallet keycard init` | Initializes a blank Keycard with a PIN and a generated PUK | -| `wallet keycard connect` | Establishes and saves a pairing with the Keycard | -| `wallet keycard disconnect` | Unpairs the Keycard and clears the saved pairing | +| `wallet keycard connect` | Opens a secure channel with the Keycard and verifies the PIN | | `wallet keycard load` | Loads a mnemonic phrase onto the Keycard | +| `wallet keycard factory-reset` | Wipes PIN/PUK/keys back to uninitialized, for re-`init` — **debug builds only** (see below) | | `wallet keycard get-private-keys`| Prints NSK and VSK for a BIP-32 path — **debug builds only** (see below) | 1. Check keycard availability @@ -118,13 +110,13 @@ Record this PUK and store it somewhere safe. It cannot be recovered. ✅ Keycard initialized successfully. ``` -3. Connect (pair and save pairing for subsequent commands) +3. Connect (open a secure channel and verify the PIN) ```bash wallet keycard connect # Output: Keycard PIN: -✅ Keycard paired and ready. +✅ Keycard connected and PIN verified. ``` 4. Load a mnemonic phrase @@ -140,25 +132,24 @@ Keycard PIN: ✅ Mnemonic phrase loaded successfully. ``` -5. Disconnect (unpair and clear saved pairing) +5. `factory-reset` + +Wipes the card's PIN, PUK, and loaded keys back to an uninitialized state, so it can be re-`init`ialized — the counterpart to `init`. It does **not** remove the identity certificate, so the card doesn't need re-personalizing afterward. Irreversibly destroys any keys currently on the card, so it requires `--confirm`: ```bash -wallet keycard disconnect +wallet keycard factory-reset --confirm # Output: -Keycard PIN: -✅ Keycard unpaired and pairing cleared. +✅ Keycard factory-reset. Run `wallet keycard init` to reinitialize it. ``` -6. Get private keys for a BIP-32 path (**debug builds only**) +6. `get-private-keys` (**debug builds only**) -`get-private-keys` exports the raw NSK and VSK for a derivation path. NSK gates nullifier creation and VSK gates note decryption — either key is sufficient to fully compromise that account's privacy. The command is only available in debug builds and requires `--reveal` to confirm intent. - -First install the wallet with the `keycard-debug` feature: +Requires building the wallet with the `keycard-debug` feature: ```bash cargo install --path lez/wallet --force --features keycard-debug ``` -Then run the command: +Exports the raw NSK and VSK for a derivation path. NSK gates nullifier creation and VSK gates note decryption — either key is sufficient to fully compromise that account's privacy. Requires `--reveal` to confirm intent: ```bash wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/0" --reveal @@ -515,20 +506,4 @@ bash lez/keycard_wallet/tests/keycard_tests.sh bash lez/keycard_wallet/tests/keycard_tests_2.sh bash lez/keycard_wallet/tests/keycard_test_3.sh bash lez/keycard_wallet/tests/keycard_power_recovery_tests.sh -``` - -## SigningGroup - -`SigningGroup` (`lez/wallet/src/signing.rs`) partitions a transaction's signers into two buckets — local accounts and Keycard accounts. This ensures that Python GIL is only used at most once per transaction, regardless of how many Keycard accounts are involved. - -Local signers are resolved and signed in pure Rust. Keycard signers store only their BIP32 key path; all of them are signed inside a single Python session (`connect` / `close_session`) when `sign_all` is called. The command calls `needs_pin` to decide whether to prompt for a PIN before signing. - -Foreign recipient accounts — those with no local key and no Keycard path — are silently skipped and require neither a signature nor a nonce. - -``` -SigningGroup { - local: [(AccountId, PrivateKey)], // signed in pure Rust - keycard: [(AccountId, BIP32Path)], // signed via a single Python/Keycard session -} -``` ``` \ No newline at end of file diff --git a/examples/program_deployment/src/bin/run_hello_world.rs b/examples/program_deployment/src/bin/run_hello_world.rs index 3f2223a1..f58f2ec0 100644 --- a/examples/program_deployment/src/bin/run_hello_world.rs +++ b/examples/program_deployment/src/bin/run_hello_world.rs @@ -27,7 +27,7 @@ use wallet::WalletCore; #[tokio::main] async fn main() { // Initialize wallet - let wallet_core = WalletCore::from_env().unwrap(); + let wallet_core = WalletCore::from_env().await.unwrap(); // Parse arguments // First argument is the path to the program binary @@ -59,7 +59,7 @@ async fn main() { // Submit the transaction let _response = wallet_core - .sequencer_client + .helm_owned() .send_transaction(LeeTransaction::Public(tx)) .await .unwrap(); diff --git a/examples/program_deployment/src/bin/run_hello_world_private.rs b/examples/program_deployment/src/bin/run_hello_world_private.rs index f6202433..64fc39c2 100644 --- a/examples/program_deployment/src/bin/run_hello_world_private.rs +++ b/examples/program_deployment/src/bin/run_hello_world_private.rs @@ -23,7 +23,7 @@ use wallet::{AccountIdentity, WalletCore}; #[tokio::main] async fn main() { // Initialize wallet - let wallet_core = WalletCore::from_env().unwrap(); + let wallet_core = WalletCore::from_env().await.unwrap(); // Parse arguments // First argument is the path to the program binary diff --git a/examples/program_deployment/src/bin/run_hello_world_through_tail_call.rs b/examples/program_deployment/src/bin/run_hello_world_through_tail_call.rs index 6ebba70f..00254bfd 100644 --- a/examples/program_deployment/src/bin/run_hello_world_through_tail_call.rs +++ b/examples/program_deployment/src/bin/run_hello_world_through_tail_call.rs @@ -27,7 +27,7 @@ use wallet::WalletCore; #[tokio::main] async fn main() { // Initialize wallet - let wallet_core = WalletCore::from_env().unwrap(); + let wallet_core = WalletCore::from_env().await.unwrap(); // Parse arguments // First argument is the path to the program binary @@ -55,7 +55,7 @@ async fn main() { // Submit the transaction let _response = wallet_core - .sequencer_client + .helm_owned() .send_transaction(LeeTransaction::Public(tx)) .await .unwrap(); diff --git a/examples/program_deployment/src/bin/run_hello_world_through_tail_call_private.rs b/examples/program_deployment/src/bin/run_hello_world_through_tail_call_private.rs index d35e6521..c1282dae 100644 --- a/examples/program_deployment/src/bin/run_hello_world_through_tail_call_private.rs +++ b/examples/program_deployment/src/bin/run_hello_world_through_tail_call_private.rs @@ -26,7 +26,7 @@ use wallet::{AccountIdentity, WalletCore}; #[tokio::main] async fn main() { // Initialize wallet - let wallet_core = WalletCore::from_env().unwrap(); + let wallet_core = WalletCore::from_env().await.unwrap(); // Parse arguments // First argument is the path to the simple_tail_call program binary diff --git a/examples/program_deployment/src/bin/run_hello_world_with_authorization.rs b/examples/program_deployment/src/bin/run_hello_world_with_authorization.rs index 0d257db2..f7ad36b1 100644 --- a/examples/program_deployment/src/bin/run_hello_world_with_authorization.rs +++ b/examples/program_deployment/src/bin/run_hello_world_with_authorization.rs @@ -29,7 +29,7 @@ use wallet::WalletCore; #[tokio::main] async fn main() { // Initialize wallet - let wallet_core = WalletCore::from_env().unwrap(); + let wallet_core = WalletCore::from_env().await.unwrap(); // Parse arguments // First argument is the path to the program binary @@ -52,7 +52,8 @@ async fn main() { .storage() .key_chain() .pub_account_signing_key(account_id) - .expect("Input account should be a self owned public account"); + .expect("Input account should be a self owned public account") + .clone(); // Define the desired greeting in ASCII let greeting: Vec = vec![72, 111, 108, 97, 32, 109, 117, 110, 100, 111, 33]; @@ -60,10 +61,10 @@ async fn main() { // Construct the public transaction // Query the current nonce from the node let nonces = wallet_core - .get_accounts_nonces(vec![account_id]) + .get_accounts_nonces(&[account_id]) .await .expect("Node should be reachable to query account data"); - let signing_keys = [signing_key]; + let signing_keys = [&signing_key]; let message = Message::try_new(program.id(), vec![account_id], nonces, greeting).unwrap(); // Pass the signing key to sign the message. This will be used by the node // to flag the pre_state as `is_authorized` when executing the program @@ -72,7 +73,7 @@ async fn main() { // Submit the transaction let _response = wallet_core - .sequencer_client + .helm_owned() .send_transaction(LeeTransaction::Public(tx)) .await .unwrap(); diff --git a/examples/program_deployment/src/bin/run_hello_world_with_authorization_through_tail_call_with_pda.rs b/examples/program_deployment/src/bin/run_hello_world_with_authorization_through_tail_call_with_pda.rs index 70688cfd..a29bd263 100644 --- a/examples/program_deployment/src/bin/run_hello_world_with_authorization_through_tail_call_with_pda.rs +++ b/examples/program_deployment/src/bin/run_hello_world_with_authorization_through_tail_call_with_pda.rs @@ -35,7 +35,7 @@ const PDA_SEED: PdaSeed = PdaSeed::new([37; 32]); #[tokio::main] async fn main() { // Initialize wallet - let wallet_core = WalletCore::from_env().unwrap(); + let wallet_core = WalletCore::from_env().await.unwrap(); // Parse arguments // First argument is the path to the program binary @@ -57,7 +57,7 @@ async fn main() { // Submit the transaction let _response = wallet_core - .sequencer_client + .helm_owned() .send_transaction(LeeTransaction::Public(tx)) .await .unwrap(); diff --git a/examples/program_deployment/src/bin/run_hello_world_with_move_function.rs b/examples/program_deployment/src/bin/run_hello_world_with_move_function.rs index a77fe2e6..c03563ad 100644 --- a/examples/program_deployment/src/bin/run_hello_world_with_move_function.rs +++ b/examples/program_deployment/src/bin/run_hello_world_with_move_function.rs @@ -66,7 +66,7 @@ async fn main() { let program = Program::new(bytecode.into()).unwrap(); // Initialize wallet - let wallet_core = WalletCore::from_env().unwrap(); + let wallet_core = WalletCore::from_env().await.unwrap(); match cli.command { Command::WritePublic { @@ -88,7 +88,7 @@ async fn main() { // Submit the transaction let _response = wallet_core - .sequencer_client + .helm_owned() .send_transaction(LeeTransaction::Public(tx)) .await .unwrap(); @@ -127,7 +127,7 @@ async fn main() { // Submit the transaction let _response = wallet_core - .sequencer_client + .helm_owned() .send_transaction(LeeTransaction::Public(tx)) .await .unwrap(); diff --git a/flake.nix b/flake.nix index 0aaad40c..24db36be 100644 --- a/flake.nix +++ b/flake.nix @@ -117,13 +117,14 @@ done if [ -n "$tool" ]; then unset DEVELOPER_DIR SDKROOT + export xcrun_nocache=1 fi exec /usr/bin/xcrun "$@" ''; commonArgs = { inherit src; - buildInputs = [ pkgs.openssl ]; + buildInputs = [ pkgs.openssl pkgs.pcsclite ]; nativeBuildInputs = [ pkgs.pkg-config pkgs.clang diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index bba06ec0..4d078550 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -22,6 +22,12 @@ associated_token_account_core.workspace = true vault_core.workspace = true faucet_core.workspace = true bridge_core.workspace = true +ping_core.workspace = true +cross_zone_outbox_core.workspace = true +cross_zone_inbox_core.workspace = true +bridge_lock_core.workspace = true +wrapped_token_core.workspace = true +risc0-zkvm.workspace = true indexer_service_rpc = { workspace = true, features = ["client"] } sequencer_service_rpc = { workspace = true, features = ["client"] } wallet-ffi.workspace = true @@ -30,18 +36,13 @@ indexer_service_protocol.workspace = true system_accounts.workspace = true programs.workspace = true test_programs.workspace = true +testnet_initial_state.workspace = true -logos-blockchain-http-api-common.workspace = true logos-blockchain-core.workspace = true -logos-blockchain-zone-sdk.workspace = true logos-blockchain-key-management-system-service.workspace = true anyhow.workspace = true log.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } -futures.workspace = true hex.workspace = true tempfile.workspace = true bytesize.workspace = true -reqwest.workspace = true -borsh.workspace = true -num-bigint.workspace = true diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index 07212251..fe07aee7 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -6,11 +6,218 @@ use std::time::Duration; use anyhow::{Context as _, Result}; +use key_protocol::key_management::key_tree::chain_index::ChainIndex; +use lee::AccountId; use log::info; +use sequencer_service_rpc::RpcClient as _; pub use test_fixtures::*; +use wallet::{ + AccountIdentity, + cli::{ + CliAccountMention, Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + programs::{ + native_token_transfer::AuthTransferSubcommand, token::TokenProgramAgnosticSubcommand, + }, + }, + program_facades::{native_token_transfer::NativeTokenTransfer, token::Token}, + storage::key_chain::FoundPrivateAccount, +}; /// Maximum time to wait for the indexer to catch up to the sequencer. -pub const L2_TO_L1_TIMEOUT: Duration = Duration::from_mins(7); +pub const L2_TO_L1_TIMEOUT: Duration = Duration::from_mins(6); + +/// Create a private or public account at the given chain index and return its ID. +/// Pass `cci: None` to use the wallet's next available chain index. +pub async fn new_account( + ctx: &mut TestContext, + private: bool, + cci: Option, +) -> Result { + let subcommand = if private { + NewSubcommand::Private { cci, label: None } + } else { + NewSubcommand::Public { cci, label: None } + }; + let result = wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(subcommand)), + ) + .await?; + let SubcommandReturnValue::RegisterAccount { account_id } = result else { + anyhow::bail!("Expected RegisterAccount return value"); + }; + Ok(account_id) +} + +/// Send `amount` from `from` to `to` via an authenticated transfer (identifier 0). +pub async fn send( + ctx: &mut TestContext, + from: CliAccountMention, + to: CliAccountMention, + amount: u128, +) -> Result<()> { + let command = Command::AuthTransfer(AuthTransferSubcommand::Send { + from, + to: Some(to), + to_npk: None, + to_vpk: None, + to_keys: None, + to_identifier: Some(0), + amount, + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + Ok(()) +} + +/// Like [`send`], but for a `to` that is still a fresh, unclaimed account. +/// +/// The wallet CLI's `AuthTransfer::Send` never signs with the recipient's key (by design: the +/// sender's wallet must not sign on behalf of an account it doesn't own). But claiming a fresh +/// account is only possible if that account's own key signs the transaction, so this bypasses +/// the CLI and calls the program facade directly with an explicit `AccountIdentity::Public` for +/// the recipient, using the key the test wallet holds for the account it just created. +/// +/// Unlike `send`, this doesn't go through the CLI's own poll-until-included step, so it waits +/// for block creation itself before returning. +pub async fn send_claiming_new_account( + ctx: &mut TestContext, + from: AccountId, + to: AccountId, + amount: u128, +) -> Result<()> { + NativeTokenTransfer(ctx.wallet()) + .send_public_transfer( + AccountIdentity::Public(from), + AccountIdentity::Public(to), + amount, + ) + .await?; + info!("Waiting for next block creation"); + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + Ok(()) +} + +/// Create a token (New) and wait for the block to be included. +pub async fn create_token( + ctx: &mut TestContext, + definition_account_id: CliAccountMention, + supply_account_id: CliAccountMention, + name: impl Into, + total_supply: u128, +) -> Result<()> { + let subcommand = TokenProgramAgnosticSubcommand::New { + definition_account_id, + supply_account_id, + name: name.into(), + total_supply, + }; + wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; + info!("Waiting for next block creation"); + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + Ok(()) +} + +/// Send tokens and wait for the block to be included. +pub async fn token_send( + ctx: &mut TestContext, + from: CliAccountMention, + to: CliAccountMention, + amount: u128, +) -> Result<()> { + let subcommand = TokenProgramAgnosticSubcommand::Send { + from, + to: Some(to), + to_npk: None, + to_vpk: None, + to_keys: None, + to_identifier: Some(0), + amount, + }; + wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; + info!("Waiting for next block creation"); + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + Ok(()) +} + +/// Like [`token_send`], but for a `to` that is still a fresh, unclaimed holding account. See +/// [`send_claiming_new_account`] for why the CLI can't be used here. +pub async fn token_send_claiming_new_account( + ctx: &mut TestContext, + from: AccountId, + to: AccountId, + amount: u128, +) -> Result<()> { + Token(ctx.wallet()) + .send_transfer_transaction( + AccountIdentity::Public(from), + AccountIdentity::Public(to), + amount, + ) + .await?; + info!("Waiting for next block creation"); + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + Ok(()) +} + +/// Retrieve the native token balance for `account_id`. +pub async fn account_balance(ctx: &TestContext, account_id: AccountId) -> Result { + Ok(ctx + .sequencer_client() + .get_account_balance(account_id) + .await?) +} + +/// Fetch the full account state for `account_id` from the sequencer. +pub async fn get_account(ctx: &TestContext, account_id: AccountId) -> Result { + Ok(ctx.sequencer_client().get_account(account_id).await?) +} + +/// Fetch the current commitment for `account_id` and assert it is present in the sequencer state. +pub async fn assert_private_commitment_in_state( + ctx: &TestContext, + account_id: AccountId, + label: &str, +) -> Result<()> { + let commitment = ctx + .wallet() + .get_private_account_commitment(account_id) + .with_context(|| format!("Failed to get commitment for {label}"))?; + assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); + Ok(()) +} + +/// Sync the wallet's private accounts. +pub async fn sync_private(ctx: &mut TestContext) -> Result<()> { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::SyncPrivate {}), + ) + .await?; + Ok(()) +} + +/// Look up a restored private account for `account_id`, panicking with `label` if absent. +pub fn restored_private_account<'ctx>( + ctx: &'ctx TestContext, + account_id: AccountId, + label: &str, +) -> FoundPrivateAccount<'ctx> { + ctx.wallet() + .storage() + .key_chain() + .private_account(account_id) + .unwrap_or_else(|| panic!("{label} should be restored")) +} + +/// Assert that a restored public account's signing key exists, panicking with `label` if absent. +pub fn assert_public_account_restored(ctx: &TestContext, account_id: AccountId, label: &str) { + ctx.wallet() + .storage() + .key_chain() + .pub_account_signing_key(account_id) + .unwrap_or_else(|| panic!("{label} should be restored")); +} /// Poll the indexer until its last finalized block id reaches the sequencer's /// current last block id or until [`L2_TO_L1_TIMEOUT`] elapses. diff --git a/integration_tests/tests/account.rs b/integration_tests/tests/account.rs index 0de8a9e2..2b69f7e0 100644 --- a/integration_tests/tests/account.rs +++ b/integration_tests/tests/account.rs @@ -4,12 +4,11 @@ )] use anyhow::{Context as _, Result}; -use integration_tests::{TestContext, private_mention}; +use integration_tests::{TestContext, get_account, new_account, private_mention}; use key_protocol::key_management::KeyChain; use lee::Data; use lee_core::account::Nonce; use log::info; -use sequencer_service_rpc::RpcClient as _; use tokio::test; use wallet::{ account::{AccountIdWithPrivacy, HumanReadableAccount, Label}, @@ -24,10 +23,7 @@ use wallet::{ async fn get_existing_account() -> Result<()> { let ctx = TestContext::new().await?; - let account = ctx - .sequencer_client() - .get_account(ctx.existing_public_accounts()[0]) - .await?; + let account = get_account(&ctx, ctx.existing_public_accounts()[0]).await?; assert_eq!( account.program_owner, @@ -95,18 +91,7 @@ async fn add_label_to_existing_account() -> Result<()> { async fn new_public_account_without_label() -> Result<()> { let mut ctx = TestContext::new().await?; - let command = Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })); - - let result = execute_subcommand(ctx.wallet_mut(), command).await?; - - // Extract the account_id from the result - - let wallet::cli::SubcommandReturnValue::RegisterAccount { account_id } = result else { - panic!("Expected RegisterAccount return value") - }; + let account_id = new_account(&mut ctx, false, None).await?; // Verify no label was stored for the account id assert!( @@ -156,7 +141,11 @@ async fn import_private_account() -> Result<()> { let mut ctx = TestContext::new().await?; let key_chain = KeyChain::new_os_random(); - let account_id = lee::AccountId::from((&key_chain.nullifier_public_key, 0)); + let account_id = lee::AccountId::from(( + &key_chain.nullifier_public_key, + &key_chain.viewing_public_key, + 0, + )); let account = lee::Account { program_owner: programs::authenticated_transfer().id(), balance: 777, @@ -213,7 +202,11 @@ async fn import_private_account_second_time_overrides_account_data() -> Result<( let mut ctx = TestContext::new().await?; let key_chain = KeyChain::new_os_random(); - let account_id = lee::AccountId::from((&key_chain.nullifier_public_key, 0)); + let account_id = lee::AccountId::from(( + &key_chain.nullifier_public_key, + &key_chain.viewing_public_key, + 0, + )); let key_chain_json = serde_json::to_string(&key_chain).context("Failed to serialize key chain")?; diff --git a/integration_tests/tests/amm.rs b/integration_tests/tests/amm.rs index 9f953001..894d787c 100644 --- a/integration_tests/tests/amm.rs +++ b/integration_tests/tests/amm.rs @@ -7,16 +7,18 @@ use std::time::Duration; use anyhow::Result; -use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, public_mention}; +use integration_tests::{ + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, create_token, get_account, new_account, + public_mention, token_send_claiming_new_account, +}; use log::info; -use sequencer_service_rpc::RpcClient as _; use tokio::test; use wallet::{ account::Label, cli::{ Command, SubcommandReturnValue, account::{AccountSubcommand, NewSubcommand}, - programs::{amm::AmmProgramAgnosticSubcommand, token::TokenProgramAgnosticSubcommand}, + programs::amm::AmmProgramAgnosticSubcommand, }, }; @@ -25,148 +27,53 @@ async fn amm_public() -> Result<()> { let mut ctx = TestContext::new().await?; // Create new account for the token definition - let SubcommandReturnValue::RegisterAccount { - account_id: definition_account_id_1, - } = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await? - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let definition_account_id_1 = new_account(&mut ctx, false, None).await?; // Create new account for the token supply holder - let SubcommandReturnValue::RegisterAccount { - account_id: supply_account_id_1, - } = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await? - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let supply_account_id_1 = new_account(&mut ctx, false, None).await?; // Create new account for receiving a token transaction - let SubcommandReturnValue::RegisterAccount { - account_id: recipient_account_id_1, - } = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await? - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let recipient_account_id_1 = new_account(&mut ctx, false, None).await?; // Create new account for the token definition - let SubcommandReturnValue::RegisterAccount { - account_id: definition_account_id_2, - } = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await? - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let definition_account_id_2 = new_account(&mut ctx, false, None).await?; // Create new account for the token supply holder - let SubcommandReturnValue::RegisterAccount { - account_id: supply_account_id_2, - } = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await? - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let supply_account_id_2 = new_account(&mut ctx, false, None).await?; // Create new account for receiving a token transaction - let SubcommandReturnValue::RegisterAccount { - account_id: recipient_account_id_2, - } = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), + let recipient_account_id_2 = new_account(&mut ctx, false, None).await?; + + // Create new token + create_token( + &mut ctx, + public_mention(definition_account_id_1), + public_mention(supply_account_id_1), + "A NAM1".to_owned(), + 37, ) - .await? - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + .await?; + + // Transfer 7 tokens from `supply_acc` to the account at account_id `recipient_account_id_1`. + // `recipient_account_id_1` is still unclaimed, so this bypasses the wallet CLI (which never + // signs with the recipient's key) and signs with the recipient's own key directly. + token_send_claiming_new_account(&mut ctx, supply_account_id_1, recipient_account_id_1, 7) + .await?; // Create new token - let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: public_mention(definition_account_id_1), - supply_account_id: public_mention(supply_account_id_1), - name: "A NAM1".to_owned(), + create_token( + &mut ctx, + public_mention(definition_account_id_2), + public_mention(supply_account_id_2), + "A NAM2".to_owned(), + 37, + ) + .await?; - total_supply: 37, - }; - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - // Transfer 7 tokens from `supply_acc` to the account at account_id `recipient_account_id_1` - let subcommand = TokenProgramAgnosticSubcommand::Send { - from: public_mention(supply_account_id_1), - to: Some(public_mention(recipient_account_id_1)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 7, - }; - - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - // Create new token - let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: public_mention(definition_account_id_2), - supply_account_id: public_mention(supply_account_id_2), - name: "A NAM2".to_owned(), - - total_supply: 37, - }; - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - // Transfer 7 tokens from `supply_acc` to the account at account_id `recipient_account_id_2` - let subcommand = TokenProgramAgnosticSubcommand::Send { - from: public_mention(supply_account_id_2), - to: Some(public_mention(recipient_account_id_2)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 7, - }; - - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + // Transfer 7 tokens from `supply_acc` to the account at account_id `recipient_account_id_2`. + // `recipient_account_id_2` is still unclaimed, so this bypasses the wallet CLI the same way. + token_send_claiming_new_account(&mut ctx, supply_account_id_2, recipient_account_id_2, 7) + .await?; info!("=================== SETUP FINISHED ==============="); @@ -174,19 +81,7 @@ async fn amm_public() -> Result<()> { // Setup accounts // Create new account for the user holding lp - let SubcommandReturnValue::RegisterAccount { - account_id: user_holding_lp, - } = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await? - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let user_holding_lp = new_account(&mut ctx, false, None).await?; // Send creation tx let subcommand = AmmProgramAgnosticSubcommand::New { @@ -201,17 +96,11 @@ async fn amm_public() -> Result<()> { info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let user_holding_a_acc = ctx - .sequencer_client() - .get_account(recipient_account_id_1) - .await?; + let user_holding_a_acc = get_account(&ctx, recipient_account_id_1).await?; - let user_holding_b_acc = ctx - .sequencer_client() - .get_account(recipient_account_id_2) - .await?; + let user_holding_b_acc = get_account(&ctx, recipient_account_id_2).await?; - let user_holding_lp_acc = ctx.sequencer_client().get_account(user_holding_lp).await?; + let user_holding_lp_acc = get_account(&ctx, user_holding_lp).await?; assert_eq!( u128::from_le_bytes(user_holding_a_acc.data[33..].try_into().unwrap()), @@ -244,17 +133,11 @@ async fn amm_public() -> Result<()> { info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let user_holding_a_acc = ctx - .sequencer_client() - .get_account(recipient_account_id_1) - .await?; + let user_holding_a_acc = get_account(&ctx, recipient_account_id_1).await?; - let user_holding_b_acc = ctx - .sequencer_client() - .get_account(recipient_account_id_2) - .await?; + let user_holding_b_acc = get_account(&ctx, recipient_account_id_2).await?; - let user_holding_lp_acc = ctx.sequencer_client().get_account(user_holding_lp).await?; + let user_holding_lp_acc = get_account(&ctx, user_holding_lp).await?; assert_eq!( u128::from_le_bytes(user_holding_a_acc.data[33..].try_into().unwrap()), @@ -287,17 +170,11 @@ async fn amm_public() -> Result<()> { info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let user_holding_a_acc = ctx - .sequencer_client() - .get_account(recipient_account_id_1) - .await?; + let user_holding_a_acc = get_account(&ctx, recipient_account_id_1).await?; - let user_holding_b_acc = ctx - .sequencer_client() - .get_account(recipient_account_id_2) - .await?; + let user_holding_b_acc = get_account(&ctx, recipient_account_id_2).await?; - let user_holding_lp_acc = ctx.sequencer_client().get_account(user_holding_lp).await?; + let user_holding_lp_acc = get_account(&ctx, user_holding_lp).await?; assert_eq!( u128::from_le_bytes(user_holding_a_acc.data[33..].try_into().unwrap()), @@ -331,17 +208,11 @@ async fn amm_public() -> Result<()> { info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let user_holding_a_acc = ctx - .sequencer_client() - .get_account(recipient_account_id_1) - .await?; + let user_holding_a_acc = get_account(&ctx, recipient_account_id_1).await?; - let user_holding_b_acc = ctx - .sequencer_client() - .get_account(recipient_account_id_2) - .await?; + let user_holding_b_acc = get_account(&ctx, recipient_account_id_2).await?; - let user_holding_lp_acc = ctx.sequencer_client().get_account(user_holding_lp).await?; + let user_holding_lp_acc = get_account(&ctx, user_holding_lp).await?; assert_eq!( u128::from_le_bytes(user_holding_a_acc.data[33..].try_into().unwrap()), @@ -375,17 +246,11 @@ async fn amm_public() -> Result<()> { info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let user_holding_a_acc = ctx - .sequencer_client() - .get_account(recipient_account_id_1) - .await?; + let user_holding_a_acc = get_account(&ctx, recipient_account_id_1).await?; - let user_holding_b_acc = ctx - .sequencer_client() - .get_account(recipient_account_id_2) - .await?; + let user_holding_b_acc = get_account(&ctx, recipient_account_id_2).await?; - let user_holding_lp_acc = ctx.sequencer_client().get_account(user_holding_lp).await?; + let user_holding_lp_acc = get_account(&ctx, user_holding_lp).await?; assert_eq!( u128::from_le_bytes(user_holding_a_acc.data[33..].try_into().unwrap()), @@ -412,33 +277,9 @@ async fn amm_new_pool_using_labels() -> Result<()> { let mut ctx = TestContext::new().await?; // Create token 1 accounts - let SubcommandReturnValue::RegisterAccount { - account_id: definition_account_id_1, - } = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await? - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let definition_account_id_1 = new_account(&mut ctx, false, None).await?; - let SubcommandReturnValue::RegisterAccount { - account_id: supply_account_id_1, - } = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await? - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let supply_account_id_1 = new_account(&mut ctx, false, None).await?; // Create holding_a with a label let holding_a_label = Label::new("amm-holding-a-label"); @@ -457,33 +298,9 @@ async fn amm_new_pool_using_labels() -> Result<()> { }; // Create token 2 accounts - let SubcommandReturnValue::RegisterAccount { - account_id: definition_account_id_2, - } = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await? - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let definition_account_id_2 = new_account(&mut ctx, false, None).await?; - let SubcommandReturnValue::RegisterAccount { - account_id: supply_account_id_2, - } = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await? - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let supply_account_id_2 = new_account(&mut ctx, false, None).await?; // Create holding_b with a label let holding_b_label = Label::new("amm-holding-b-label"); @@ -518,48 +335,31 @@ async fn amm_new_pool_using_labels() -> Result<()> { }; // Create token 1 and distribute to holding_a - let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: public_mention(definition_account_id_1), - supply_account_id: public_mention(supply_account_id_1), - name: "TOKEN1".to_owned(), - total_supply: 10, - }; - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + create_token( + &mut ctx, + public_mention(definition_account_id_1), + public_mention(supply_account_id_1), + "TOKEN1".to_owned(), + 10, + ) + .await?; - let subcommand = TokenProgramAgnosticSubcommand::Send { - from: public_mention(supply_account_id_1), - to: Some(public_mention(holding_a_id)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 5, - }; - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + // `holding_a_id` is still unclaimed, so bypass the wallet CLI (see + // `token_send_claiming_new_account`'s docs for why). + token_send_claiming_new_account(&mut ctx, supply_account_id_1, holding_a_id, 5).await?; // Create token 2 and distribute to holding_b - let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: public_mention(definition_account_id_2), - supply_account_id: public_mention(supply_account_id_2), - name: "TOKEN2".to_owned(), - total_supply: 10, - }; - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + create_token( + &mut ctx, + public_mention(definition_account_id_2), + public_mention(supply_account_id_2), + "TOKEN2".to_owned(), + 10, + ) + .await?; - let subcommand = TokenProgramAgnosticSubcommand::Send { - from: public_mention(supply_account_id_2), - to: Some(public_mention(holding_b_id)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 5, - }; - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + // `holding_b_id` is still unclaimed, so bypass the wallet CLI the same way. + token_send_claiming_new_account(&mut ctx, supply_account_id_2, holding_b_id, 5).await?; // Create AMM pool using account labels instead of IDs let subcommand = AmmProgramAgnosticSubcommand::New { @@ -572,7 +372,7 @@ async fn amm_new_pool_using_labels() -> Result<()> { wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::AMM(subcommand)).await?; tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let holding_lp_acc = ctx.sequencer_client().get_account(holding_lp_id).await?; + let holding_lp_acc = get_account(&ctx, holding_lp_id).await?; // LP balance should be 3 (geometric mean of 3, 3) assert_eq!( diff --git a/integration_tests/tests/ata.rs b/integration_tests/tests/ata.rs index 7faac67e..21905fb9 100644 --- a/integration_tests/tests/ata.rs +++ b/integration_tests/tests/ata.rs @@ -9,75 +9,34 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use associated_token_account_core::{compute_ata_seed, get_associated_token_account_id}; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention, - verify_commitment_is_in_state, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, create_token, get_account, new_account, + private_mention, public_mention, token_send, verify_commitment_is_in_state, }; use log::info; use sequencer_service_rpc::RpcClient as _; use token_core::{TokenDefinition, TokenHolding}; use tokio::test; -use wallet::cli::{ - Command, SubcommandReturnValue, - account::{AccountSubcommand, NewSubcommand}, - programs::{ata::AtaSubcommand, token::TokenProgramAgnosticSubcommand}, -}; - -/// Create a public account and return its ID. -async fn new_public_account(ctx: &mut TestContext) -> Result { - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { account_id } = result else { - anyhow::bail!("Expected RegisterAccount return value"); - }; - Ok(account_id) -} - -/// Create a private account and return its ID. -async fn new_private_account(ctx: &mut TestContext) -> Result { - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { account_id } = result else { - anyhow::bail!("Expected RegisterAccount return value"); - }; - Ok(account_id) -} +use wallet::cli::{Command, programs::ata::AtaSubcommand}; #[test] async fn create_ata_initializes_holding_account() -> Result<()> { let mut ctx = TestContext::new().await?; - let definition_account_id = new_public_account(&mut ctx).await?; - let supply_account_id = new_public_account(&mut ctx).await?; - let owner_account_id = new_public_account(&mut ctx).await?; + let definition_account_id = new_account(&mut ctx, false, None).await?; + let supply_account_id = new_account(&mut ctx, false, None).await?; + let owner_account_id = new_account(&mut ctx, false, None).await?; // Create a fungible token let total_supply = 100_u128; - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Token(TokenProgramAgnosticSubcommand::New { - definition_account_id: public_mention(definition_account_id), - supply_account_id: public_mention(supply_account_id), - name: "TEST".to_owned(), - total_supply, - }), + create_token( + &mut ctx, + public_mention(definition_account_id), + public_mention(supply_account_id), + "TEST".to_owned(), + total_supply, ) .await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - // Create the ATA for owner + definition wallet::cli::execute_subcommand( ctx.wallet_mut(), @@ -121,25 +80,20 @@ async fn create_ata_initializes_holding_account() -> Result<()> { async fn create_ata_is_idempotent() -> Result<()> { let mut ctx = TestContext::new().await?; - let definition_account_id = new_public_account(&mut ctx).await?; - let supply_account_id = new_public_account(&mut ctx).await?; - let owner_account_id = new_public_account(&mut ctx).await?; + let definition_account_id = new_account(&mut ctx, false, None).await?; + let supply_account_id = new_account(&mut ctx, false, None).await?; + let owner_account_id = new_account(&mut ctx, false, None).await?; // Create a fungible token - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Token(TokenProgramAgnosticSubcommand::New { - definition_account_id: public_mention(definition_account_id), - supply_account_id: public_mention(supply_account_id), - name: "TEST".to_owned(), - total_supply: 100, - }), + create_token( + &mut ctx, + public_mention(definition_account_id), + public_mention(supply_account_id), + "TEST".to_owned(), + 100, ) .await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - // Create the ATA once wallet::cli::execute_subcommand( ctx.wallet_mut(), @@ -196,28 +150,23 @@ async fn create_ata_is_idempotent() -> Result<()> { async fn transfer_and_burn_via_ata() -> Result<()> { let mut ctx = TestContext::new().await?; - let definition_account_id = new_public_account(&mut ctx).await?; - let supply_account_id = new_public_account(&mut ctx).await?; - let sender_account_id = new_public_account(&mut ctx).await?; - let recipient_account_id = new_public_account(&mut ctx).await?; + let definition_account_id = new_account(&mut ctx, false, None).await?; + let supply_account_id = new_account(&mut ctx, false, None).await?; + let sender_account_id = new_account(&mut ctx, false, None).await?; + let recipient_account_id = new_account(&mut ctx, false, None).await?; let total_supply = 1000_u128; // Create a fungible token, supply goes to supply_account_id - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Token(TokenProgramAgnosticSubcommand::New { - definition_account_id: public_mention(definition_account_id), - supply_account_id: public_mention(supply_account_id), - name: "TEST".to_owned(), - total_supply, - }), + create_token( + &mut ctx, + public_mention(definition_account_id), + public_mention(supply_account_id), + "TEST".to_owned(), + total_supply, ) .await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - // Derive ATA addresses let ata_program_id = programs::ata().id(); let sender_ata_id = get_associated_token_account_id( @@ -252,23 +201,14 @@ async fn transfer_and_burn_via_ata() -> Result<()> { // Fund sender's ATA from the supply account (direct token transfer) let fund_amount = 200_u128; - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Token(TokenProgramAgnosticSubcommand::Send { - from: public_mention(supply_account_id), - to: Some(public_mention(sender_ata_id)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: fund_amount, - }), + token_send( + &mut ctx, + public_mention(supply_account_id), + public_mention(sender_ata_id), + fund_amount, ) .await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - // Transfer from sender's ATA to recipient's ATA via the ATA program let transfer_amount = 50_u128; wallet::cli::execute_subcommand( @@ -286,7 +226,7 @@ async fn transfer_and_burn_via_ata() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Verify sender ATA balance decreased - let sender_ata_acc = ctx.sequencer_client().get_account(sender_ata_id).await?; + let sender_ata_acc = get_account(&ctx, sender_ata_id).await?; let sender_holding = TokenHolding::try_from(&sender_ata_acc.data)?; assert_eq!( sender_holding, @@ -297,7 +237,7 @@ async fn transfer_and_burn_via_ata() -> Result<()> { ); // Verify recipient ATA balance increased - let recipient_ata_acc = ctx.sequencer_client().get_account(recipient_ata_id).await?; + let recipient_ata_acc = get_account(&ctx, recipient_ata_id).await?; let recipient_holding = TokenHolding::try_from(&recipient_ata_acc.data)?; assert_eq!( recipient_holding, @@ -323,7 +263,7 @@ async fn transfer_and_burn_via_ata() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Verify sender ATA balance after burn - let sender_ata_acc = ctx.sequencer_client().get_account(sender_ata_id).await?; + let sender_ata_acc = get_account(&ctx, sender_ata_id).await?; let sender_holding = TokenHolding::try_from(&sender_ata_acc.data)?; assert_eq!( sender_holding, @@ -334,10 +274,7 @@ async fn transfer_and_burn_via_ata() -> Result<()> { ); // Verify the token definition total_supply decreased by burn_amount - let definition_acc = ctx - .sequencer_client() - .get_account(definition_account_id) - .await?; + let definition_acc = get_account(&ctx, definition_account_id).await?; let token_definition = TokenDefinition::try_from(&definition_acc.data)?; assert_eq!( token_definition, @@ -355,25 +292,20 @@ async fn transfer_and_burn_via_ata() -> Result<()> { async fn create_ata_with_private_owner() -> Result<()> { let mut ctx = TestContext::new().await?; - let definition_account_id = new_public_account(&mut ctx).await?; - let supply_account_id = new_public_account(&mut ctx).await?; - let owner_account_id = new_private_account(&mut ctx).await?; + let definition_account_id = new_account(&mut ctx, false, None).await?; + let supply_account_id = new_account(&mut ctx, false, None).await?; + let owner_account_id = new_account(&mut ctx, true, None).await?; // Create a fungible token - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Token(TokenProgramAgnosticSubcommand::New { - definition_account_id: public_mention(definition_account_id), - supply_account_id: public_mention(supply_account_id), - name: "TEST".to_owned(), - total_supply: 100, - }), + create_token( + &mut ctx, + public_mention(definition_account_id), + public_mention(supply_account_id), + "TEST".to_owned(), + 100, ) .await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - // Create the ATA for the private owner + definition wallet::cli::execute_subcommand( ctx.wallet_mut(), @@ -424,28 +356,23 @@ async fn create_ata_with_private_owner() -> Result<()> { async fn transfer_via_ata_private_owner() -> Result<()> { let mut ctx = TestContext::new().await?; - let definition_account_id = new_public_account(&mut ctx).await?; - let supply_account_id = new_public_account(&mut ctx).await?; - let sender_account_id = new_private_account(&mut ctx).await?; - let recipient_account_id = new_public_account(&mut ctx).await?; + let definition_account_id = new_account(&mut ctx, false, None).await?; + let supply_account_id = new_account(&mut ctx, false, None).await?; + let sender_account_id = new_account(&mut ctx, true, None).await?; + let recipient_account_id = new_account(&mut ctx, false, None).await?; let total_supply = 1000_u128; // Create a fungible token - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Token(TokenProgramAgnosticSubcommand::New { - definition_account_id: public_mention(definition_account_id), - supply_account_id: public_mention(supply_account_id), - name: "TEST".to_owned(), - total_supply, - }), + create_token( + &mut ctx, + public_mention(definition_account_id), + public_mention(supply_account_id), + "TEST".to_owned(), + total_supply, ) .await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - // Derive ATA addresses let ata_program_id = programs::ata().id(); let sender_ata_id = get_associated_token_account_id( @@ -480,23 +407,14 @@ async fn transfer_via_ata_private_owner() -> Result<()> { // Fund sender's ATA from the supply account (direct token transfer) let fund_amount = 200_u128; - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Token(TokenProgramAgnosticSubcommand::Send { - from: public_mention(supply_account_id), - to: Some(public_mention(sender_ata_id)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: fund_amount, - }), + token_send( + &mut ctx, + public_mention(supply_account_id), + public_mention(sender_ata_id), + fund_amount, ) .await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - // Transfer from sender's ATA (private owner) to recipient's ATA let transfer_amount = 50_u128; wallet::cli::execute_subcommand( @@ -514,7 +432,7 @@ async fn transfer_via_ata_private_owner() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Verify sender ATA balance decreased - let sender_ata_acc = ctx.sequencer_client().get_account(sender_ata_id).await?; + let sender_ata_acc = get_account(&ctx, sender_ata_id).await?; let sender_holding = TokenHolding::try_from(&sender_ata_acc.data)?; assert_eq!( sender_holding, @@ -525,7 +443,7 @@ async fn transfer_via_ata_private_owner() -> Result<()> { ); // Verify recipient ATA balance increased - let recipient_ata_acc = ctx.sequencer_client().get_account(recipient_ata_id).await?; + let recipient_ata_acc = get_account(&ctx, recipient_ata_id).await?; let recipient_holding = TokenHolding::try_from(&recipient_ata_acc.data)?; assert_eq!( recipient_holding, @@ -549,27 +467,22 @@ async fn transfer_via_ata_private_owner() -> Result<()> { async fn burn_via_ata_private_owner() -> Result<()> { let mut ctx = TestContext::new().await?; - let definition_account_id = new_public_account(&mut ctx).await?; - let supply_account_id = new_public_account(&mut ctx).await?; - let holder_account_id = new_private_account(&mut ctx).await?; + let definition_account_id = new_account(&mut ctx, false, None).await?; + let supply_account_id = new_account(&mut ctx, false, None).await?; + let holder_account_id = new_account(&mut ctx, true, None).await?; let total_supply = 500_u128; // Create a fungible token - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Token(TokenProgramAgnosticSubcommand::New { - definition_account_id: public_mention(definition_account_id), - supply_account_id: public_mention(supply_account_id), - name: "TEST".to_owned(), - total_supply, - }), + create_token( + &mut ctx, + public_mention(definition_account_id), + public_mention(supply_account_id), + "TEST".to_owned(), + total_supply, ) .await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - // Derive holder's ATA address let ata_program_id = programs::ata().id(); let holder_ata_id = get_associated_token_account_id( @@ -592,23 +505,14 @@ async fn burn_via_ata_private_owner() -> Result<()> { // Fund holder's ATA from the supply account let fund_amount = 300_u128; - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Token(TokenProgramAgnosticSubcommand::Send { - from: public_mention(supply_account_id), - to: Some(public_mention(holder_ata_id)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: fund_amount, - }), + token_send( + &mut ctx, + public_mention(supply_account_id), + public_mention(holder_ata_id), + fund_amount, ) .await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - // Burn from holder's ATA (private owner) let burn_amount = 100_u128; wallet::cli::execute_subcommand( @@ -625,7 +529,7 @@ async fn burn_via_ata_private_owner() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Verify holder ATA balance after burn - let holder_ata_acc = ctx.sequencer_client().get_account(holder_ata_id).await?; + let holder_ata_acc = get_account(&ctx, holder_ata_id).await?; let holder_holding = TokenHolding::try_from(&holder_ata_acc.data)?; assert_eq!( holder_holding, @@ -636,10 +540,7 @@ async fn burn_via_ata_private_owner() -> Result<()> { ); // Verify the token definition total_supply decreased by burn_amount - let definition_acc = ctx - .sequencer_client() - .get_account(definition_account_id) - .await?; + let definition_acc = get_account(&ctx, definition_account_id).await?; let token_definition = TokenDefinition::try_from(&definition_acc.data)?; assert_eq!( token_definition, diff --git a/integration_tests/tests/auth_transfer/private.rs b/integration_tests/tests/auth_transfer/private.rs index 30f0cfdd..f172b27e 100644 --- a/integration_tests/tests/auth_transfer/private.rs +++ b/integration_tests/tests/auth_transfer/private.rs @@ -3,17 +3,18 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use common::transaction::LeeTransaction; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, fetch_privacy_preserving_tx, private_mention, - public_mention, verify_commitment_is_in_state, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, + assert_private_commitment_in_state, fetch_privacy_preserving_tx, get_account, new_account, + private_mention, public_mention, send, sync_private, verify_commitment_is_in_state, }; use lee::{ - AccountId, SharedSecretKey, execute_and_prove, - privacy_preserving_transaction::circuit::ProgramWithDependencies, program::Program, + AccountId, execute_and_prove, privacy_preserving_transaction::circuit::ProgramWithDependencies, + program::Program, }; use lee_core::{ - EncryptedAccountData, InputAccountIdentity, NullifierPublicKey, - account::AccountWithMetadata, - encryption::{EphemeralPublicKey, ViewingPublicKey}, + DUMMY_COMMITMENT_HASH, InputAccountIdentity, Nullifier, NullifierPublicKey, + account::{Account, AccountWithMetadata}, + encryption::ViewingPublicKey, }; use log::info; use sequencer_service_rpc::RpcClient as _; @@ -34,32 +35,13 @@ async fn private_transfer_to_owned_account() -> Result<()> { let from: AccountId = ctx.existing_private_accounts()[0]; let to: AccountId = ctx.existing_private_accounts()[1]; - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: private_mention(from), - to: Some(private_mention(to)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 100, - }); - - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + send(&mut ctx, private_mention(from), private_mention(to), 100).await?; info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let new_commitment1 = ctx - .wallet() - .get_private_account_commitment(from) - .context("Failed to get private account commitment for sender")?; - assert!(verify_commitment_is_in_state(new_commitment1, ctx.sequencer_client()).await); - - let new_commitment2 = ctx - .wallet() - .get_private_account_commitment(to) - .context("Failed to get private account commitment for receiver")?; - assert!(verify_commitment_is_in_state(new_commitment2, ctx.sequencer_client()).await); + assert_private_commitment_in_state(&ctx, from, "sender").await?; + assert_private_commitment_in_state(&ctx, to, "receiver").await?; info!("Successfully transferred privately to owned account"); @@ -86,8 +68,8 @@ async fn private_transfer_to_foreign_account() -> Result<()> { }); let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = result else { - anyhow::bail!("Expected PrivacyPreservingTransfer return value"); + let SubcommandReturnValue::TransactionExecuted { tx_hash } = result else { + anyhow::bail!("Expected TransactionExecuted return value"); }; info!("Waiting for next block creation"); @@ -99,9 +81,8 @@ async fn private_transfer_to_foreign_account() -> Result<()> { .context("Failed to get private account commitment for sender")?; let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await; - assert_eq!(tx.message.new_commitments[0], new_commitment1); + assert!(tx.message.new_commitments.contains(&new_commitment1)); - assert_eq!(tx.message.new_commitments.len(), 2); for commitment in tx.message.new_commitments { assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); } @@ -125,6 +106,38 @@ async fn deshielded_transfer_to_public_account() -> Result<()> { .context("Failed to get sender's private account")?; assert_eq!(from_acc.balance, 10000); + send(&mut ctx, private_mention(from), public_mention(to), 100).await?; + + info!("Waiting for next block creation"); + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + + let from_acc = ctx + .wallet() + .get_account_private(from) + .context("Failed to get sender's private account")?; + assert_private_commitment_in_state(&ctx, from, "sender").await?; + + let acc_2_balance = account_balance(&ctx, to).await?; + + assert_eq!(from_acc.balance, 9900); + assert_eq!(acc_2_balance, 20100); + + info!("Successfully deshielded transfer to public account"); + + Ok(()) +} + +/// A deshielded transfer's public recipient must not be asked to sign the transaction: the +/// sender's private-side proof is the only authorization the protocol requires, and signing +/// with the recipient's key (when the wallet happens to hold it) would leak a link between +/// the two accounts. +#[test] +async fn deshielded_transfer_does_not_sign_with_recipient_key() -> Result<()> { + let mut ctx = TestContext::new().await?; + + let from: AccountId = ctx.existing_private_accounts()[0]; + let to: AccountId = ctx.existing_public_accounts()[1]; + let command = Command::AuthTransfer(AuthTransferSubcommand::Send { from: private_mention(from), to: Some(public_mention(to)), @@ -135,27 +148,22 @@ async fn deshielded_transfer_to_public_account() -> Result<()> { amount: 100, }); - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + let SubcommandReturnValue::TransactionExecuted { tx_hash } = result else { + anyhow::bail!("Expected TransactionExecuted return value"); + }; info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let from_acc = ctx - .wallet() - .get_account_private(from) - .context("Failed to get sender's private account")?; - let new_commitment = ctx - .wallet() - .get_private_account_commitment(from) - .context("Failed to get private account commitment")?; - assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await); + let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await; - let acc_2_balance = ctx.sequencer_client().get_account_balance(to).await?; + assert!( + tx.witness_set().signatures_and_public_keys().is_empty(), + "deshielded transfer must not carry any signature, in particular not the recipient's" + ); - assert_eq!(from_acc.balance, 9900); - assert_eq!(acc_2_balance, 20100); - - info!("Successfully deshielded transfer to public account"); + info!("Deshielded transfer correctly did not sign with the recipient's key"); Ok(()) } @@ -167,18 +175,7 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> { let from: AccountId = ctx.existing_private_accounts()[0]; // Create a new private account - let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })); - - let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - let SubcommandReturnValue::RegisterAccount { - account_id: to_account_id, - } = sub_ret - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let to_account_id = new_account(&mut ctx, true, None).await?; // Get the keys for the newly created account let to = ctx @@ -200,23 +197,21 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> { }); let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = sub_ret else { - anyhow::bail!("Expected PrivacyPreservingTransfer return value"); + let SubcommandReturnValue::TransactionExecuted { tx_hash } = sub_ret else { + anyhow::bail!("Expected TransactionExecuted return value"); }; let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await; // Sync the wallet to claim the new account - let command = Command::Account(AccountSubcommand::SyncPrivate {}); - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + sync_private(&mut ctx).await?; - let new_commitment1 = ctx + let sender_commitment = ctx .wallet() .get_private_account_commitment(from) .context("Failed to get private account commitment for sender")?; - assert_eq!(tx.message.new_commitments[0], new_commitment1); + assert!(tx.message.new_commitments.contains(&sender_commitment)); - assert_eq!(tx.message.new_commitments.len(), 2); for commitment in tx.message.new_commitments { assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); } @@ -239,17 +234,7 @@ async fn shielded_transfer_to_owned_private_account() -> Result<()> { let from: AccountId = ctx.existing_public_accounts()[0]; let to: AccountId = ctx.existing_private_accounts()[1]; - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: public_mention(from), - to: Some(private_mention(to)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 100, - }); - - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + send(&mut ctx, public_mention(from), private_mention(to), 100).await?; info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; @@ -258,13 +243,9 @@ async fn shielded_transfer_to_owned_private_account() -> Result<()> { .wallet() .get_account_private(to) .context("Failed to get receiver's private account")?; - let new_commitment = ctx - .wallet() - .get_private_account_commitment(to) - .context("Failed to get receiver's commitment")?; - assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await); + assert_private_commitment_in_state(&ctx, to, "receiver").await?; - let acc_from_balance = ctx.sequencer_client().get_account_balance(from).await?; + let acc_from_balance = account_balance(&ctx, from).await?; assert_eq!(acc_from_balance, 9900); assert_eq!(acc_to.balance, 20100); @@ -294,8 +275,8 @@ async fn shielded_transfer_to_foreign_account() -> Result<()> { }); let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = result else { - anyhow::bail!("Expected PrivacyPreservingTransfer return value"); + let SubcommandReturnValue::TransactionExecuted { tx_hash } = result else { + anyhow::bail!("Expected TransactionExecuted return value"); }; info!("Waiting for next block creation"); @@ -303,15 +284,11 @@ async fn shielded_transfer_to_foreign_account() -> Result<()> { let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await; - let acc_1_balance = ctx.sequencer_client().get_account_balance(from).await?; + let acc_1_balance = account_balance(&ctx, from).await?; - assert!( - verify_commitment_is_in_state( - tx.message.new_commitments[0].clone(), - ctx.sequencer_client() - ) - .await - ); + for commitment in tx.message.new_commitments { + assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); + } assert_eq!(acc_1_balance, 9900); @@ -332,18 +309,7 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> { let from: AccountId = ctx.existing_private_accounts()[0]; // Create a new private account - let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })); - let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - - let SubcommandReturnValue::RegisterAccount { - account_id: to_account_id, - } = sub_ret - else { - anyhow::bail!("Failed to register account"); - }; + let to_account_id = new_account(&mut ctx, true, None).await?; // Get the newly created account's keys let to = ctx @@ -365,7 +331,7 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> { }); let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = sub_ret else { + let SubcommandReturnValue::TransactionExecuted { tx_hash } = sub_ret else { anyhow::bail!("Failed to send transaction"); }; @@ -376,7 +342,6 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Verify commitments are in state - assert_eq!(tx.message.new_commitments.len(), 2); for commitment in tx.message.new_commitments { assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); } @@ -396,14 +361,7 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> { async fn initialize_private_account() -> Result<()> { let mut ctx = TestContext::new().await?; - let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })); - let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - let SubcommandReturnValue::RegisterAccount { account_id } = result else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let account_id = new_account(&mut ctx, true, None).await?; let command = Command::AuthTransfer(AuthTransferSubcommand::Init { account_id: private_mention(account_id), @@ -413,14 +371,9 @@ async fn initialize_private_account() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; info!("Syncing private accounts"); - let command = Command::Account(AccountSubcommand::SyncPrivate {}); - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + sync_private(&mut ctx).await?; - let new_commitment = ctx - .wallet() - .get_private_account_commitment(account_id) - .context("Failed to get private account commitment")?; - assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await); + assert_private_commitment_in_state(&ctx, account_id, "account").await?; let account = ctx .wallet() @@ -455,32 +408,19 @@ async fn private_transfer_using_from_label() -> Result<()> { wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; // Send using the label instead of account ID - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: CliAccountMention::Label(label), - to: Some(private_mention(to)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 100, - }); - - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + send( + &mut ctx, + CliAccountMention::Label(label), + private_mention(to), + 100, + ) + .await?; info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let new_commitment1 = ctx - .wallet() - .get_private_account_commitment(from) - .context("Failed to get private account commitment for sender")?; - assert!(verify_commitment_is_in_state(new_commitment1, ctx.sequencer_client()).await); - - let new_commitment2 = ctx - .wallet() - .get_private_account_commitment(to) - .context("Failed to get private account commitment for receiver")?; - assert!(verify_commitment_is_in_state(new_commitment2, ctx.sequencer_client()).await); + assert_private_commitment_in_state(&ctx, from, "sender").await?; + assert_private_commitment_in_state(&ctx, to, "receiver").await?; info!("Successfully transferred privately using from_label"); @@ -510,14 +450,9 @@ async fn initialize_private_account_using_label() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let command = Command::Account(AccountSubcommand::SyncPrivate {}); - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + sync_private(&mut ctx).await?; - let new_commitment = ctx - .wallet() - .get_private_account_commitment(account_id) - .context("Failed to get private account commitment")?; - assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await); + assert_private_commitment_in_state(&ctx, account_id, "account").await?; let account = ctx .wallet() @@ -593,21 +528,17 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> { info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::SyncPrivate {}), - ) - .await?; + sync_private(&mut ctx).await?; // Both accounts must be discovered with the correct balances. - let account_id_1 = AccountId::for_regular_private_account(&npk, identifier_1); + let account_id_1 = AccountId::for_regular_private_account(&npk, &vpk, identifier_1); let acc_1 = ctx .wallet() .get_account_private(account_id_1) .context("account for identifier 1 not found after sync")?; assert_eq!(acc_1.balance, 100); - let account_id_2 = AccountId::for_regular_private_account(&npk, identifier_2); + let account_id_2 = AccountId::for_regular_private_account(&npk, &vpk, identifier_2); let acc_2 = ctx .wallet() .get_account_private(account_id_2) @@ -663,25 +594,19 @@ async fn ppt_cant_chain_call_faucet() -> Result<()> { let nsk: lee_core::NullifierSecretKey = [3; 32]; let npk = NullifierPublicKey::from(&nsk); let vpk = ViewingPublicKey::from_bytes(vec![4_u8; 1184]).unwrap(); - let ssk = SharedSecretKey([55_u8; 32]); - let epk = EphemeralPublicKey(vec![55_u8; 1088]); let attacker_vault_id = { let seed = vault_core::compute_vault_seed(attacker_id); - AccountId::for_private_pda(&vault_program_id, &seed, &npk, 1337) + AccountId::for_private_pda(&vault_program_id, &seed, &npk, &vpk, 1337) }; let amount: u128 = 1; let faucet_pre = AccountWithMetadata::new( - ctx.sequencer_client() - .get_account(faucet_account_id) - .await?, + get_account(&ctx, faucet_account_id).await?, false, faucet_account_id, ); let vault_pda_pre = AccountWithMetadata::new( - ctx.sequencer_client() - .get_account(attacker_vault_id) - .await?, + get_account(&ctx, attacker_vault_id).await?, false, attacker_vault_id, ); @@ -705,11 +630,11 @@ async fn ppt_cant_chain_call_faucet() -> Result<()> { vec![ InputAccountIdentity::Public, InputAccountIdentity::PrivatePdaInit { - epk, - view_tag: EncryptedAccountData::compute_view_tag(&npk, &vpk), + vpk, + random_seed: [0; 32], npk, - ssk, identifier: 1337, + commitment_root: DUMMY_COMMITMENT_HASH, seed: None, }, ], @@ -720,3 +645,89 @@ async fn ppt_cant_chain_call_faucet() -> Result<()> { Ok(()) } + +async fn prove_init_with_commitment_root( + ctx: &TestContext, + commitment_root: lee_core::CommitmentSetDigest, +) -> Result { + let program = programs::authenticated_transfer(); + let sender_id = ctx.existing_public_accounts()[0]; + let sender_pre = AccountWithMetadata::new( + ctx.sequencer_client().get_account(sender_id).await?, + true, + sender_id, + ); + + let nsk: lee_core::NullifierSecretKey = [7; 32]; + let npk = NullifierPublicKey::from(&nsk); + let vpk = ViewingPublicKey::from_bytes(vec![4_u8; 1184]).unwrap(); + let recipient_account_id = AccountId::for_regular_private_account(&npk, &vpk, 0); + let recipient = AccountWithMetadata::new(Account::default(), true, recipient_account_id); + + let (output, _) = execute_and_prove( + vec![sender_pre, recipient], + Program::serialize_instruction(authenticated_transfer_core::Instruction::Transfer { + amount: 1, + })?, + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateForeignInit { + vpk, + random_seed: [0; 32], + npk, + identifier: 0, + commitment_root, + }, + ], + &program.into(), + )?; + + Ok(output) +} + +#[test] +async fn init_with_dummy_commitment_root_produces_valid_root() -> Result<()> { + let ctx = TestContext::new().await?; + + let (_, expected_digest) = ctx.sequencer_client().get_proofs_and_root(vec![]).await?; + + let nsk: lee_core::NullifierSecretKey = [7; 32]; + let npk = NullifierPublicKey::from(&nsk); + let vpk = ViewingPublicKey::from_bytes(vec![4_u8; 1184]).unwrap(); + let recipient_account_id = AccountId::for_regular_private_account(&npk, &vpk, 0); + + let output = prove_init_with_commitment_root(&ctx, expected_digest).await?; + + assert_eq!(output.new_nullifiers.len(), 1); + let (nullifier, digest) = &output.new_nullifiers[0]; + assert_eq!( + *nullifier, + Nullifier::for_account_initialization(&recipient_account_id) + ); + assert_eq!(*digest, expected_digest); + assert_ne!(*digest, DUMMY_COMMITMENT_HASH); + + Ok(()) +} + +#[test] +async fn init_nullifier_digest_is_bound_to_commitment_root() -> Result<()> { + let ctx = TestContext::new().await?; + + let (_, expected_digest) = ctx.sequencer_client().get_proofs_and_root(vec![]).await?; + + let output_with_root = prove_init_with_commitment_root(&ctx, expected_digest).await?; + let output_without_root = prove_init_with_commitment_root(&ctx, DUMMY_COMMITMENT_HASH).await?; + + assert_eq!(output_with_root.new_nullifiers[0].1, expected_digest); + assert_eq!( + output_without_root.new_nullifiers[0].1, + DUMMY_COMMITMENT_HASH + ); + assert_ne!( + output_with_root.new_nullifiers[0].1, + output_without_root.new_nullifiers[0].1, + ); + + Ok(()) +} diff --git a/integration_tests/tests/auth_transfer/public.rs b/integration_tests/tests/auth_transfer/public.rs index 5bbf0954..ea0838ef 100644 --- a/integration_tests/tests/auth_transfer/public.rs +++ b/integration_tests/tests/auth_transfer/public.rs @@ -1,17 +1,19 @@ use std::time::Duration; -use anyhow::Result; +use anyhow::{Context as _, Result}; use common::transaction::LeeTransaction; -use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, public_mention}; -use lee::public_transaction; +use integration_tests::{ + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, get_account, new_account, + public_mention, send, send_claiming_new_account, +}; +use lee::{PublicKey, public_transaction}; use log::info; use sequencer_service_rpc::RpcClient as _; use tokio::test; use wallet::{ account::Label, cli::{ - CliAccountMention, Command, SubcommandReturnValue, - account::{AccountSubcommand, NewSubcommand}, + CliAccountMention, Command, SubcommandReturnValue, account::AccountSubcommand, programs::native_token_transfer::AuthTransferSubcommand, }, }; @@ -20,30 +22,29 @@ use wallet::{ async fn successful_transfer_to_existing_account() -> Result<()> { let mut ctx = TestContext::new().await?; + let sender = ctx.existing_public_accounts()[0]; + let receiver = ctx.existing_public_accounts()[1]; + let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: public_mention(ctx.existing_public_accounts()[0]), - to: Some(public_mention(ctx.existing_public_accounts()[1])), + from: public_mention(sender), + to: Some(public_mention(receiver)), to_npk: None, to_vpk: None, to_keys: None, to_identifier: Some(0), amount: 100, }); - - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + let SubcommandReturnValue::TransactionExecuted { tx_hash } = result else { + anyhow::bail!("Expected TransactionExecuted return value"); + }; info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; info!("Checking correct balance move"); - let acc_1_balance = ctx - .sequencer_client() - .get_account_balance(ctx.existing_public_accounts()[0]) - .await?; - let acc_2_balance = ctx - .sequencer_client() - .get_account_balance(ctx.existing_public_accounts()[1]) - .await?; + let acc_1_balance = account_balance(&ctx, sender).await?; + let acc_2_balance = account_balance(&ctx, receiver).await?; info!("Balance of sender: {acc_1_balance:#?}"); info!("Balance of receiver: {acc_2_balance:#?}"); @@ -51,6 +52,34 @@ async fn successful_transfer_to_existing_account() -> Result<()> { assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); + // The recipient already exists, so the protocol doesn't require its signature, and the + // wallet must never sign with a key it doesn't need to use. Assert the transfer's witness + // set contains exactly the sender's signature, not the recipient's. + let (tx, _block_id) = ctx + .sequencer_client() + .get_transaction(tx_hash) + .await? + .context("transfer transaction should be included in a block")?; + let LeeTransaction::Public(tx) = tx else { + anyhow::bail!("Expected a public transaction"); + }; + let sender_public_key = PublicKey::new_from_private_key( + ctx.wallet() + .get_account_public_signing_key(sender) + .context("sender should have a signing key")?, + ); + let signers: Vec<_> = tx + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, public_key)| public_key) + .collect(); + assert_eq!( + signers, + vec![&sender_public_key], + "only the sender should sign a transfer to an existing account" + ); + Ok(()) } @@ -58,51 +87,16 @@ async fn successful_transfer_to_existing_account() -> Result<()> { pub async fn successful_transfer_to_new_account() -> Result<()> { let mut ctx = TestContext::new().await?; - let command = Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })); + let new_persistent_account_id = new_account(&mut ctx, false, None).await?; - wallet::cli::execute_subcommand(ctx.wallet_mut(), command) - .await - .unwrap(); - - let new_persistent_account_id = ctx - .wallet() - .storage() - .key_chain() - .public_account_ids() - .map(|(account_id, _)| account_id) - .find(|acc_id| { - *acc_id != ctx.existing_public_accounts()[0] - && *acc_id != ctx.existing_public_accounts()[1] - }) - .expect("Failed to find newly created account in the wallet storage"); - - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: public_mention(ctx.existing_public_accounts()[0]), - to: Some(public_mention(new_persistent_account_id)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 100, - }); - - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + let sender = ctx.existing_public_accounts()[0]; + // The wallet CLI never signs with the recipient's key, but claiming this fresh account + // requires it, so bypass the CLI for this one send. + send_claiming_new_account(&mut ctx, sender, new_persistent_account_id, 100).await?; info!("Checking correct balance move"); - let acc_1_balance = ctx - .sequencer_client() - .get_account_balance(ctx.existing_public_accounts()[0]) - .await?; - let acc_2_balance = ctx - .sequencer_client() - .get_account_balance(new_persistent_account_id) - .await?; + let acc_1_balance = account_balance(&ctx, sender).await?; + let acc_2_balance = account_balance(&ctx, new_persistent_account_id).await?; info!("Balance of sender: {acc_1_balance:#?}"); info!("Balance of receiver: {acc_2_balance:#?}"); @@ -134,14 +128,8 @@ async fn failed_transfer_with_insufficient_balance() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; info!("Checking balances unchanged"); - let acc_1_balance = ctx - .sequencer_client() - .get_account_balance(ctx.existing_public_accounts()[0]) - .await?; - let acc_2_balance = ctx - .sequencer_client() - .get_account_balance(ctx.existing_public_accounts()[1]) - .await?; + let acc_1_balance = account_balance(&ctx, ctx.existing_public_accounts()[0]).await?; + let acc_2_balance = account_balance(&ctx, ctx.existing_public_accounts()[1]).await?; info!("Balance of sender: {acc_1_balance:#?}"); info!("Balance of receiver: {acc_2_balance:#?}"); @@ -156,31 +144,24 @@ async fn failed_transfer_with_insufficient_balance() -> Result<()> { async fn two_consecutive_successful_transfers() -> Result<()> { let mut ctx = TestContext::new().await?; - // First transfer - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: public_mention(ctx.existing_public_accounts()[0]), - to: Some(public_mention(ctx.existing_public_accounts()[1])), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 100, - }); + let sender = ctx.existing_public_accounts()[0]; + let receiver = ctx.existing_public_accounts()[1]; - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + // First transfer + send( + &mut ctx, + public_mention(sender), + public_mention(receiver), + 100, + ) + .await?; info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; info!("Checking correct balance move after first transfer"); - let acc_1_balance = ctx - .sequencer_client() - .get_account_balance(ctx.existing_public_accounts()[0]) - .await?; - let acc_2_balance = ctx - .sequencer_client() - .get_account_balance(ctx.existing_public_accounts()[1]) - .await?; + let acc_1_balance = account_balance(&ctx, sender).await?; + let acc_2_balance = account_balance(&ctx, receiver).await?; info!("Balance of sender: {acc_1_balance:#?}"); info!("Balance of receiver: {acc_2_balance:#?}"); @@ -191,30 +172,20 @@ async fn two_consecutive_successful_transfers() -> Result<()> { info!("First TX Success!"); // Second transfer - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: public_mention(ctx.existing_public_accounts()[0]), - to: Some(public_mention(ctx.existing_public_accounts()[1])), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 100, - }); - - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + send( + &mut ctx, + public_mention(sender), + public_mention(receiver), + 100, + ) + .await?; info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; info!("Checking correct balance move after second transfer"); - let acc_1_balance = ctx - .sequencer_client() - .get_account_balance(ctx.existing_public_accounts()[0]) - .await?; - let acc_2_balance = ctx - .sequencer_client() - .get_account_balance(ctx.existing_public_accounts()[1]) - .await?; + let acc_1_balance = account_balance(&ctx, sender).await?; + let acc_2_balance = account_balance(&ctx, receiver).await?; info!("Balance of sender: {acc_1_balance:#?}"); info!("Balance of receiver: {acc_2_balance:#?}"); @@ -231,14 +202,7 @@ async fn two_consecutive_successful_transfers() -> Result<()> { async fn initialize_public_account() -> Result<()> { let mut ctx = TestContext::new().await?; - let command = Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })); - let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - let SubcommandReturnValue::RegisterAccount { account_id } = result else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let account_id = new_account(&mut ctx, false, None).await?; let command = Command::AuthTransfer(AuthTransferSubcommand::Init { account_id: public_mention(account_id), @@ -246,7 +210,7 @@ async fn initialize_public_account() -> Result<()> { wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; info!("Checking correct execution"); - let account = ctx.sequencer_client().get_account(account_id).await?; + let account = get_account(&ctx, account_id).await?; assert_eq!( account.program_owner, @@ -274,30 +238,22 @@ async fn successful_transfer_using_from_label() -> Result<()> { wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; // Send using the label instead of account ID - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: CliAccountMention::Label(label), - to: Some(public_mention(ctx.existing_public_accounts()[1])), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 100, - }); - - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + let sender = ctx.existing_public_accounts()[0]; + let receiver = ctx.existing_public_accounts()[1]; + send( + &mut ctx, + CliAccountMention::Label(label), + public_mention(receiver), + 100, + ) + .await?; info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; info!("Checking correct balance move"); - let acc_1_balance = ctx - .sequencer_client() - .get_account_balance(ctx.existing_public_accounts()[0]) - .await?; - let acc_2_balance = ctx - .sequencer_client() - .get_account_balance(ctx.existing_public_accounts()[1]) - .await?; + let acc_1_balance = account_balance(&ctx, sender).await?; + let acc_2_balance = account_balance(&ctx, receiver).await?; assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); @@ -320,30 +276,22 @@ async fn successful_transfer_using_to_label() -> Result<()> { wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; // Send using the label for the recipient - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: public_mention(ctx.existing_public_accounts()[0]), - to: Some(CliAccountMention::Label(label)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 100, - }); - - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + let sender = ctx.existing_public_accounts()[0]; + let receiver = ctx.existing_public_accounts()[1]; + send( + &mut ctx, + public_mention(sender), + CliAccountMention::Label(label), + 100, + ) + .await?; info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; info!("Checking correct balance move"); - let acc_1_balance = ctx - .sequencer_client() - .get_account_balance(ctx.existing_public_accounts()[0]) - .await?; - let acc_2_balance = ctx - .sequencer_client() - .get_account_balance(ctx.existing_public_accounts()[1]) - .await?; + let acc_1_balance = account_balance(&ctx, sender).await?; + let acc_2_balance = account_balance(&ctx, receiver).await?; assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); @@ -359,14 +307,8 @@ async fn cannot_transfer_funds_from_system_faucet_account() -> Result<()> { let faucet_account_id = system_accounts::faucet_account_id(); let recipient = ctx.existing_public_accounts()[0]; - let recipient_balance_before = ctx - .sequencer_client() - .get_account_balance(recipient) - .await?; - let faucet_balance_before = ctx - .sequencer_client() - .get_account_balance(faucet_account_id) - .await?; + let recipient_balance_before = account_balance(&ctx, recipient).await?; + let faucet_balance_before = account_balance(&ctx, faucet_account_id).await?; let amount = 1_u128; let message = public_transaction::Message::try_new( @@ -387,14 +329,8 @@ async fn cannot_transfer_funds_from_system_faucet_account() -> Result<()> { info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let recipient_balance_after = ctx - .sequencer_client() - .get_account_balance(recipient) - .await?; - let faucet_balance_after = ctx - .sequencer_client() - .get_account_balance(faucet_account_id) - .await?; + let recipient_balance_after = account_balance(&ctx, recipient).await?; + let faucet_balance_after = account_balance(&ctx, faucet_account_id).await?; let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?; assert_eq!(recipient_balance_after, recipient_balance_before); @@ -413,14 +349,8 @@ async fn cannot_execute_faucet_program() -> Result<()> { let vault_program_id = programs::vault().id(); let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient); - let recipient_balance_before = ctx - .sequencer_client() - .get_account_balance(recipient) - .await?; - let faucet_balance_before = ctx - .sequencer_client() - .get_account_balance(faucet_account_id) - .await?; + let recipient_balance_before = account_balance(&ctx, recipient).await?; + let faucet_balance_before = account_balance(&ctx, faucet_account_id).await?; let amount = 1_u128; let message = public_transaction::Message::try_new( @@ -445,14 +375,8 @@ async fn cannot_execute_faucet_program() -> Result<()> { info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let recipient_balance_after = ctx - .sequencer_client() - .get_account_balance(recipient) - .await?; - let faucet_balance_after = ctx - .sequencer_client() - .get_account_balance(faucet_account_id) - .await?; + let recipient_balance_after = account_balance(&ctx, recipient).await?; + let faucet_balance_after = account_balance(&ctx, faucet_account_id).await?; let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?; assert_eq!(recipient_balance_after, recipient_balance_before); @@ -493,28 +417,16 @@ async fn user_tx_that_chain_calls_faucet_is_dropped() -> Result<()> { lee::public_transaction::WitnessSet::from_raw_parts(vec![]), )); - let faucet_balance_before = ctx - .sequencer_client() - .get_account_balance(faucet_account_id) - .await?; - let vault_balance_before = ctx - .sequencer_client() - .get_account_balance(attacker_vault_id) - .await?; + let faucet_balance_before = account_balance(&ctx, faucet_account_id).await?; + let vault_balance_before = account_balance(&ctx, attacker_vault_id).await?; let tx_hash = ctx.sequencer_client().send_transaction(attack_tx).await?; info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let faucet_balance_after = ctx - .sequencer_client() - .get_account_balance(faucet_account_id) - .await?; - let vault_balance_after = ctx - .sequencer_client() - .get_account_balance(attacker_vault_id) - .await?; + let faucet_balance_after = account_balance(&ctx, faucet_account_id).await?; + let vault_balance_after = account_balance(&ctx, attacker_vault_id).await?; let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?; assert_eq!(faucet_balance_after, faucet_balance_before); diff --git a/integration_tests/tests/bridge.rs b/integration_tests/tests/bridge.rs index 9efdd641..e980ffac 100644 --- a/integration_tests/tests/bridge.rs +++ b/integration_tests/tests/bridge.rs @@ -1,42 +1,23 @@ #![expect( clippy::tests_outside_test_module, - clippy::arithmetic_side_effects, reason = "We don't care about these in tests" )] -use std::{ops::Deref as _, time::Duration}; +use std::time::Duration; use anyhow::Context as _; -use borsh::BorshSerialize; use common::transaction::LeeTransaction; -use futures::StreamExt as _; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, wait_for_indexer_to_catch_up, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, get_account, }; use lee::{ - AccountId, execute_and_prove, privacy_preserving_transaction, program::Program, - public_transaction, + execute_and_prove, privacy_preserving_transaction, program::Program, public_transaction, }; use lee_core::{InputAccountIdentity, account::AccountWithMetadata}; -use log::info; -use logos_blockchain_core::mantle::{ledger::Inputs, ops::channel::deposit::DepositOp}; -use logos_blockchain_http_api_common::bodies::{ - channel::ChannelDepositRequestBody, - wallet::{ - balance::WalletBalanceResponseBody, - transfer_funds::{WalletTransferFundsRequestBody, WalletTransferFundsResponseBody}, - }, -}; -use logos_blockchain_zone_sdk::{ - CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, -}; -use num_bigint::BigUint; use sequencer_service_rpc::RpcClient as _; -use test_fixtures::public_mention; use tokio::test; -use wallet::cli::{Command, execute_subcommand, programs::bridge::BridgeSubcommand}; -const TIME_TO_FINALIZE_DEPOSIT_EVENT_ON_BEDROCK: Duration = Duration::from_mins(2); +// const TIME_TO_FINALIZE_DEPOSIT_EVENT_ON_BEDROCK: Duration = Duration::from_mins(2); #[test] async fn public_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { @@ -46,10 +27,11 @@ async fn public_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { let bridge_account_id = system_accounts::bridge_account_id(); let vault_program_id = programs::vault().id(); let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id); + let receipt_id = bridge_core::deposit_receipt_account_id(programs::bridge().id(), [0_u8; 32]); let message = public_transaction::Message::try_new( programs::bridge().id(), - vec![bridge_account_id, recipient_vault_id], + vec![bridge_account_id, recipient_vault_id, receipt_id], vec![], bridge_core::Instruction::Deposit { l1_deposit_op_id: [0_u8; 32], @@ -65,27 +47,15 @@ async fn public_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { lee::public_transaction::WitnessSet::from_raw_parts(vec![]), )); - let bridge_balance_before = ctx - .sequencer_client() - .get_account_balance(bridge_account_id) - .await?; - let vault_balance_before = ctx - .sequencer_client() - .get_account_balance(recipient_vault_id) - .await?; + let bridge_balance_before = account_balance(&ctx, bridge_account_id).await?; + let vault_balance_before = account_balance(&ctx, recipient_vault_id).await?; let tx_hash = ctx.sequencer_client().send_transaction(attack_tx).await?; tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let bridge_balance_after = ctx - .sequencer_client() - .get_account_balance(bridge_account_id) - .await?; - let vault_balance_after = ctx - .sequencer_client() - .get_account_balance(recipient_vault_id) - .await?; + let bridge_balance_after = account_balance(&ctx, bridge_account_id).await?; + let vault_balance_after = account_balance(&ctx, recipient_vault_id).await?; let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?; assert_eq!(bridge_balance_after, bridge_balance_before); @@ -106,10 +76,11 @@ async fn public_bridge_deposit_with_zero_amount_is_rejected() -> anyhow::Result< let bridge_account_id = system_accounts::bridge_account_id(); let vault_program_id = programs::vault().id(); let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id); + let receipt_id = bridge_core::deposit_receipt_account_id(programs::bridge().id(), [0_u8; 32]); let message = public_transaction::Message::try_new( programs::bridge().id(), - vec![bridge_account_id, recipient_vault_id], + vec![bridge_account_id, recipient_vault_id, receipt_id], vec![], bridge_core::Instruction::Deposit { l1_deposit_op_id: [0_u8; 32], @@ -166,22 +137,22 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { let bridge_account_id = system_accounts::bridge_account_id(); let vault_program_id = programs::vault().id(); let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id); + let receipt_id = bridge_core::deposit_receipt_account_id(programs::bridge().id(), [0_u8; 32]); - // Get pre-state of bridge and vault accounts + // Get pre-state of bridge and vault accounts; the receipt is unminted (a + // default account), so the program would create it on a first mint. let bridge_pre = AccountWithMetadata::new( - ctx.sequencer_client() - .get_account(bridge_account_id) - .await?, + get_account(&ctx, bridge_account_id).await?, false, bridge_account_id, ); let vault_pre = AccountWithMetadata::new( - ctx.sequencer_client() - .get_account(recipient_vault_id) - .await?, + get_account(&ctx, recipient_vault_id).await?, false, recipient_vault_id, ); + let receipt_pre = + AccountWithMetadata::new(lee_core::account::Account::default(), false, receipt_id); // Create program with dependencies let program_with_deps = @@ -208,17 +179,25 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { // Execute and prove the bridge deposit let (output, proof) = execute_and_prove( - vec![bridge_pre.clone(), vault_pre.clone()], + vec![bridge_pre.clone(), vault_pre.clone(), receipt_pre.clone()], instruction, - vec![InputAccountIdentity::Public, InputAccountIdentity::Public], + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Public, + InputAccountIdentity::Public, + ], &program_with_deps, ) .context("Failed to execute/prove bridge deposit")?; // Create privacy-preserving transaction from circuit output let message = privacy_preserving_transaction::Message::try_from_circuit_output( - vec![bridge_account_id, recipient_vault_id], - vec![bridge_pre.account.nonce, vault_pre.account.nonce], + vec![bridge_account_id, recipient_vault_id, receipt_id], + vec![ + bridge_pre.account.nonce, + vault_pre.account.nonce, + receipt_pre.account.nonce, + ], output, ) .context("Failed to build privacy-preserving bridge deposit message")?; @@ -229,27 +208,15 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { witness_set, )); - let bridge_balance_before = ctx - .sequencer_client() - .get_account_balance(bridge_account_id) - .await?; - let vault_balance_before = ctx - .sequencer_client() - .get_account_balance(recipient_vault_id) - .await?; + let bridge_balance_before = account_balance(&ctx, bridge_account_id).await?; + let vault_balance_before = account_balance(&ctx, recipient_vault_id).await?; let tx_hash = ctx.sequencer_client().send_transaction(attack_tx).await?; tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let bridge_balance_after = ctx - .sequencer_client() - .get_account_balance(bridge_account_id) - .await?; - let vault_balance_after = ctx - .sequencer_client() - .get_account_balance(recipient_vault_id) - .await?; + let bridge_balance_after = account_balance(&ctx, bridge_account_id).await?; + let vault_balance_after = account_balance(&ctx, recipient_vault_id).await?; let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?; assert_eq!(bridge_balance_after, bridge_balance_before); @@ -262,389 +229,386 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { Ok(()) } -async fn submit_bedrock_deposit( - bedrock_addr: std::net::SocketAddr, - bedrock_account_pk: &str, - recipient_id: AccountId, - amount: u64, -) -> anyhow::Result<()> { - #[derive(BorshSerialize)] - struct DepositMetadata { - recipient_id: AccountId, - } +// async fn submit_bedrock_deposit( +// bedrock_addr: std::net::SocketAddr, +// bedrock_account_pk: &str, +// recipient_id: AccountId, +// amount: u64, +// ) -> anyhow::Result<()> { +// #[derive(BorshSerialize)] +// struct DepositMetadata { +// recipient_id: AccountId, +// } - // Encode deposit metadata - let metadata = borsh::to_vec(&DepositMetadata { recipient_id }) - .context("Failed to encode deposit metadata")? - .try_into() - .context("Encoded metadata is too big")?; +// // Encode deposit metadata +// let metadata = borsh::to_vec(&DepositMetadata { recipient_id }) +// .context("Failed to encode deposit metadata")? +// .try_into() +// .context("Encoded metadata is too big")?; - let channel_id = integration_tests::config::bedrock_channel_id(); - let client = reqwest::Client::new(); +// let channel_id = integration_tests::config::bedrock_channel_id(); +// let client = reqwest::Client::new(); - let query_balance = || async { - let balance_response = client - .get(format!( - "http://{bedrock_addr}/wallet/{bedrock_account_pk}/balance" - )) - .send() - .await - .context("Failed to query Bedrock wallet balance")?; +// let mut balance = bedrock_wallet_balance(bedrock_addr, bedrock_account_pk).await?; - let balance_response = check_response_success(balance_response).await?; +// info!( +// "Queried Bedrock balance for key {bedrock_account_pk}: {:?}", +// balance.balance +// ); - balance_response - .json::() - .await - .context("Failed to decode Bedrock balance response") - }; +// if balance.balance < amount { +// anyhow::bail!( +// "Bedrock wallet with key {bedrock_account_pk} has insufficient balance {:?} for +// deposit amount {:?}", balance.balance, +// amount +// ); +// } - let mut balance = query_balance().await?; +// let mut selected_note_id = balance +// .notes +// .iter() +// .find_map(|(note_id, value)| (*value == amount).then_some(*note_id)); - info!( - "Queried Bedrock balance for key {bedrock_account_pk}: {:?}", - balance.balance - ); +// if selected_note_id.is_none() { +// let transfer_body = WalletTransferFundsRequestBody { +// tip: None, +// change_public_key: balance.address, +// funding_public_keys: vec![balance.address], +// recipient_public_key: balance.address, +// amount, +// }; - if balance.balance < amount { - anyhow::bail!( - "Bedrock wallet with key {bedrock_account_pk} has insufficient balance {:?} for deposit amount {:?}", - balance.balance, - amount - ); - } +// let transfer_response = client +// .post(format!( +// "http://{bedrock_addr}/wallet/transactions/transfer-funds" +// )) +// .json(&transfer_body) +// .send() +// .await +// .context("Failed to submit Bedrock transfer-funds request")?; +// let transfer_response = check_response_success(transfer_response).await?; - let mut selected_note_id = balance - .notes - .iter() - .find_map(|(note_id, value)| (*value == amount).then_some(*note_id)); +// let transfer: WalletTransferFundsResponseBody = transfer_response +// .json() +// .await +// .context("Failed to decode Bedrock transfer-funds response")?; - if selected_note_id.is_none() { - let transfer_body = WalletTransferFundsRequestBody { - tip: None, - change_public_key: balance.address, - funding_public_keys: vec![balance.address], - recipient_public_key: balance.address, - amount, - }; +// info!( +// "Submitted transfer-funds to create exact deposit note, tx hash {:?}", +// transfer.hash +// ); - let transfer_response = client - .post(format!( - "http://{bedrock_addr}/wallet/transactions/transfer-funds" - )) - .json(&transfer_body) - .send() - .await - .context("Failed to submit Bedrock transfer-funds request")?; - let transfer_response = check_response_success(transfer_response).await?; +// let mut found_note = None; +// for _ in 0..20 { +// tokio::time::sleep(Duration::from_millis(500)).await; +// balance = bedrock_wallet_balance(bedrock_addr, bedrock_account_pk).await?; +// found_note = balance +// .notes +// .iter() +// .find_map(|(note_id, value)| (*value == amount).then_some(*note_id)); +// if found_note.is_some() { +// break; +// } +// } - let transfer: WalletTransferFundsResponseBody = transfer_response - .json() - .await - .context("Failed to decode Bedrock transfer-funds response")?; +// selected_note_id = found_note; +// } - info!( - "Submitted transfer-funds to create exact deposit note, tx hash {:?}", - transfer.hash - ); +// let Some(selected_note_id) = selected_note_id else { +// anyhow::bail!( +// "Failed to locate exact-value note {amount:?} for Bedrock deposit; available notes: +// {:?}", balance.notes, +// ); +// }; - let mut found_note = None; - for _ in 0..20 { - tokio::time::sleep(Duration::from_millis(500)).await; - balance = query_balance().await?; - found_note = balance - .notes - .iter() - .find_map(|(note_id, value)| (*value == amount).then_some(*note_id)); - if found_note.is_some() { - break; - } - } +// let body = ChannelDepositRequestBody { +// tip: None, +// deposit: DepositOp { +// channel_id, +// inputs: Inputs::new(selected_note_id), +// metadata, +// }, +// change_public_key: balance.address, +// funding_public_keys: vec![balance.address], +// max_tx_fee: u64::MAX.into(), +// }; - selected_note_id = found_note; - } +// let response = client +// .post(format!("http://{bedrock_addr}/channel/deposit")) +// .json(&body) +// .send() +// .await +// .context("Failed to submit Bedrock deposit request")?; +// let response = check_response_success(response).await?; - let Some(selected_note_id) = selected_note_id else { - anyhow::bail!( - "Failed to locate exact-value note {amount:?} for Bedrock deposit; available notes: {:?}", - balance.notes, - ); - }; +// let body_text = response +// .text() +// .await +// .unwrap_or_else(|_| "".to_owned()); +// info!( +// "Successfully submitted Bedrock deposit request for recipient {recipient_id} and amount +// {amount}, response body: {body_text}", ); - let body = ChannelDepositRequestBody { - tip: None, - deposit: DepositOp { - channel_id, - inputs: Inputs::new(selected_note_id), - metadata, - }, - change_public_key: balance.address, - funding_public_keys: vec![balance.address], - max_tx_fee: 1_000_u64.into(), - }; +// Ok(()) +// } - let response = client - .post(format!("http://{bedrock_addr}/channel/deposit")) - .json(&body) - .send() - .await - .context("Failed to submit Bedrock deposit request")?; - let response = check_response_success(response).await?; +// /// The Bedrock wallet state of `bedrock_account_pk`: its total balance and the +// /// notes it owns, keyed by note id. +// async fn bedrock_wallet_balance( +// bedrock_addr: std::net::SocketAddr, +// bedrock_account_pk: &str, +// ) -> anyhow::Result { +// let response = reqwest::Client::new() +// .get(format!( +// "http://{bedrock_addr}/wallet/{bedrock_account_pk}/balance" +// )) +// .send() +// .await +// .context("Failed to query Bedrock wallet balance")?; - let body_text = response - .text() - .await - .unwrap_or_else(|_| "".to_owned()); - info!( - "Successfully submitted Bedrock deposit request for recipient {recipient_id} and amount {amount}, response body: {body_text}", - ); +// check_response_success(response) +// .await? +// .json::() +// .await +// .context("Failed to decode Bedrock balance response") +// } - Ok(()) -} +// async fn check_response_success(response: reqwest::Response) -> anyhow::Result +// { if response.status().is_success() { +// Ok(response) +// } else { +// let status = response.status(); +// let body_text = response.text().await.unwrap_or_default(); +// anyhow::bail!("Request failed with status {status} and body {body_text}"); +// } +// } -async fn check_response_success(response: reqwest::Response) -> anyhow::Result { - if response.status().is_success() { - Ok(response) - } else { - let status = response.status(); - let body_text = response.text().await.unwrap_or_default(); - anyhow::bail!("Request failed with status {status} and body {body_text}"); - } -} +// async fn wait_for_vault_balance( +// ctx: &TestContext, +// vault_id: AccountId, +// expected_balance: u128, +// ) -> anyhow::Result<()> { +// let timeout = TIME_TO_FINALIZE_DEPOSIT_EVENT_ON_BEDROCK +// + Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS); +// tokio::time::timeout(timeout, async { +// loop { +// let balance = account_balance(ctx, vault_id).await?; +// if balance == expected_balance { +// return Ok(()); +// } +// tokio::time::sleep(Duration::from_millis(500)).await; +// } +// }) +// .await +// .with_context(|| { +// format!("Timed out waiting for vault {vault_id} balance to reach {expected_balance}") +// })? +// } -async fn wait_for_vault_balance( - ctx: &TestContext, - vault_id: AccountId, - expected_balance: u128, -) -> anyhow::Result<()> { - let timeout = TIME_TO_FINALIZE_DEPOSIT_EVENT_ON_BEDROCK - + Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS); - tokio::time::timeout(timeout, async { - loop { - let balance = ctx.sequencer_client().get_account_balance(vault_id).await?; - if balance == expected_balance { - return Ok(()); - } - tokio::time::sleep(Duration::from_millis(500)).await; - } - }) - .await - .with_context(|| { - format!("Timed out waiting for vault {vault_id} balance to reach {expected_balance}") - })? -} +// /// Test deposit and withdraw round trip. +// /// +// /// Implemented as one test instead of two separate tests for deposit and withdraw, because the +// /// withdraw test depends on the deposit to set up the necessary state (funds in vault) for +// testing /// withdraw functionality. +// #[test] +// async fn bedrock_deposit_claim_and_withdraw_round_trip_succeeds() -> anyhow::Result<()> { +// let mut ctx = TestContext::new().await?; -/// Test deposit and withdraw round trip. -/// -/// Implemented as one test instead of two separate tests for deposit and withdraw, because the -/// withdraw test depends on the deposit to set up the necessary state (funds in vault) for testing -/// withdraw functionality. -#[test] -async fn bedrock_deposit_claim_and_withdraw_round_trip_succeeds() -> anyhow::Result<()> { - let mut ctx = TestContext::new().await?; +// let bedrock_account_pk = "2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26"; +// let recipient_id = ctx.existing_public_accounts()[0]; +// let amount = 1_u64; +// let vault_program_id = programs::vault().id(); +// let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, +// recipient_id); - let bedrock_account_pk = "2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26"; - let recipient_id = ctx.existing_public_accounts()[0]; - let amount = 1_u64; - let vault_program_id = programs::vault().id(); - let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id); +// let vault_balance_before = account_balance(&ctx, recipient_vault_id).await?; +// let recipient_balance_before = account_balance(&ctx, recipient_id).await?; - let vault_balance_before = ctx - .sequencer_client() - .get_account_balance(recipient_vault_id) - .await?; - let recipient_balance_before = ctx - .sequencer_client() - .get_account_balance(recipient_id) - .await?; +// // Submit deposit to Bedrock +// submit_bedrock_deposit(ctx.bedrock_addr(), bedrock_account_pk, recipient_id, amount) +// .await +// .context("Failed to submit Bedrock deposit for round-trip setup")?; - // Submit deposit to Bedrock - submit_bedrock_deposit(ctx.bedrock_addr(), bedrock_account_pk, recipient_id, amount) - .await - .context("Failed to submit Bedrock deposit for round-trip setup")?; +// // Wait for vault to receive the deposit (minted from bridge to vault) +// wait_for_vault_balance( +// &ctx, +// recipient_vault_id, +// vault_balance_before + u128::from(amount), +// ) +// .await?; - // Wait for vault to receive the deposit (minted from bridge to vault) - wait_for_vault_balance( - &ctx, - recipient_vault_id, - vault_balance_before + u128::from(amount), - ) - .await?; +// // Now claim funds from vault back to recipient +// let nonces = ctx +// .wallet() +// .get_accounts_nonces(&[recipient_id]) +// .await +// .context("Failed to get nonce for vault claim")?; - // Now claim funds from vault back to recipient - let nonces = ctx - .wallet() - .get_accounts_nonces(vec![recipient_id]) - .await - .context("Failed to get nonce for vault claim")?; +// let signing_key = ctx +// .wallet() +// .storage() +// .key_chain() +// .pub_account_signing_key(recipient_id) +// .with_context(|| format!("Missing signing key for account {recipient_id}"))?; - let signing_key = ctx - .wallet() - .storage() - .key_chain() - .pub_account_signing_key(recipient_id) - .with_context(|| format!("Missing signing key for account {recipient_id}"))?; +// let claim_message = public_transaction::Message::try_new( +// vault_program_id, +// vec![recipient_id, recipient_vault_id], +// nonces, +// vault_core::Instruction::Claim { +// amount: u128::from(amount), +// }, +// ) +// .context("Failed to build vault claim message")?; - let claim_message = public_transaction::Message::try_new( - vault_program_id, - vec![recipient_id, recipient_vault_id], - nonces, - vault_core::Instruction::Claim { - amount: u128::from(amount), - }, - ) - .context("Failed to build vault claim message")?; +// let claim_witness_set = +// public_transaction::WitnessSet::for_message(&claim_message, &[signing_key]); +// let claim_tx = LeeTransaction::Public(lee::PublicTransaction::new( +// claim_message, +// claim_witness_set, +// )); - let claim_witness_set = - public_transaction::WitnessSet::for_message(&claim_message, &[signing_key]); - let claim_tx = LeeTransaction::Public(lee::PublicTransaction::new( - claim_message, - claim_witness_set, - )); +// let claim_hash = ctx.sequencer_client().send_transaction(claim_tx).await?; - let claim_hash = ctx.sequencer_client().send_transaction(claim_tx).await?; +// tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; +// let claim_on_chain = ctx.sequencer_client().get_transaction(claim_hash).await?; +// let vault_balance_after_claim = account_balance(&ctx, recipient_vault_id).await?; +// let recipient_balance_after_claim = account_balance(&ctx, recipient_id).await?; - let claim_on_chain = ctx.sequencer_client().get_transaction(claim_hash).await?; - let vault_balance_after_claim = ctx - .sequencer_client() - .get_account_balance(recipient_vault_id) - .await?; - let recipient_balance_after_claim = ctx - .sequencer_client() - .get_account_balance(recipient_id) - .await?; +// assert!( +// claim_on_chain.is_some(), +// "Vault claim transaction must be included on-chain" +// ); +// assert_eq!( +// vault_balance_after_claim, vault_balance_before, +// "Vault balance should return to initial state after claim" +// ); +// assert_eq!( +// recipient_balance_after_claim, +// recipient_balance_before + u128::from(amount), +// "Recipient balance should increase by claimed amount" +// ); - assert!( - claim_on_chain.is_some(), - "Vault claim transaction must be included on-chain" - ); - assert_eq!( - vault_balance_after_claim, vault_balance_before, - "Vault balance should return to initial state after claim" - ); - assert_eq!( - recipient_balance_after_claim, - recipient_balance_before + u128::from(amount), - "Recipient balance should increase by claimed amount" - ); +// // The indexer must replay the deposit and claim blocks and reach the same +// // state as the sequencer — including the bridge system account the deposit +// // modifies, which is the case the hot fix unblocks. +// wait_for_indexer_to_catch_up(&ctx).await?; +// let bridge_account_id = system_accounts::bridge_account_id(); +// for account_id in [recipient_id, recipient_vault_id, bridge_account_id] { +// let indexer_account = indexer_service_rpc::RpcClient::get_account( +// // `deref` is needed for correct trait resolution +// // of the async `get_account` method on `RpcClient` +// ctx.indexer_client().deref(), +// account_id.into(), +// ) +// .await?; +// let sequencer_account = get_account(&ctx, account_id).await?; +// assert_eq!( +// indexer_account, +// sequencer_account.into(), +// "Indexer and sequencer diverged for account {account_id} after deposit" +// ); +// } - // The indexer must replay the deposit and claim blocks and reach the same - // state as the sequencer — including the bridge system account the deposit - // modifies, which is the case the hot fix unblocks. - wait_for_indexer_to_catch_up(&ctx).await?; - let bridge_account_id = system_accounts::bridge_account_id(); - for account_id in [recipient_id, recipient_vault_id, bridge_account_id] { - let indexer_account = indexer_service_rpc::RpcClient::get_account( - // `deref` is needed for correct trait resolution - // of the async `get_account` method on `RpcClient` - ctx.indexer_client().deref(), - account_id.into(), - ) - .await?; - let sequencer_account = ctx.sequencer_client().get_account(account_id).await?; - assert_eq!( - indexer_account, - sequencer_account.into(), - "Indexer and sequencer diverged for account {account_id} after deposit" - ); - } +// // Withdraw back to Bedrock and wait for finalized withdraw event. +// let sender_id = recipient_id; - // Withdraw back to Bedrock and wait for finalized withdraw event. - let sender_id = recipient_id; +// let observer = create_zone_indexer_observer(ctx.bedrock_addr())?; +// let observe_fut = +// wait_for_finalized_withdraw_op(&observer, ctx.bedrock_addr(), amount, +// bedrock_account_pk); - let observer = create_zone_indexer_observer(ctx.bedrock_addr())?; - let observe_fut = wait_for_finalized_withdraw_op(&observer, amount, bedrock_account_pk); +// let withdraw_fut = execute_subcommand( +// ctx.wallet_mut(), +// Command::Bridge(BridgeSubcommand::Withdraw { +// from: public_mention(sender_id), +// amount, +// bedrock_account_pk: bedrock_account_pk.to_owned(), +// }), +// ); - let withdraw_fut = execute_subcommand( - ctx.wallet_mut(), - Command::Bridge(BridgeSubcommand::Withdraw { - from: public_mention(sender_id), - amount, - bedrock_account_pk: bedrock_account_pk.to_owned(), - }), - ); +// let (observe_result, withdraw_result) = tokio::join!(observe_fut, withdraw_fut); - let (observe_result, withdraw_result) = tokio::join!(observe_fut, withdraw_fut); +// withdraw_result.context("Failed to execute wallet bridge withdraw command")?; - withdraw_result.context("Failed to execute wallet bridge withdraw command")?; +// observe_result +// .context("Failed while waiting for finalized withdraw event from zone indexer")?; - observe_result - .context("Failed while waiting for finalized withdraw event from zone indexer")?; +// // Sleep to observe sequencer log about validated withdraw event +// tokio::time::sleep(Duration::from_secs(1)).await; - // Sleep to observe sequencer log about validated withdraw event - tokio::time::sleep(Duration::from_secs(1)).await; +// Ok(()) +// } - Ok(()) -} +// fn create_zone_indexer_observer( +// bedrock_addr: std::net::SocketAddr, +// ) -> anyhow::Result> { +// let bedrock_url = integration_tests::config::addr_to_url( +// integration_tests::config::UrlProtocol::Http, +// bedrock_addr, +// ) +// .context("Failed to convert Bedrock addr to URL for zone indexer observer")?; -fn create_zone_indexer_observer( - bedrock_addr: std::net::SocketAddr, -) -> anyhow::Result> { - let bedrock_url = integration_tests::config::addr_to_url( - integration_tests::config::UrlProtocol::Http, - bedrock_addr, - ) - .context("Failed to convert Bedrock addr to URL for zone indexer observer")?; +// let node = NodeHttpClient::new(CommonHttpClient::new(None), bedrock_url); - let node = NodeHttpClient::new(CommonHttpClient::new(None), bedrock_url); +// Ok(ZoneIndexer::new( +// integration_tests::config::bedrock_channel_id(), +// node, +// )) +// } - Ok(ZoneIndexer::new( - integration_tests::config::bedrock_channel_id(), - node, - )) -} +// /// Waits for a finalized withdraw that pays `expected_amount` to `receiver_pk`. +// /// +// /// A withdraw op releases channel-owned notes and carries nothing but their +// /// ids — the value and recipient live in the note itself. A released note keeps +// /// its id, value and public key, so the pairing is checked on the receiver's +// /// Bedrock wallet: one of the released notes must land there with the expected +// /// value. +// async fn wait_for_finalized_withdraw_op( +// observer: &ZoneIndexer, +// bedrock_addr: std::net::SocketAddr, +// expected_amount: u64, +// receiver_pk: &str, +// ) -> anyhow::Result<()> { +// let timeout = TIME_TO_FINALIZE_DEPOSIT_EVENT_ON_BEDROCK +// + Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS); -async fn wait_for_finalized_withdraw_op( - observer: &ZoneIndexer, - expected_amount: u64, - receiver_pk: &str, -) -> anyhow::Result<()> { - let timeout = TIME_TO_FINALIZE_DEPOSIT_EVENT_ON_BEDROCK - + Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS); +// tokio::time::timeout(timeout, async { +// // The wallet can trail the channel event, so released notes accumulate +// // across polls instead of being checked once when first observed. +// let mut released_notes = HashSet::new(); - let bedrock_account_pk_bytes = hex::decode(receiver_pk) - .context("Failed to decode expected receiver public key from hex")?; - let expected_receiver_pk = - logos_blockchain_key_management_system_service::keys::ZkPublicKey::from( - BigUint::from_bytes_le(&bedrock_account_pk_bytes), - ); +// loop { +// let stream = observer +// .follow() +// .await +// .context("Failed to read zone indexer message batch")?; +// let mut stream = std::pin::pin!(stream); - tokio::time::timeout(timeout, async { - loop { - let stream = observer - .follow() - .await - .context("Failed to read zone indexer message batch")?; - let mut stream = std::pin::pin!(stream); +// while let Some(message) = stream.next().await { +// info!("Observed zone message {message:?}"); - while let Some(message) = stream.next().await { - info!("Observed zone message {message:?}"); +// if let ZoneMessage::Withdraw(withdraw) = message { +// released_notes.extend(withdraw.inputs.iter().copied()); +// } +// } - let ZoneMessage::Withdraw(withdraw) = message else { - continue; - }; +// if !released_notes.is_empty() { +// let balance = bedrock_wallet_balance(bedrock_addr, receiver_pk).await?; +// if released_notes +// .iter() +// .any(|note_id| balance.notes.get(note_id) == Some(&expected_amount)) +// { +// return Ok(()); +// } +// } - let mut iter = withdraw.outputs.iter(); - let Some(note) = iter.next() else { - continue; - }; - if iter.next().is_some() { - // Withdraw op should only have one output - continue; - } - - if note.value == expected_amount && note.pk == expected_receiver_pk { - return Ok(()); - } - } - - tokio::time::sleep(Duration::from_millis(500)).await; - } - }) - .await - .with_context(|| { - format!("Timed out waiting for finalized withdraw message with amount {expected_amount}") - })? -} +// tokio::time::sleep(Duration::from_millis(500)).await; +// } +// }) +// .await +// .with_context(|| { +// format!("Timed out waiting for finalized withdraw message with amount {expected_amount}") +// })? +// } diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs new file mode 100644 index 00000000..919b3dbc --- /dev/null +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -0,0 +1,191 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "top-level test functions are conventional for integration tests" +)] + +//! Demo 2: a wrapped-token bridge over the cross-zone spine. A holder locks part +//! of their bridgeable balance on zone A; the watcher carries the emitted mint to +//! zone B, where the indexer re-derives and verifies it (Option B) before the +//! wrapped token is minted to the recipient. Reuses the M3/M4 spine unchanged; +//! only the source caller (`bridge_lock`) and target (`wrapped_token`) are new. +//! +//! Not production-safe. The inbox allowlist gates the target program, not the +//! source emitter, and `extract_emission` recognizes any known emitter, so in a +//! zone that allows `wrapped_token` as a target a permissionless `ping_sender` +//! send can carry a `wrapped_token::Mint` and mint with no lock. Making this safe +//! needs source verification, where a value-bearing target checks the message +//! originated from `bridge_lock`; that is out of scope for the demo. + +use std::time::Duration; + +use anyhow::{Context as _, Result}; +use common::transaction::LeeTransaction; +use cross_zone_outbox_core::outbox_pda; +use integration_tests::{ + config::{self, SequencerPartialConfig}, + indexer_client::IndexerClient, + setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer}, +}; +use lee::{ + AccountId, PrivateKey, PublicKey, PublicTransaction, + public_transaction::{Message, WitnessSet}, +}; +use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, GenesisAction}; +use sequencer_service_rpc::RpcClient as _; +use tokio::test; + +const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600); +const INITIAL_BALANCE: u128 = 100; +const LOCK_AMOUNT: u128 = 30; +const RECIPIENT: [u8; 32] = [9; 32]; + +#[test] +async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { + // Declared first so it outlives both zones (drops run in reverse order). + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to set up shared Bedrock node")?; + + let partial = SequencerPartialConfig::default(); + let channel_a = config::bedrock_channel_id(); + let channel_b = config::bedrock_channel_id_b(); + let zone_b: [u8; 32] = *channel_b.as_ref(); + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + + let wrapped_token_id = programs::wrapped_token().id(); + let cross_zone = CrossZoneConfig { + peers: vec![CrossZonePeer { + channel_id: *channel_a.as_ref(), + allowed_targets: vec![wrapped_token_id], + expected_block_signing_pubkey: None, + }], + }; + + // Zone A seeds the holder's bridgeable balance. Zone B runs the watcher on its + // sequencer and the verifier on its indexer. + let genesis_a = vec![GenesisAction::SupplyBridgeLockHolding { + holder: holder_id, + amount: INITIAL_BALANCE, + }]; + let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_a) + .with_genesis(genesis_a) + .setup() + .await + .context("Failed to set up zone A sequencer")?; + let (_seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_b) + .with_genesis(vec![]) + .with_cross_zone(cross_zone.clone()) + .setup() + .await + .context("Failed to set up zone B sequencer")?; + let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone)) + .await + .context("Failed to set up zone B indexer")?; + + // Lock LOCK_AMOUNT on zone A, addressed to the recipient on zone B. + let lock = build_lock_tx(&holder_key, holder_id, zone_b); + sequencer_client(seq_a.addr())? + .send_transaction(lock) + .await + .context("Failed to submit lock on zone A")?; + + // Wait until zone B's indexer reflects the verified mint. + let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); + let indexer = indexer_client(idx_b.addr()) + .await + .context("Failed to build indexer client")?; + + let minted = wait_for_mint(&indexer, holding_id).await?; + assert_eq!( + minted, LOCK_AMOUNT, + "zone B must mint exactly the locked amount" + ); + + // Conservation: the mint on B must be backed by an equal lock on A. The lock + // has already landed (it preceded delivery), so zone A reflects the debit and + // escrow now. + let seq_a_client = sequencer_client(seq_a.addr())?; + let escrow_id = bridge_lock_core::escrow_account_id(programs::bridge_lock().id()); + let escrowed = seq_a_client.get_account(escrow_id).await?.balance; + assert_eq!( + escrowed, LOCK_AMOUNT, + "zone A escrow must hold the locked amount" + ); + let remaining = seq_a_client.get_account(holder_id).await?.balance; + assert_eq!( + remaining, + INITIAL_BALANCE - LOCK_AMOUNT, + "zone A holder must be debited by the locked amount" + ); + Ok(()) +} + +/// Builds a signed `bridge_lock` Lock that forwards a wrapped-token Mint of the +/// locked amount to the recipient on the target zone. +fn build_lock_tx( + holder_key: &PrivateKey, + holder_id: AccountId, + target_zone: [u8; 32], +) -> LeeTransaction { + let bridge_lock_id = programs::bridge_lock().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let outbox_id = programs::cross_zone_outbox().id(); + let ordinal = 0; + + let mint = wrapped_token_core::Instruction::Mint { + recipient: RECIPIENT, + amount: LOCK_AMOUNT, + }; + let words = risc0_zkvm::serde::to_vec(&mint).expect("serialize mint"); + let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); + + let target_accounts = vec![ + wrapped_token_core::config_account_id(wrapped_token_id).into_value(), + wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT).into_value(), + ]; + let lock = bridge_lock_core::Instruction::Lock { + amount: LOCK_AMOUNT, + target_zone, + target_program_id: wrapped_token_id, + target_accounts, + payload, + outbox_program_id: outbox_id, + ordinal, + }; + + let accounts = vec![ + holder_id, + bridge_lock_core::escrow_account_id(bridge_lock_id), + outbox_pda(outbox_id, &target_zone, ordinal), + ]; + // One nonce per signature: the holder signs, at its genesis nonce 0. + let message = Message::try_new(bridge_lock_id, accounts, vec![0_u128.into()], lock) + .expect("build lock message"); + let witness = WitnessSet::for_message(&message, &[holder_key]); + LeeTransaction::Public(PublicTransaction::new(message, witness)) +} + +/// Polls zone B's indexer until the recipient's wrapped holding is non-zero. +async fn wait_for_mint(indexer: &IndexerClient, holding_id: AccountId) -> Result { + let account_id = indexer_service_protocol::AccountId { + value: holding_id.into_value(), + }; + let wait = async { + loop { + let account = + indexer_service_rpc::RpcClient::get_account(&**indexer, account_id).await?; + let balance = wrapped_token_core::read_balance(&account.data.0); + if balance != 0 { + return Ok::(balance); + } + tokio::time::sleep(Duration::from_secs(3)).await; + } + }; + tokio::time::timeout(DELIVERY_TIMEOUT, wait) + .await + .context("Zone B's indexer did not mint the wrapped token in time")? +} diff --git a/integration_tests/tests/cross_zone_ingress_guard.rs b/integration_tests/tests/cross_zone_ingress_guard.rs new file mode 100644 index 00000000..86731477 --- /dev/null +++ b/integration_tests/tests/cross_zone_ingress_guard.rs @@ -0,0 +1,73 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "We don't care about these in tests" +)] + +//! M6 ingress guard: the cross-zone inbox is sequencer-only. Only the watcher +//! injects inbox dispatches; a user must not be able to invoke the inbox through +//! the public RPC, or anyone could forge an inbound cross-zone delivery. The +//! inbox guest's caller-is-none assertion passes for a top-level user tx, so the +//! sequencer ingress guard is the only thing that stops this. + +use anyhow::{Context as _, Result}; +use common::transaction::LeeTransaction; +use cross_zone_inbox_core::{ + CrossZoneMessage, Instruction, inbox_config_account_id, inbox_seen_shard_account_id, +}; +use integration_tests::{ + config::{self, SequencerPartialConfig}, + setup::{SequencerSetup, sequencer_client, setup_bedrock_node}, +}; +use lee::{ + PublicTransaction, + public_transaction::{Message, WitnessSet}, +}; +use sequencer_service_rpc::RpcClient as _; +use tokio::test; + +#[test] +async fn user_origin_inbox_call_rejected() -> Result<()> { + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to set up Bedrock node")?; + let partial = SequencerPartialConfig::default(); + let channel = config::bedrock_channel_id(); + let (seq, _seq_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel) + .with_genesis(vec![]) + .setup() + .await + .context("Failed to set up sequencer")?; + + // A user hand-builds a top-level inbox Dispatch and submits it via RPC. + let inbox_id = programs::cross_zone_inbox().id(); + let msg = CrossZoneMessage { + src_zone: [2; 32], + src_block_id: 1, + src_tx_index: 0, + src_program_id: [9; 8], + target_program_id: programs::ping_receiver().id(), + payload: vec![], + l1_inclusion_witness: None, + }; + let seen_id = inbox_seen_shard_account_id(inbox_id, &msg.src_zone, msg.src_block_id); + let message = Message::try_new( + inbox_id, + vec![inbox_config_account_id(inbox_id), seen_id], + vec![], + Instruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = LeeTransaction::Public(PublicTransaction::new( + message, + WitnessSet::from_raw_parts(vec![]), + )); + + let result = sequencer_client(seq.addr())?.send_transaction(tx).await; + let err = result.expect_err("the sequencer must reject a user-origin inbox call"); + assert!( + err.to_string().contains("sequencer-only"), + "rejection should cite the sequencer-only guard, got: {err}" + ); + Ok(()) +} diff --git a/integration_tests/tests/cross_zone_ping.rs b/integration_tests/tests/cross_zone_ping.rs new file mode 100644 index 00000000..f106bda1 --- /dev/null +++ b/integration_tests/tests/cross_zone_ping.rs @@ -0,0 +1,141 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "top-level test functions are conventional for integration tests" +)] + +//! End-to-end cross-zone round trip: a ping submitted on zone A is delivered by +//! zone B's watcher to `ping_receiver` on zone B, which records the payload. +//! +//! Two sequencers share one Bedrock node (no indexers): zone A publishes the +//! ping to Bedrock, zone B's watcher reads zone A's finalized blocks, injects the +//! inbox dispatch, and zone B's sequencer delivers it. This is the M3 milestone, +//! sequencer-trusted, with no indexer re-derivation (that is M4). + +use std::time::Duration; + +use anyhow::{Context as _, Result}; +use common::transaction::LeeTransaction; +use cross_zone_outbox_core::outbox_pda; +use integration_tests::{ + config::{self, SequencerPartialConfig}, + setup::{SequencerSetup, sequencer_client, setup_bedrock_node}, +}; +use lee::{AccountId, PublicTransaction, public_transaction::Message}; +use lee_core::program::ProgramId; +use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use sequencer_core::config::{CrossZoneConfig, CrossZonePeer}; +use sequencer_service_rpc::{RpcClient as _, SequencerClient}; +use tokio::test; + +const DELIVERY_TIMEOUT: Duration = Duration::from_secs(480); +const PING_PAYLOAD: &[u8] = b"hello-cross-zone"; + +#[test] +async fn ping_crosses_from_zone_a_to_zone_b() -> Result<()> { + // Declared first so it outlives both zones (drops run in reverse order). + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to set up shared Bedrock node")?; + + let partial = SequencerPartialConfig::default(); + let channel_a = config::bedrock_channel_id(); + let channel_b = config::bedrock_channel_id_b(); + let zone_a: [u8; 32] = *channel_a.as_ref(); + let zone_b: [u8; 32] = *channel_b.as_ref(); + + let receiver_id = programs::ping_receiver().id(); + + // Zone B watches zone A and allows delivery only to ping_receiver. + let cross_zone = CrossZoneConfig { + peers: vec![CrossZonePeer { + channel_id: zone_a, + allowed_targets: vec![receiver_id], + expected_block_signing_pubkey: None, + }], + }; + + let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_a) + .with_genesis(vec![]) + .setup() + .await + .context("Failed to set up zone A sequencer")?; + let (seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_b) + .with_genesis(vec![]) + .with_cross_zone(cross_zone) + .setup() + .await + .context("Failed to set up zone B sequencer")?; + + // Submit the ping on zone A, addressed to ping_receiver on zone B. + let ping = build_ping_tx(zone_b, receiver_id); + sequencer_client(seq_a.addr())? + .send_transaction(ping) + .await + .context("Failed to submit ping on zone A")?; + + // Wait until zone B's sequencer records the delivered payload. + let record_id = ping_record_pda(receiver_id); + let delivered = wait_for_delivery(sequencer_client(seq_b.addr())?, record_id).await?; + + assert_eq!( + delivered, PING_PAYLOAD, + "Zone B must record the payload delivered from zone A" + ); + Ok(()) +} + +/// Builds a top-level `ping_sender` transaction that chains into the outbox to emit +/// a message carrying a `ping_receiver::Record` instruction for the target zone. +fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransaction { + let outbox_id = programs::cross_zone_outbox().id(); + let ordinal = 0; + + // The payload is the ping_receiver instruction, serialized as risc0 words in + // little-endian bytes (the contract the inbox reverses when forwarding). + let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { + payload: PING_PAYLOAD.to_vec(), + }) + .expect("serialize ping instruction"); + let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); + + let send = SenderInstruction::Send { + outbox_program_id: outbox_id, + target_zone, + target_program_id: receiver_id, + target_accounts: vec![ping_record_pda(receiver_id).into_value()], + payload, + ordinal, + }; + + let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal); + let message = Message::try_new( + programs::ping_sender().id(), + vec![outbox_account], + vec![], + send, + ) + .expect("build ping message"); + LeeTransaction::Public(PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + )) +} + +/// Polls zone B's sequencer until the ping record PDA holds a payload. +async fn wait_for_delivery(client: SequencerClient, record_id: AccountId) -> Result> { + let wait = async { + loop { + let account = client.get_account(record_id).await?; + let data = account.data.into_inner(); + if !data.is_empty() { + return Ok::, anyhow::Error>(data); + } + tokio::time::sleep(Duration::from_secs(3)).await; + } + }; + tokio::time::timeout(DELIVERY_TIMEOUT, wait) + .await + .context("Zone B did not record the cross-zone payload in time")? +} diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs new file mode 100644 index 00000000..f1080c8c --- /dev/null +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -0,0 +1,367 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "top-level test functions are conventional for integration tests" +)] + +//! Single-zone state-machine tests for cross-zone delivery (ping demo) and the +//! wrapped-token bridge (Demo 2). They drive the guests in isolation, no watcher +//! or Bedrock: a hand-built `cross_zone_inbox::Dispatch` (as the watcher would +//! inject) and the source `bridge_lock::Lock` (which escrows and chains +//! `outbox::Emit`). Fast, so they pin guest logic before the e2e exercises the +//! plumbing. Run with `RISC0_DEV_MODE=1`. + +use std::collections::BTreeMap; + +use cross_zone_inbox_core::{ + CrossZoneMessage, InboxConfig, Instruction as InboxInstruction, SeenShard, + inbox_config_account_id, inbox_seen_shard_account_id, message_key, +}; +use cross_zone_outbox_core::{OutboxRecord, outbox_pda}; +use lee::{ + AccountId, PrivateKey, PublicKey, PublicTransaction, V03State, ValidatedStateDiff, + public_transaction::{Message, WitnessSet}, +}; +use lee_core::account::Account; +use ping_core::{ReceiverInstruction, ping_record_pda}; + +const INITIAL_BALANCE: u128 = 100; +const LOCK_AMOUNT: u128 = 30; +const RECIPIENT: [u8; 32] = [9; 32]; + +/// State registering the cross-zone builtins these tests exercise. +fn base_state() -> V03State { + V03State::new().with_programs([ + programs::cross_zone_inbox(), + programs::cross_zone_outbox(), + programs::ping_receiver(), + programs::bridge_lock(), + programs::wrapped_token(), + ]) +} + +/// Seeds an inbox config (inbox-owned) allowing `src_zone -> target`. +fn seed_inbox_config( + state: &mut V03State, + self_zone: [u8; 32], + src_zone: [u8; 32], + target: lee_core::program::ProgramId, +) { + let inbox_id = programs::cross_zone_inbox().id(); + let mut allowed_targets = BTreeMap::new(); + allowed_targets.insert(src_zone, vec![target]); + let config = InboxConfig { + self_zone, + allowed_peers: BTreeMap::new(), + allowed_targets, + }; + *state = std::mem::replace(state, V03State::new()).with_public_accounts([( + inbox_config_account_id(inbox_id), + Account { + program_owner: inbox_id, + balance: 0, + data: config + .to_bytes() + .try_into() + .expect("config fits in account data"), + nonce: 0_u128.into(), + }, + )]); +} + +/// Seeds the wrapped-token config account pinning the inbox as authorized minter, +/// matching what genesis seeds for a real zone. +fn seed_wrapped_config(state: &mut V03State) { + let wrapped_token_id = programs::wrapped_token().id(); + *state = std::mem::replace(state, V03State::new()).with_public_accounts([( + wrapped_token_core::config_account_id(wrapped_token_id), + Account { + program_owner: wrapped_token_id, + data: wrapped_token_core::minter_bytes(programs::cross_zone_inbox().id()) + .to_vec() + .try_into() + .expect("minter id fits in account data"), + ..Default::default() + }, + )]); +} + +/// The wrapped-token `Mint` the bridge forwards, serialized as the cross-zone +/// payload (risc0 words, little-endian bytes). +fn mint_payload() -> Vec { + let mint = wrapped_token_core::Instruction::Mint { + recipient: RECIPIENT, + amount: LOCK_AMOUNT, + }; + let words = risc0_zkvm::serde::to_vec(&mint).expect("serialize mint"); + words.iter().flat_map(|word| word.to_le_bytes()).collect() +} + +/// Drives `cross_zone_inbox::Dispatch` directly through the state machine +/// (no watcher) and asserts the message is delivered to `ping_receiver`, which +/// records the payload into its own PDA. +#[test] +fn inbox_dispatch_delivers_payload_to_ping_receiver() { + let inbox_id = programs::cross_zone_inbox().id(); + let receiver_id = programs::ping_receiver().id(); + + let self_zone = [1_u8; 32]; + let src_zone = [2_u8; 32]; + let src_block_id = 5; + + let mut state = base_state(); + seed_inbox_config(&mut state, self_zone, src_zone, receiver_id); + + // The payload is the ping_receiver instruction, serialized as risc0 words in + // little-endian bytes (the contract the inbox reverses when forwarding). + let inner = b"hello-cross-zone".to_vec(); + let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { + payload: inner.clone(), + }) + .expect("serialize ping instruction"); + let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); + + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_tx_index: 0, + src_program_id: [9_u32; 8], + target_program_id: receiver_id, + payload, + l1_inclusion_witness: None, + }; + + let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); + let record_id = ping_record_pda(receiver_id); + + let message = Message::try_new( + inbox_id, + vec![inbox_config_account_id(inbox_id), seen_id, record_id], + vec![], + InboxInstruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) + .expect("dispatch must validate and execute"); + let record = diff + .public_diff() + .get(&record_id) + .expect("ping record account must change") + .clone(); + assert_eq!( + record.data.into_inner(), + inner, + "ping_receiver must record the delivered payload" + ); +} + +/// Drives `bridge_lock::Lock` and asserts it debits the holder, credits the +/// escrow, and records the forwarded mint in the outbox PDA. +#[test] +fn lock_escrows_balance_and_emits_to_outbox() { + let bridge_lock_id = programs::bridge_lock().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let outbox_id = programs::cross_zone_outbox().id(); + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let mut state = base_state(); + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + state = state.with_public_accounts([( + holder_id, + Account { + program_owner: bridge_lock_id, + balance: INITIAL_BALANCE, + ..Default::default() + }, + )]); + + let payload = mint_payload(); + let target_accounts = vec![ + wrapped_token_core::config_account_id(wrapped_token_id).into_value(), + wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT).into_value(), + ]; + let lock = bridge_lock_core::Instruction::Lock { + amount: LOCK_AMOUNT, + target_zone: zone_b, + target_program_id: wrapped_token_id, + target_accounts, + payload: payload.clone(), + outbox_program_id: outbox_id, + ordinal, + }; + + let escrow_id = bridge_lock_core::escrow_account_id(bridge_lock_id); + let outbox_record_id = outbox_pda(outbox_id, &zone_b, ordinal); + let message = Message::try_new( + bridge_lock_id, + vec![holder_id, escrow_id, outbox_record_id], + vec![0_u128.into()], + lock, + ) + .expect("build lock message"); + let witness = WitnessSet::for_message(&message, &[&holder_key]); + let tx = PublicTransaction::new(message, witness); + + let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) + .expect("lock must validate and execute"); + let public_diff = diff.public_diff(); + + let holder_after = public_diff[&holder_id].balance; + assert_eq!( + holder_after, + INITIAL_BALANCE - LOCK_AMOUNT, + "holder debited" + ); + + let escrow_after = public_diff[&escrow_id].balance; + assert_eq!(escrow_after, LOCK_AMOUNT, "escrow credited"); + + let record = + OutboxRecord::from_bytes(&public_diff[&outbox_record_id].data.clone().into_inner()) + .expect("outbox PDA holds an OutboxRecord"); + assert_eq!(record.target_zone, zone_b); + assert_eq!(record.target_program_id, wrapped_token_id); + assert_eq!( + record.payload, payload, + "emitted payload is the wrapped mint" + ); +} + +/// Drives a hand-built `cross_zone_inbox::Dispatch` (as the watcher would inject) +/// and asserts it chains into `wrapped_token::Mint`, crediting the recipient. +#[test] +fn inbox_dispatch_mints_wrapped_token() { + let inbox_id = programs::cross_zone_inbox().id(); + let wrapped_token_id = programs::wrapped_token().id(); + + let self_zone = [1_u8; 32]; + let src_zone = [2_u8; 32]; + let src_block_id = 5; + + let mut state = base_state(); + seed_inbox_config(&mut state, self_zone, src_zone, wrapped_token_id); + seed_wrapped_config(&mut state); + + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_tx_index: 0, + src_program_id: [9_u32; 8], + target_program_id: wrapped_token_id, + payload: mint_payload(), + l1_inclusion_witness: None, + }; + + let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); + let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id); + let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); + + let message = Message::try_new( + inbox_id, + vec![ + inbox_config_account_id(inbox_id), + seen_id, + wrapped_config_id, + holding_id, + ], + vec![], + InboxInstruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) + .expect("dispatch must validate and execute"); + let minted = wrapped_token_core::read_balance( + &diff.public_diff()[&holding_id].data.clone().into_inner(), + ); + assert_eq!( + minted, LOCK_AMOUNT, + "recipient holding minted the locked amount" + ); +} + +/// A dispatch whose message key is already in the seen-shard is an idempotent +/// no-op: the inbox makes no chained call, so the wrapped token is not minted a +/// second time. This is the bridge's replay defense. +#[test] +fn mint_replay_rejected() { + let inbox_id = programs::cross_zone_inbox().id(); + let wrapped_token_id = programs::wrapped_token().id(); + + let self_zone = [1_u8; 32]; + let src_zone = [2_u8; 32]; + let src_block_id = 5; + let src_tx_index = 0; + + let mut state = base_state(); + seed_inbox_config(&mut state, self_zone, src_zone, wrapped_token_id); + seed_wrapped_config(&mut state); + + // Seed the seen-shard as already containing this message's key, so the inbox + // takes the replay no-op branch. The shard is inbox-owned (claimed on a prior + // delivery), so the guest leaves it untouched. + let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); + let mut shard = SeenShard::default(); + shard.insert(message_key(&src_zone, src_block_id, src_tx_index)); + state = state.with_public_accounts([( + seen_id, + Account { + program_owner: inbox_id, + balance: 0, + data: shard + .to_bytes() + .try_into() + .expect("shard fits in account data"), + nonce: 0_u128.into(), + }, + )]); + + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_tx_index, + src_program_id: [9_u32; 8], + target_program_id: wrapped_token_id, + payload: mint_payload(), + l1_inclusion_witness: None, + }; + + let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id); + let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); + + let message = Message::try_new( + inbox_id, + vec![ + inbox_config_account_id(inbox_id), + seen_id, + wrapped_config_id, + holding_id, + ], + vec![], + InboxInstruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) + .expect("a replayed dispatch is a valid no-op, not an error"); + let public_diff = diff.public_diff(); + + // No mint: the holding is never credited on replay. + let minted = public_diff.get(&holding_id).map_or(0, |account| { + wrapped_token_core::read_balance(&account.data.clone().into_inner()) + }); + assert_eq!(minted, 0, "a replayed message must not mint again"); + + // The seen-shard is untouched by the no-op. + if let Some(seen) = public_diff.get(&seen_id) { + let shard_after = + SeenShard::from_bytes(&seen.data.clone().into_inner()).expect("seen shard decodes"); + assert_eq!(shard_after, shard, "replay must not modify the seen-shard"); + } +} diff --git a/integration_tests/tests/cross_zone_verified.rs b/integration_tests/tests/cross_zone_verified.rs new file mode 100644 index 00000000..cc21c42a --- /dev/null +++ b/integration_tests/tests/cross_zone_verified.rs @@ -0,0 +1,153 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "top-level test functions are conventional for integration tests" +)] + +//! Cross-zone round trip with the indexer in the loop (Option B). A ping on zone +//! A is delivered to zone B, and zone B's indexer independently re-derives the +//! injected dispatch from zone A's finalized blocks before applying it. The +//! payload landing in the indexer's state proves verification passed; a forgery +//! would have halted the indexer instead. + +use std::time::Duration; + +use anyhow::{Context as _, Result}; +use common::transaction::LeeTransaction; +use cross_zone_outbox_core::outbox_pda; +use integration_tests::{ + config::{self, SequencerPartialConfig}, + indexer_client::IndexerClient, + setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer}, +}; +use lee::{AccountId, PublicTransaction, public_transaction::Message}; +use lee_core::program::ProgramId; +use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use sequencer_core::config::{CrossZoneConfig, CrossZonePeer}; +use sequencer_service_rpc::RpcClient as _; +use tokio::test; + +const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600); +const PING_PAYLOAD: &[u8] = b"hello-verified-zone"; + +#[test] +async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> { + // Declared first so it outlives both zones (drops run in reverse order). + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to set up shared Bedrock node")?; + + let partial = SequencerPartialConfig::default(); + let channel_a = config::bedrock_channel_id(); + let channel_b = config::bedrock_channel_id_b(); + let zone_a: [u8; 32] = *channel_a.as_ref(); + let zone_b: [u8; 32] = *channel_b.as_ref(); + + let receiver_id = programs::ping_receiver().id(); + let cross_zone = CrossZoneConfig { + peers: vec![CrossZonePeer { + channel_id: zone_a, + allowed_targets: vec![receiver_id], + expected_block_signing_pubkey: None, + }], + }; + + // Zone A: source. Zone B: destination, with the watcher on its sequencer and + // the verifier on its indexer. + let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_a) + .with_genesis(vec![]) + .setup() + .await + .context("Failed to set up zone A sequencer")?; + let (_idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None) + .await + .context("Failed to set up zone A indexer")?; + let (_seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_b) + .with_genesis(vec![]) + .with_cross_zone(cross_zone.clone()) + .setup() + .await + .context("Failed to set up zone B sequencer")?; + let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone)) + .await + .context("Failed to set up zone B indexer")?; + + // Submit the ping on zone A, addressed to ping_receiver on zone B. + let ping = build_ping_tx(zone_b, receiver_id); + sequencer_client(seq_a.addr())? + .send_transaction(ping) + .await + .context("Failed to submit ping on zone A")?; + + // Wait until zone B's indexer records the delivered payload. The indexer only + // applies the dispatch after re-deriving and verifying it. + let record_id = ping_record_pda(receiver_id); + let indexer = indexer_client(idx_b.addr()) + .await + .context("Failed to build indexer client")?; + + let delivered = wait_for_indexer_delivery(&indexer, record_id).await?; + assert_eq!( + delivered, PING_PAYLOAD, + "Zone B's indexer must record the verified cross-zone payload" + ); + Ok(()) +} + +fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransaction { + let outbox_id = programs::cross_zone_outbox().id(); + let ordinal = 0; + + let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { + payload: PING_PAYLOAD.to_vec(), + }) + .expect("serialize ping instruction"); + let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); + + let send = SenderInstruction::Send { + outbox_program_id: outbox_id, + target_zone, + target_program_id: receiver_id, + target_accounts: vec![ping_record_pda(receiver_id).into_value()], + payload, + ordinal, + }; + + let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal); + let message = Message::try_new( + programs::ping_sender().id(), + vec![outbox_account], + vec![], + send, + ) + .expect("build ping message"); + LeeTransaction::Public(PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + )) +} + +/// Polls zone B's indexer until the ping record PDA holds a payload. +async fn wait_for_indexer_delivery( + indexer: &IndexerClient, + record_id: AccountId, +) -> Result> { + let account_id = indexer_service_protocol::AccountId { + value: record_id.into_value(), + }; + let wait = async { + loop { + let account = + indexer_service_rpc::RpcClient::get_account(&**indexer, account_id).await?; + let data = account.data.0; + if !data.is_empty() { + return Ok::, anyhow::Error>(data); + } + tokio::time::sleep(Duration::from_secs(3)).await; + } + }; + tokio::time::timeout(DELIVERY_TIMEOUT, wait) + .await + .context("Zone B's indexer did not record the payload in time")? +} diff --git a/integration_tests/tests/cross_zone_watcher_restart.rs b/integration_tests/tests/cross_zone_watcher_restart.rs new file mode 100644 index 00000000..155a29fc --- /dev/null +++ b/integration_tests/tests/cross_zone_watcher_restart.rs @@ -0,0 +1,227 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "top-level test functions are conventional for integration tests" +)] + +//! A sequencer restart must resume its cross-zone watcher from the persisted +//! per-peer delivery floor instead of re-reading the peer channel from genesis. +//! +//! Re-reading is safe (the dispatch key is content-addressed and the inbox +//! no-ops a replay) so on-chain state cannot tell the two apart. What does tell +//! them apart is the transactions: a watcher that lost its cursor re-injects +//! every already-delivered dispatch, which shows up as inbox transactions in +//! blocks produced after the restart. This is the only test that covers the +//! wiring from `spawn_watchers` through the store, so a silent regression here +//! would leave every other test green while the feature does nothing. + +use std::time::Duration; + +use anyhow::{Context as _, Result}; +use common::transaction::LeeTransaction; +use cross_zone_outbox_core::outbox_pda; +use integration_tests::{ + config::{self, SequencerPartialConfig}, + setup::{SequencerSetup, sequencer_client, setup_bedrock_node}, +}; +use lee::{AccountId, PublicTransaction, public_transaction::Message}; +use lee_core::program::ProgramId; +use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use sequencer_core::config::{CrossZoneConfig, CrossZonePeer}; +use sequencer_service_rpc::{RpcClient as _, SequencerClient}; +use tokio::test; + +const DELIVERY_TIMEOUT: Duration = Duration::from_secs(480); +/// Blocks zone B must produce after the restart before we judge the watcher. +/// A watcher that lost its cursor re-reads the peer channel on its first pass, +/// so a handful of blocks is ample room for the replay to appear. +const BLOCKS_AFTER_RESTART: u64 = 5; +const RESTART_TIMEOUT: Duration = Duration::from_secs(240); +const PING_PAYLOAD: &[u8] = b"hello-cross-zone"; + +#[test] +async fn restarted_watcher_resumes_instead_of_replaying_the_peer_channel() -> Result<()> { + // Declared first so it outlives both zones (drops run in reverse order). + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to set up shared Bedrock node")?; + + let partial = SequencerPartialConfig::default(); + let channel_a = config::bedrock_channel_id(); + let channel_b = config::bedrock_channel_id_b(); + let zone_a: [u8; 32] = *channel_a.as_ref(); + let zone_b: [u8; 32] = *channel_b.as_ref(); + let receiver_id = programs::ping_receiver().id(); + + let cross_zone = CrossZoneConfig { + peers: vec![CrossZonePeer { + channel_id: zone_a, + allowed_targets: vec![receiver_id], + expected_block_signing_pubkey: None, + }], + }; + + let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_a) + .with_genesis(vec![]) + .setup() + .await + .context("Failed to set up zone A sequencer")?; + + // Zone B keeps an explicit home so it can be restarted on the same store. + let home_b = tempfile::tempdir().context("Failed to create zone B home")?; + let mut seq_b = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_b) + .with_genesis(vec![]) + .with_cross_zone(cross_zone.clone()) + .setup_at(home_b.path()) + .await + .context("Failed to set up zone B sequencer")?; + + // Deliver one ping, so the peer channel holds a dispatch worth replaying. + sequencer_client(seq_a.addr())? + .send_transaction(build_ping_tx(zone_b, receiver_id)) + .await + .context("Failed to submit ping on zone A")?; + let record_id = ping_record_pda(receiver_id); + let delivered = wait_for_delivery(sequencer_client(seq_b.addr())?, record_id).await?; + assert_eq!( + delivered, PING_PAYLOAD, + "Zone B must record the payload before the restart" + ); + + let tip_before = sequencer_client(seq_b.addr())?.get_last_block_id().await?; + + // Restart zone B on the same home. Zone A stays quiet from here, so any + // inbox transaction after the restart is a replay, not a new delivery. + // + // `shutdown` rather than `drop`: dropping aborts the main loop without + // awaiting it and leaves the watchers and the publisher's drive task holding + // the store, so the reopen below would race the `RocksDB` lock. + seq_b.shutdown().await; + seq_b = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_b) + .with_genesis(vec![]) + .with_cross_zone(cross_zone) + .setup_at(home_b.path()) + .await + .context("Failed to restart zone B sequencer")?; + let client_b = sequencer_client(seq_b.addr())?; + + let tip_after = wait_for_block_id( + &client_b, + tip_before.saturating_add(BLOCKS_AFTER_RESTART), + RESTART_TIMEOUT, + ) + .await?; + + let replayed = count_inbox_transactions(&client_b, tip_before.saturating_add(1), tip_after) + .await + .context("Failed to scan zone B blocks after the restart")?; + assert_eq!( + replayed, + 0, + "a restarted watcher must resume from its persisted delivery floor; found {replayed} inbox transaction(s) in blocks {}..={tip_after}, which means it re-read the peer channel from genesis", + tip_before.saturating_add(1) + ); + + // The delivery itself must survive the restart untouched. + let account = client_b.get_account(record_id).await?; + assert_eq!( + account.data.into_inner(), + PING_PAYLOAD, + "the delivered payload must survive the restart" + ); + Ok(()) +} + +/// Counts inbox transactions across `from..=to`, the signature of a re-injected +/// dispatch. +async fn count_inbox_transactions(client: &SequencerClient, from: u64, to: u64) -> Result { + let inbox_id = programs::cross_zone_inbox().id(); + let mut count = 0_usize; + for block_id in from..=to { + let Some(block) = client.get_block(block_id).await? else { + continue; + }; + for tx in &block.body.transactions { + if let LeeTransaction::Public(public_tx) = tx + && public_tx.message().program_id == inbox_id + { + count = count.saturating_add(1); + } + } + } + Ok(count) +} + +/// Waits until the sequencer's tip reaches `target`, returning the tip. +async fn wait_for_block_id( + client: &SequencerClient, + target: u64, + timeout: Duration, +) -> Result { + let wait = async { + loop { + let tip = client.get_last_block_id().await?; + if tip >= target { + return Ok::(tip); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + }; + tokio::time::timeout(timeout, wait) + .await + .context("Zone B did not produce enough blocks after the restart")? +} + +/// Builds a top-level `ping_sender` transaction that chains into the outbox to emit +/// a message carrying a `ping_receiver::Record` instruction for the target zone. +fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransaction { + let outbox_id = programs::cross_zone_outbox().id(); + let ordinal = 0; + + let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { + payload: PING_PAYLOAD.to_vec(), + }) + .expect("serialize ping instruction"); + let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); + + let send = SenderInstruction::Send { + outbox_program_id: outbox_id, + target_zone, + target_program_id: receiver_id, + target_accounts: vec![ping_record_pda(receiver_id).into_value()], + payload, + ordinal, + }; + + let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal); + let message = Message::try_new( + programs::ping_sender().id(), + vec![outbox_account], + vec![], + send, + ) + .expect("build ping message"); + LeeTransaction::Public(PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + )) +} + +/// Polls zone B's sequencer until the ping record PDA holds a payload. +async fn wait_for_delivery(client: SequencerClient, record_id: AccountId) -> Result> { + let wait = async { + loop { + let account = client.get_account(record_id).await?; + let data = account.data.into_inner(); + if !data.is_empty() { + return Ok::, anyhow::Error>(data); + } + tokio::time::sleep(Duration::from_secs(3)).await; + } + }; + tokio::time::timeout(DELIVERY_TIMEOUT, wait) + .await + .context("Zone B did not record the cross-zone payload in time")? +} diff --git a/integration_tests/tests/indexer_ffi_helpers/mod.rs b/integration_tests/tests/indexer_ffi_helpers/mod.rs index 170102fd..09e0a927 100644 --- a/integration_tests/tests/indexer_ffi_helpers/mod.rs +++ b/integration_tests/tests/indexer_ffi_helpers/mod.rs @@ -50,8 +50,12 @@ pub fn setup_indexer_ffi(bedrock_addr: SocketAddr) -> Result<(IndexerServiceFFI, temp_indexer_dir.path().display() ); - let indexer_config = integration_tests::config::indexer_config(bedrock_addr) - .context("Failed to create Indexer config")?; + let indexer_config = integration_tests::config::indexer_config( + bedrock_addr, + integration_tests::config::bedrock_channel_id(), + None, + ) + .context("Failed to create Indexer config")?; let config_json = serde_json::to_vec(&indexer_config)?; let config_path = temp_indexer_dir.path().join("indexer_config.json"); diff --git a/integration_tests/tests/indexer_stall.rs b/integration_tests/tests/indexer_stall.rs new file mode 100644 index 00000000..ae1b8b6a --- /dev/null +++ b/integration_tests/tests/indexer_stall.rs @@ -0,0 +1,54 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "We don't care about these in tests" +)] + +use std::time::Duration; + +use anyhow::{Context as _, Result}; +use indexer_service_protocol::IndexerSyncState; +use indexer_service_rpc::RpcClient as _; +use integration_tests::{TestContext, wait_for_indexer_to_catch_up}; +use log::info; + +const CAUGHT_UP_STATUS_TIMEOUT: Duration = Duration::from_secs(60); + +/// Test that the indexer status RPC reports caught-up with no stall after a clean run. +/// +/// The sequencer keeps producing blocks while we assert, so the status is polled until a +/// `CaughtUp` snapshot is observed and the indexed tip is checked as a lower bound. +/// +/// TODO: Integration-level park testing (publishing a bad block to force a stall) is a follow-up +/// needing fault injection support in the test harness. +#[tokio::test] +async fn indexer_status_rpc_reports_caught_up_with_no_stall() -> Result<()> { + let ctx = TestContext::new().await?; + + let indexer_tip = wait_for_indexer_to_catch_up(&ctx).await?; + + let status = tokio::time::timeout(CAUGHT_UP_STATUS_TIMEOUT, async { + loop { + let status = ctx.indexer_client().get_status().await?; + if status.state == IndexerSyncState::CaughtUp { + return anyhow::Ok(status); + } + info!("Waiting for caught-up indexer status, got {status:?}"); + tokio::time::sleep(Duration::from_millis(500)).await; + } + }) + .await + .context("Timed out waiting for indexer status to report caught-up")??; + + assert!( + status.stall_reason.is_none(), + "indexer should have no stall reason after a clean run, got {status:?}" + ); + // test for >= here because the sequencer keeps producing blocks while we assert, + // so the indexed tip may be ahead of the tip we observed when we waited for caught-up. + assert!( + status.indexed_block_id >= Some(indexer_tip), + "status indexed_block_id should be at least the caught-up tip {indexer_tip}, got {status:?}" + ); + + Ok(()) +} diff --git a/integration_tests/tests/indexer_state_consistency.rs b/integration_tests/tests/indexer_state_consistency.rs index e87927bc..4ed2fd26 100644 --- a/integration_tests/tests/indexer_state_consistency.rs +++ b/integration_tests/tests/indexer_state_consistency.rs @@ -1,51 +1,36 @@ #![expect( - clippy::shadow_unrelated, clippy::tests_outside_test_module, reason = "We don't care about these in tests" )] use std::time::Duration; -use anyhow::{Context as _, Result}; +use anyhow::Result; use indexer_service_rpc::RpcClient as _; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention, - verify_commitment_is_in_state, wait_for_indexer_to_catch_up, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, + assert_private_commitment_in_state, get_account, private_mention, public_mention, send, + wait_for_indexer_to_catch_up, }; use lee::AccountId; use log::info; -use wallet::cli::{Command, programs::native_token_transfer::AuthTransferSubcommand}; #[tokio::test] async fn indexer_state_consistency() -> Result<()> { let mut ctx = TestContext::new().await?; - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: public_mention(ctx.existing_public_accounts()[0]), - to: Some(public_mention(ctx.existing_public_accounts()[1])), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 100, - }); - - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + let (acc0, acc1) = ( + ctx.existing_public_accounts()[0], + ctx.existing_public_accounts()[1], + ); + send(&mut ctx, public_mention(acc0), public_mention(acc1), 100).await?; info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; info!("Checking correct balance move"); - let acc_1_balance = sequencer_service_rpc::RpcClient::get_account_balance( - ctx.sequencer_client(), - ctx.existing_public_accounts()[0], - ) - .await?; - let acc_2_balance = sequencer_service_rpc::RpcClient::get_account_balance( - ctx.sequencer_client(), - ctx.existing_public_accounts()[1], - ) - .await?; + let acc_1_balance = account_balance(&ctx, ctx.existing_public_accounts()[0]).await?; + let acc_2_balance = account_balance(&ctx, ctx.existing_public_accounts()[1]).await?; info!("Balance of sender: {acc_1_balance:#?}"); info!("Balance of receiver: {acc_2_balance:#?}"); @@ -56,32 +41,13 @@ async fn indexer_state_consistency() -> Result<()> { let from: AccountId = ctx.existing_private_accounts()[0]; let to: AccountId = ctx.existing_private_accounts()[1]; - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: private_mention(from), - to: Some(private_mention(to)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 100, - }); - - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + send(&mut ctx, private_mention(from), private_mention(to), 100).await?; info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let new_commitment1 = ctx - .wallet() - .get_private_account_commitment(from) - .context("Failed to get private account commitment for sender")?; - assert!(verify_commitment_is_in_state(new_commitment1, ctx.sequencer_client()).await); - - let new_commitment2 = ctx - .wallet() - .get_private_account_commitment(to) - .context("Failed to get private account commitment for receiver")?; - assert!(verify_commitment_is_in_state(new_commitment2, ctx.sequencer_client()).await); + assert_private_commitment_in_state(&ctx, from, "sender").await?; + assert_private_commitment_in_state(&ctx, to, "receiver").await?; info!("Successfully transferred privately to owned account"); @@ -100,16 +66,8 @@ async fn indexer_state_consistency() -> Result<()> { .unwrap(); info!("Checking correct state transition"); - let acc1_seq_state = sequencer_service_rpc::RpcClient::get_account( - ctx.sequencer_client(), - ctx.existing_public_accounts()[0], - ) - .await?; - let acc2_seq_state = sequencer_service_rpc::RpcClient::get_account( - ctx.sequencer_client(), - ctx.existing_public_accounts()[1], - ) - .await?; + let acc1_seq_state = get_account(&ctx, ctx.existing_public_accounts()[0]).await?; + let acc2_seq_state = get_account(&ctx, ctx.existing_public_accounts()[1]).await?; assert_eq!(acc1_ind_state, acc1_seq_state.into()); assert_eq!(acc2_ind_state, acc2_seq_state.into()); diff --git a/integration_tests/tests/indexer_state_consistency_with_labels.rs b/integration_tests/tests/indexer_state_consistency_with_labels.rs index 5f561d6f..219c3ebf 100644 --- a/integration_tests/tests/indexer_state_consistency_with_labels.rs +++ b/integration_tests/tests/indexer_state_consistency_with_labels.rs @@ -9,12 +9,13 @@ use std::time::Duration; use anyhow::Result; use indexer_service_rpc::RpcClient as _; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, public_mention, wait_for_indexer_to_catch_up, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, get_account, public_mention, + send, wait_for_indexer_to_catch_up, }; use log::info; use wallet::{ account::Label, - cli::{CliAccountMention, Command, programs::native_token_transfer::AuthTransferSubcommand}, + cli::{CliAccountMention, Command}, }; #[tokio::test] @@ -38,31 +39,19 @@ async fn indexer_state_consistency_with_labels() -> Result<()> { wallet::cli::execute_subcommand(ctx.wallet_mut(), label_cmd).await?; // Send using labels instead of account IDs - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: CliAccountMention::Label(from_label), - to: Some(CliAccountMention::Label(to_label)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 100, - }); - - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + send( + &mut ctx, + CliAccountMention::Label(from_label), + CliAccountMention::Label(to_label), + 100, + ) + .await?; info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let acc_1_balance = sequencer_service_rpc::RpcClient::get_account_balance( - ctx.sequencer_client(), - ctx.existing_public_accounts()[0], - ) - .await?; - let acc_2_balance = sequencer_service_rpc::RpcClient::get_account_balance( - ctx.sequencer_client(), - ctx.existing_public_accounts()[1], - ) - .await?; + let acc_1_balance = account_balance(&ctx, ctx.existing_public_accounts()[0]).await?; + let acc_2_balance = account_balance(&ctx, ctx.existing_public_accounts()[1]).await?; assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); @@ -75,11 +64,7 @@ async fn indexer_state_consistency_with_labels() -> Result<()> { .get_account(ctx.existing_public_accounts()[0].into()) .await .unwrap(); - let acc1_seq_state = sequencer_service_rpc::RpcClient::get_account( - ctx.sequencer_client(), - ctx.existing_public_accounts()[0], - ) - .await?; + let acc1_seq_state = get_account(&ctx, ctx.existing_public_accounts()[0]).await?; assert_eq!(acc1_ind_state, acc1_seq_state.into()); diff --git a/integration_tests/tests/keys.rs b/integration_tests/tests/keys.rs index 9fd3b3f1..1631dc07 100644 --- a/integration_tests/tests/keys.rs +++ b/integration_tests/tests/keys.rs @@ -8,8 +8,9 @@ use std::{str::FromStr as _, time::Duration}; use anyhow::{Context as _, Result}; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, fetch_privacy_preserving_tx, private_mention, - public_mention, verify_commitment_is_in_state, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, assert_public_account_restored, + fetch_privacy_preserving_tx, new_account, private_mention, public_mention, + restored_private_account, send, send_claiming_new_account, verify_commitment_is_in_state, }; use key_protocol::key_management::key_tree::chain_index::ChainIndex; use lee::AccountId; @@ -17,8 +18,7 @@ use log::info; use sequencer_service_rpc::RpcClient as _; use tokio::test; use wallet::cli::{ - Command, SubcommandReturnValue, - account::{AccountSubcommand, NewSubcommand}, + Command, SubcommandReturnValue, account::AccountSubcommand, programs::native_token_transfer::AuthTransferSubcommand, }; @@ -28,35 +28,12 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> { let from: AccountId = ctx.existing_private_accounts()[0]; - // Create a new private account - let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })); - + // Key Tree shift — create 3 accounts to advance the key index for _ in 0..3 { - // Key Tree shift - // This way we have account with child index > 0. - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { account_id: _ } = result else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + new_account(&mut ctx, true, None).await?; } - let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - let SubcommandReturnValue::RegisterAccount { - account_id: to_account_id, - } = sub_ret - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let to_account_id = new_account(&mut ctx, true, None).await?; // Get the keys for the newly created account let to_account = ctx @@ -80,8 +57,8 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> { }); let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = sub_ret else { - anyhow::bail!("Expected PrivacyPreservingTransfer return value"); + let SubcommandReturnValue::TransactionExecuted { tx_hash } = sub_ret else { + anyhow::bail!("Expected TransactionExecuted return value"); }; let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await; @@ -94,9 +71,8 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> { .wallet() .get_private_account_commitment(from) .context("Failed to get private account commitment for sender")?; - assert_eq!(tx.message.new_commitments[0], new_commitment1); + assert!(tx.message.new_commitments.contains(&new_commitment1)); - assert_eq!(tx.message.new_commitments.len(), 2); for commitment in tx.message.new_commitments { assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); } @@ -118,107 +94,36 @@ async fn restore_keys_from_seed() -> Result<()> { let from: AccountId = ctx.existing_private_accounts()[0]; - // Create first private account at root - let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: Some(ChainIndex::root()), - label: None, - })); - let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - let SubcommandReturnValue::RegisterAccount { - account_id: to_account_id1, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + // Create private accounts at root and /0 + let to_account_id1 = new_account(&mut ctx, true, Some(ChainIndex::root())).await?; + let to_account_id2 = new_account(&mut ctx, true, Some(ChainIndex::from_str("/0")?)).await?; - // Create second private account at /0 - let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: Some(ChainIndex::from_str("/0")?), - label: None, - })); - let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - let SubcommandReturnValue::RegisterAccount { - account_id: to_account_id2, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; - - // Send to first private account - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: private_mention(from), - to: Some(private_mention(to_account_id1)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 100, - }); - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - - // Send to second private account - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: private_mention(from), - to: Some(private_mention(to_account_id2)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 101, - }); - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + // Send to both private accounts + send( + &mut ctx, + private_mention(from), + private_mention(to_account_id1), + 100, + ) + .await?; + send( + &mut ctx, + private_mention(from), + private_mention(to_account_id2), + 101, + ) + .await?; let from: AccountId = ctx.existing_public_accounts()[0]; - // Create first public account at root - let command = Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: Some(ChainIndex::root()), - label: None, - })); - let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - let SubcommandReturnValue::RegisterAccount { - account_id: to_account_id3, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + // Create public accounts at root and /0 + let to_account_id3 = new_account(&mut ctx, false, Some(ChainIndex::root())).await?; + let to_account_id4 = new_account(&mut ctx, false, Some(ChainIndex::from_str("/0")?)).await?; - // Create second public account at /0 - let command = Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: Some(ChainIndex::from_str("/0")?), - label: None, - })); - let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - let SubcommandReturnValue::RegisterAccount { - account_id: to_account_id4, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; - - // Send to first public account - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: public_mention(from), - to: Some(public_mention(to_account_id3)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 102, - }); - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - - // Send to second public account - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: public_mention(from), - to: Some(public_mention(to_account_id4)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 103, - }); - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + // Send to both public accounts. Both are still unclaimed, so bypass the wallet CLI (which + // never signs with the recipient's key) and sign with the recipient's own key directly. + send_claiming_new_account(&mut ctx, from, to_account_id3, 102).await?; + send_claiming_new_account(&mut ctx, from, to_account_id4, 103).await?; info!("Preparation complete, performing keys restoration"); @@ -226,34 +131,12 @@ async fn restore_keys_from_seed() -> Result<()> { wallet::cli::execute_keys_restoration(ctx.wallet_mut(), 10).await?; // Verify restored private accounts - let acc1 = ctx - .wallet() - .storage() - .key_chain() - .private_account(to_account_id1) - .expect("Acc 1 should be restored"); - - let acc2 = ctx - .wallet() - .storage() - .key_chain() - .private_account(to_account_id2) - .expect("Acc 2 should be restored"); + let acc1 = restored_private_account(&ctx, to_account_id1, "Acc 1"); + let acc2 = restored_private_account(&ctx, to_account_id2, "Acc 2"); // Verify restored public accounts - let _acc3 = ctx - .wallet() - .storage() - .key_chain() - .pub_account_signing_key(to_account_id3) - .expect("Acc 3 should be restored"); - - let _acc4 = ctx - .wallet() - .storage() - .key_chain() - .pub_account_signing_key(to_account_id4) - .expect("Acc 4 should be restored"); + assert_public_account_restored(&ctx, to_account_id3, "Acc 3"); + assert_public_account_restored(&ctx, to_account_id4, "Acc 4"); assert_eq!( acc1.account.program_owner, @@ -270,27 +153,20 @@ async fn restore_keys_from_seed() -> Result<()> { info!("Tree checks passed, testing restored accounts can transact"); // Test that restored accounts can send transactions - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: private_mention(to_account_id1), - to: Some(private_mention(to_account_id2)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 10, - }); - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - - let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: public_mention(to_account_id3), - to: Some(public_mention(to_account_id4)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: 11, - }); - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + send( + &mut ctx, + private_mention(to_account_id1), + private_mention(to_account_id2), + 10, + ) + .await?; + send( + &mut ctx, + public_mention(to_account_id3), + public_mention(to_account_id4), + 11, + ) + .await?; tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; diff --git a/integration_tests/tests/multi_sequencer.rs b/integration_tests/tests/multi_sequencer.rs new file mode 100644 index 00000000..1b01b62d --- /dev/null +++ b/integration_tests/tests/multi_sequencer.rs @@ -0,0 +1,237 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "top-level test functions are conventional for integration tests" +)] + +//! Two sequencers share one channel: A starts solo as channel admin, live- +//! accredits `[A, B]` with round-robin rotation, B joins and syncs, both +//! produce on their turns, and A, B and an indexer converge on the same chain. + +use std::time::Duration; + +use anyhow::{Context as _, Result, ensure}; +use indexer_service_rpc::RpcClient as _; +use integration_tests::{ + config::{self, SequencerPartialConfig}, + indexer_client::IndexerClient, + setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer}, +}; +use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key}; +use sequencer_core::{block_publisher::post_channel_config, config::BedrockConfig}; +use sequencer_service_rpc::{RpcClient as _, SequencerClient}; +use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts}; +use tokio::test; + +/// 1 s bedrock slots: rotate the turn every ~20 s of tenure; steal a stalled +/// turn after ~30 s (bounds the stall while B is accredited but not started). +const POSTING_TIMEFRAME_SLOTS: u32 = 20; +const POSTING_TIMEOUT_SLOTS: u32 = 30; +const PHASE_TIMEOUT: Duration = Duration::from_secs(360); +const POLL_INTERVAL: Duration = Duration::from_secs(2); +const TRANSFER_AMOUNT: u128 = 10; +/// ≈4 turn windows past B's join (5 s blocks, ~20 s turns → ~4 blocks/window). +const ROTATION_BLOCKS: u64 = 8; + +#[test] +async fn multi_sequencer_committee_converges() -> Result<()> { + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to set up Bedrock node")?; + + // Fixed seeds so A can accredit B's public key before B exists. + let key_a = [0xA1_u8; ED25519_SECRET_KEY_SIZE]; + let key_b = [0xB2_u8; ED25519_SECRET_KEY_SIZE]; + let pub_a = Ed25519Key::from_bytes(&key_a).public_key(); + let pub_b = Ed25519Key::from_bytes(&key_b).public_key(); + + let partial = SequencerPartialConfig { + block_create_timeout: Duration::from_secs(5), + ..SequencerPartialConfig::default() + }; + + // Phase 1: A solo (its first inscription creates the channel), plus an indexer. + let (seq_a, _a_home) = SequencerSetup::new(partial, bedrock_addr) + .with_genesis(vec![]) + .with_bedrock_signing_key(key_a) + .setup() + .await + .context("Failed to set up sequencer A")?; + let a = sequencer_client(seq_a.addr())?; + let (idx, _idx_home) = setup_indexer(bedrock_addr, config::bedrock_channel_id(), None) + .await + .context("Failed to set up indexer")?; + let indexer = indexer_client(idx.addr()).await?; + + wait_for_height(&a, 2, "sequencer A to produce past genesis").await?; + + // Phase 2: live roster change to [A, B] with rotation enabled, posted + // straight to bedrock with A's admin key (the operator one-shot path). + post_channel_config( + &BedrockConfig { + channel_id: config::bedrock_channel_id(), + node_url: config::addr_to_url(config::UrlProtocol::Http, bedrock_addr)?, + funding_key: config::bedrock_funding_key(), + auth: None, + }, + &Ed25519Key::from_bytes(&key_a), + vec![pub_a, pub_b], + POSTING_TIMEFRAME_SLOTS, + POSTING_TIMEOUT_SLOTS, + 1, + 1, + ) + .await + .context("Failed to configure the channel committee")?; + + let height_at_config = a.get_last_block_id().await?; + wait_for_height( + &a, + height_at_config + 1, + "A to produce after the roster change", + ) + .await?; + + // Phase 3: B joins live and syncs the existing chain. + let (seq_b, _b_home) = SequencerSetup::new(partial, bedrock_addr) + .with_genesis(vec![]) + .with_bedrock_signing_key(key_b) + .setup() + .await + .context("Failed to set up sequencer B")?; + let b = sequencer_client(seq_b.addr())?; + + let join_height = a.get_last_block_id().await?; + wait_for_height(&b, join_height, "B to sync to A's height at join").await?; + + // Phase 4: rotation + convergence over ≈4 turn windows. + let rotation_target = join_height + ROTATION_BLOCKS; + wait_for_height( + &a, + rotation_target, + "the chain to advance across turn windows", + ) + .await?; + wait_for_height(&b, rotation_target, "B to follow across turn windows").await?; + assert_same_chain(&a, &b).await?; + + // Phase 5: a tx submitted only to B is included by B and visible on A. + let accounts = initial_public_user_accounts(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = initial_pub_accounts_private_keys()[0].pub_sign_key.clone(); + + let to_balance_before = a.get_account_balance(to).await?; + let nonce = b.get_accounts_nonces(vec![from]).await?[0]; + let tx = common::test_utils::create_transaction_native_token_transfer( + from, + nonce.0, + to, + TRANSFER_AMOUNT, + &sign_key, + ); + b.send_transaction(tx) + .await + .context("Failed to submit the transfer to B")?; + + wait_for_balance(&a, to, to_balance_before + TRANSFER_AMOUNT).await?; + + // Phase 6: the indexer finalizes the same chain, with no stall. + wait_for_finalized(&indexer, join_height).await?; + let finalized = indexer.get_last_finalized_block_id().await?.unwrap_or(0); + for id in 1..=finalized { + let block_i = indexer + .get_block_by_id(id) + .await? + .with_context(|| format!("Indexer is missing finalized block {id}"))?; + let block_a = a + .get_block(id) + .await? + .with_context(|| format!("A is missing block {id}"))?; + ensure!( + block_i.header.hash == indexer_service_protocol::HashType::from(block_a.header.hash), + "Indexer diverges from A at block {id}" + ); + } + let status = indexer.get_status().await?; + ensure!( + status.stall_reason.is_none(), + "Indexer is stalled: {:?}", + status.stall_reason + ); + + Ok(()) +} + +/// Polls the sequencer until its chain height reaches `target`. +async fn wait_for_height(client: &SequencerClient, target: u64, what: &str) -> Result<()> { + let wait = async { + loop { + if client.get_last_block_id().await? >= target { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }; + tokio::time::timeout(PHASE_TIMEOUT, wait) + .await + .with_context(|| format!("Timed out waiting for {what} (target height {target})"))? +} + +/// Polls the sequencer until `account`'s balance reaches `expected`. +async fn wait_for_balance( + client: &SequencerClient, + account: lee::AccountId, + expected: u128, +) -> Result<()> { + let wait = async { + loop { + if client.get_account_balance(account).await? == expected { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }; + tokio::time::timeout(PHASE_TIMEOUT, wait) + .await + .context("Timed out waiting for the cross-sequencer transfer to reach A")? +} + +/// Polls the indexer until its finalized height reaches `target`. +async fn wait_for_finalized(indexer: &IndexerClient, target: u64) -> Result<()> { + let wait = async { + loop { + if indexer.get_last_finalized_block_id().await?.unwrap_or(0) >= target { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(POLL_INTERVAL).await; + } + }; + tokio::time::timeout(PHASE_TIMEOUT, wait) + .await + .context("Timed out waiting for the indexer to finalize")? +} + +/// Asserts A and B hold byte-identical block hashes over their common prefix. +async fn assert_same_chain(a: &SequencerClient, b: &SequencerClient) -> Result<()> { + let common = a + .get_last_block_id() + .await? + .min(b.get_last_block_id().await?); + for id in 1..=common { + let block_a = a + .get_block(id) + .await? + .with_context(|| format!("A is missing block {id}"))?; + let block_b = b + .get_block(id) + .await? + .with_context(|| format!("B is missing block {id}"))?; + ensure!( + block_a.header.hash == block_b.header.hash, + "Chain divergence at block {id}: A {:?} vs B {:?}", + block_a.header.hash, + block_b.header.hash + ); + } + Ok(()) +} diff --git a/integration_tests/tests/pinata.rs b/integration_tests/tests/pinata.rs index fa4c3d98..f2c634a2 100644 --- a/integration_tests/tests/pinata.rs +++ b/integration_tests/tests/pinata.rs @@ -8,15 +8,13 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention, - verify_commitment_is_in_state, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, new_account, private_mention, + public_mention, sync_private, verify_commitment_is_in_state, wait_for_indexer_to_catch_up, }; use log::info; -use sequencer_service_rpc::RpcClient as _; use tokio::test; use wallet::cli::{ Command, SubcommandReturnValue, - account::{AccountSubcommand, NewSubcommand}, programs::{ native_token_transfer::AuthTransferSubcommand, pinata::PinataProgramAgnosticSubcommand, }, @@ -26,25 +24,9 @@ use wallet::cli::{ async fn claim_pinata_to_uninitialized_public_account_fails_fast() -> Result<()> { let mut ctx = TestContext::new().await?; - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: winner_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let winner_account_id = new_account(&mut ctx, false, None).await?; - let pinata_balance_pre = ctx - .sequencer_client() - .get_account_balance(system_accounts::pinata_account_id()) - .await?; + let pinata_balance_pre = account_balance(&ctx, system_accounts::pinata_account_id()).await?; let claim_result = wallet::cli::execute_subcommand( ctx.wallet_mut(), @@ -64,10 +46,7 @@ async fn claim_pinata_to_uninitialized_public_account_fails_fast() -> Result<()> "Expected init guidance, got: {err}", ); - let pinata_balance_post = ctx - .sequencer_client() - .get_account_balance(system_accounts::pinata_account_id()) - .await?; + let pinata_balance_post = account_balance(&ctx, system_accounts::pinata_account_id()).await?; assert_eq!(pinata_balance_post, pinata_balance_pre); @@ -78,25 +57,9 @@ async fn claim_pinata_to_uninitialized_public_account_fails_fast() -> Result<()> async fn claim_pinata_to_uninitialized_private_account_fails_fast() -> Result<()> { let mut ctx = TestContext::new().await?; - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: winner_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let winner_account_id = new_account(&mut ctx, true, None).await?; - let pinata_balance_pre = ctx - .sequencer_client() - .get_account_balance(system_accounts::pinata_account_id()) - .await?; + let pinata_balance_pre = account_balance(&ctx, system_accounts::pinata_account_id()).await?; let claim_result = wallet::cli::execute_subcommand( ctx.wallet_mut(), @@ -116,10 +79,7 @@ async fn claim_pinata_to_uninitialized_private_account_fails_fast() -> Result<() "Expected init guidance, got: {err}", ); - let pinata_balance_post = ctx - .sequencer_client() - .get_account_balance(system_accounts::pinata_account_id()) - .await?; + let pinata_balance_post = account_balance(&ctx, system_accounts::pinata_account_id()).await?; assert_eq!(pinata_balance_post, pinata_balance_pre); @@ -135,10 +95,7 @@ async fn claim_pinata_to_existing_public_account() -> Result<()> { to: public_mention(ctx.existing_public_accounts()[0]), }); - let pinata_balance_pre = ctx - .sequencer_client() - .get_account_balance(system_accounts::pinata_account_id()) - .await?; + let pinata_balance_pre = account_balance(&ctx, system_accounts::pinata_account_id()).await?; wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; @@ -146,15 +103,9 @@ async fn claim_pinata_to_existing_public_account() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; info!("Checking correct balance move"); - let pinata_balance_post = ctx - .sequencer_client() - .get_account_balance(system_accounts::pinata_account_id()) - .await?; + let pinata_balance_post = account_balance(&ctx, system_accounts::pinata_account_id()).await?; - let winner_balance_post = ctx - .sequencer_client() - .get_account_balance(ctx.existing_public_accounts()[0]) - .await?; + let winner_balance_post = account_balance(&ctx, ctx.existing_public_accounts()[0]).await?; assert_eq!(pinata_balance_post, pinata_balance_pre - pinata_prize); assert_eq!(winner_balance_post, 10000 + pinata_prize); @@ -164,6 +115,41 @@ async fn claim_pinata_to_existing_public_account() -> Result<()> { Ok(()) } +#[test] +async fn claim_pinata_indexer_keeps_up() -> Result<()> { + let mut ctx = TestContext::new().await?; + + let command = Command::Pinata(PinataProgramAgnosticSubcommand::Claim { + to: public_mention(ctx.existing_public_accounts()[0]), + }); + + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + info!("Waiting for next block creation"); + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + + info!("Waiting for indexer to parse blocks"); + wait_for_indexer_to_catch_up(&ctx).await?; + + let winner_ind_state = indexer_service_rpc::RpcClient::get_account( + &**ctx.indexer_client(), + ctx.existing_public_accounts()[0].into(), + ) + .await + .unwrap(); + let winner_seq_state = sequencer_service_rpc::RpcClient::get_account( + ctx.sequencer_client(), + ctx.existing_public_accounts()[0], + ) + .await?; + + assert_eq!(winner_ind_state, winner_seq_state.into()); + + info!("Indexer correctly indexed the pinata claim"); + + Ok(()) +} + #[test] async fn claim_pinata_to_existing_private_account() -> Result<()> { let mut ctx = TestContext::new().await?; @@ -173,22 +159,18 @@ async fn claim_pinata_to_existing_private_account() -> Result<()> { to: private_mention(ctx.existing_private_accounts()[0]), }); - let pinata_balance_pre = ctx - .sequencer_client() - .get_account_balance(system_accounts::pinata_account_id()) - .await?; + let pinata_balance_pre = account_balance(&ctx, system_accounts::pinata_account_id()).await?; let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash: _ } = result else { - anyhow::bail!("Expected PrivacyPreservingTransfer return value"); + let SubcommandReturnValue::TransactionExecuted { tx_hash: _ } = result else { + anyhow::bail!("Expected TransactionExecuted return value"); }; info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; info!("Syncing private accounts"); - let command = Command::Account(AccountSubcommand::SyncPrivate {}); - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + sync_private(&mut ctx).await?; let new_commitment = ctx .wallet() @@ -196,10 +178,7 @@ async fn claim_pinata_to_existing_private_account() -> Result<()> { .context("Failed to get private account commitment")?; assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await); - let pinata_balance_post = ctx - .sequencer_client() - .get_account_balance(system_accounts::pinata_account_id()) - .await?; + let pinata_balance_post = account_balance(&ctx, system_accounts::pinata_account_id()).await?; assert_eq!(pinata_balance_post, pinata_balance_pre - pinata_prize); @@ -215,20 +194,7 @@ async fn claim_pinata_to_new_private_account() -> Result<()> { let pinata_prize = 150; // Create new private account - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: winner_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let winner_account_id = new_account(&mut ctx, true, None).await?; // Initialize account under auth transfer program let command = Command::AuthTransfer(AuthTransferSubcommand::Init { @@ -250,10 +216,7 @@ async fn claim_pinata_to_new_private_account() -> Result<()> { to: private_mention(winner_account_id), }); - let pinata_balance_pre = ctx - .sequencer_client() - .get_account_balance(system_accounts::pinata_account_id()) - .await?; + let pinata_balance_pre = account_balance(&ctx, system_accounts::pinata_account_id()).await?; wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; @@ -266,10 +229,7 @@ async fn claim_pinata_to_new_private_account() -> Result<()> { .context("Failed to get private account commitment")?; assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await); - let pinata_balance_post = ctx - .sequencer_client() - .get_account_balance(system_accounts::pinata_account_id()) - .await?; + let pinata_balance_post = account_balance(&ctx, system_accounts::pinata_account_id()).await?; assert_eq!(pinata_balance_post, pinata_balance_pre - pinata_prize); diff --git a/integration_tests/tests/private_pda.rs b/integration_tests/tests/private_pda.rs index f3136717..af78aa74 100644 --- a/integration_tests/tests/private_pda.rs +++ b/integration_tests/tests/private_pda.rs @@ -9,9 +9,8 @@ use anyhow::{Context as _, Result}; use authenticated_transfer_core::Instruction as AuthTransferInstruction; use common::transaction::LeeTransaction; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, verify_commitment_is_in_state, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, sync_private, verify_commitment_is_in_state, }; -use key_protocol::key_management::ephemeral_key_holder::EphemeralKeyHolder; use lee::{ AccountId, PrivacyPreservingTransaction, ProgramId, privacy_preserving_transaction::{ @@ -22,7 +21,7 @@ use lee::{ program::Program, }; use lee_core::{ - EncryptedAccountData, InputAccountIdentity, NullifierPublicKey, + DUMMY_COMMITMENT_HASH, InputAccountIdentity, NullifierPublicKey, account::{Account, AccountWithMetadata}, encryption::ViewingPublicKey, program::PdaSeed, @@ -30,10 +29,7 @@ use lee_core::{ use log::info; use sequencer_service_rpc::RpcClient as _; use tokio::test; -use wallet::{ - AccountIdentity, WalletCore, - cli::{Command, account::AccountSubcommand}, -}; +use wallet::{AccountIdentity, WalletCore}; /// Funds a private PDA by calling `auth_transfer` directly. #[expect( @@ -51,7 +47,8 @@ async fn fund_private_pda( amount: u128, auth_transfer: &ProgramWithDependencies, ) -> Result<()> { - let pda_account_id = AccountId::for_private_pda(&authority_program_id, &seed, &npk, identifier); + let pda_account_id = + AccountId::for_private_pda(&authority_program_id, &seed, &npk, &vpk, identifier); let sender_account = wallet .get_account_public(sender) .await @@ -63,21 +60,17 @@ async fn fund_private_pda( let sender_pre = AccountWithMetadata::new(sender_account.clone(), true, sender); let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_account_id); - let eph_holder = EphemeralKeyHolder::new(&vpk); - let ssk = eph_holder.calculate_shared_secret_sender(); - let epk = eph_holder.ephemeral_public_key().clone(); - let instruction = Program::serialize_instruction(AuthTransferInstruction::Transfer { amount }) .context("failed to serialize auth_transfer instruction")?; let account_identities = vec![ InputAccountIdentity::Public, InputAccountIdentity::PrivatePdaInit { - epk, - view_tag: EncryptedAccountData::compute_view_tag(&npk, &vpk), + vpk, + random_seed: [0; 32], npk, - ssk, identifier, + commitment_root: DUMMY_COMMITMENT_HASH, seed: Some((seed, authority_program_id)), }, ]; @@ -98,7 +91,7 @@ async fn fund_private_pda( let tx = PrivacyPreservingTransaction::new(message, witness_set); wallet - .sequencer_client + .helm_owned() .send_transaction(LeeTransaction::PrivacyPreserving(tx)) .await .map_err(|e| anyhow::anyhow!("send transaction failed: {e}"))?; @@ -175,8 +168,8 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { let spend_program = ProgramWithDependencies::new(proxy, [(auth_transfer_id, auth_transfer)].into()); - let alice_pda_0_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, 0); - let alice_pda_1_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, 1); + let alice_pda_0_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, &alice_vpk, 0); + let alice_pda_1_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, &alice_vpk, 1); // Use two different public senders to avoid nonce conflicts between the back-to-back txs. let senders = ctx.existing_public_accounts(); @@ -187,7 +180,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { info!("Sending to alice_pda_0 (identifier=0)"); fund_private_pda( - ctx.wallet(), + ctx.wallet_mut(), sender_0, alice_npk, alice_vpk.clone(), @@ -201,7 +194,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { info!("Sending to alice_pda_1 (identifier=1)"); fund_private_pda( - ctx.wallet(), + ctx.wallet_mut(), sender_1, alice_npk, alice_vpk.clone(), @@ -217,11 +210,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Sync so alice's wallet discovers and stores both PDAs. - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::SyncPrivate {}), - ) - .await?; + sync_private(&mut ctx).await?; // Both PDAs must be discoverable and have the correct balance. let pda_0_account = ctx @@ -242,7 +231,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { .get_private_account_commitment(alice_pda_0_id) .context("commitment for alice_pda_0 missing")?; assert!( - verify_commitment_is_in_state(commitment_0.clone(), ctx.sequencer_client()).await, + verify_commitment_is_in_state(commitment_0, ctx.sequencer_client()).await, "alice_pda_0 commitment not in state after receive" ); @@ -251,7 +240,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { .get_private_account_commitment(alice_pda_1_id) .context("commitment for alice_pda_1 missing")?; assert!( - verify_commitment_is_in_state(commitment_1.clone(), ctx.sequencer_client()).await, + verify_commitment_is_in_state(commitment_1, ctx.sequencer_client()).await, "alice_pda_1 commitment not in state after receive" ); assert_ne!( @@ -273,7 +262,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { info!("Alice spending from alice_pda_0"); spend_private_pda( - ctx.wallet(), + ctx.wallet_mut(), alice_pda_0_id, recipient_npk_0, recipient_vpk_0, @@ -286,7 +275,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { info!("Alice spending from alice_pda_1"); spend_private_pda( - ctx.wallet(), + ctx.wallet_mut(), alice_pda_1_id, recipient_npk_1, recipient_vpk_1, @@ -300,11 +289,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { info!("Waiting for block"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::SyncPrivate {}), - ) - .await?; + sync_private(&mut ctx).await?; // After spending, PDAs should have the remaining balance. let pda_0_spent = ctx diff --git a/integration_tests/tests/private_transaction_padding.rs b/integration_tests/tests/private_transaction_padding.rs new file mode 100644 index 00000000..d9fceefe --- /dev/null +++ b/integration_tests/tests/private_transaction_padding.rs @@ -0,0 +1,34 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "We don't care about these in tests" +)] + +use anyhow::Result; +use integration_tests::{TestContext, fetch_privacy_preserving_tx, new_account, private_mention}; +use tokio::test; +use wallet::cli::{ + Command, SubcommandReturnValue, programs::native_token_transfer::AuthTransferSubcommand, +}; + +#[test] +async fn private_transaction_pads_notes_to_max() -> Result<()> { + let mut ctx = TestContext::new().await?; + + let account_id = new_account(&mut ctx, true, None).await?; + + let command = Command::AuthTransfer(AuthTransferSubcommand::Init { + account_id: private_mention(account_id), + }); + let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + let SubcommandReturnValue::TransactionExecuted { tx_hash } = result else { + anyhow::bail!("Expected TransactionExecuted return value"); + }; + + let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await; + + assert_eq!(tx.message.new_commitments.len(), 7); + assert_eq!(tx.message.new_nullifiers.len(), 7); + assert_eq!(tx.message.encrypted_private_post_states.len(), 7); + + Ok(()) +} diff --git a/integration_tests/tests/program_deployment.rs b/integration_tests/tests/program_deployment.rs index ec01c3c8..3c620168 100644 --- a/integration_tests/tests/program_deployment.rs +++ b/integration_tests/tests/program_deployment.rs @@ -7,14 +7,11 @@ use std::{io::Write as _, time::Duration}; use anyhow::Result; use common::transaction::LeeTransaction; -use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext}; +use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, get_account, new_account}; use log::info; use sequencer_service_rpc::RpcClient as _; use tokio::test; -use wallet::cli::{ - Command, SubcommandReturnValue, - account::{AccountSubcommand, NewSubcommand}, -}; +use wallet::{cli::Command, config::WalletConfigOverrides}; #[test] async fn deploy_and_execute_program() -> Result<()> { @@ -32,22 +29,9 @@ async fn deploy_and_execute_program() -> Result<()> { wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + let account_id = new_account(&mut ctx, false, None).await?; - let SubcommandReturnValue::RegisterAccount { account_id } = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await? - else { - panic!("Expected RegisterAccount return value"); - }; - - let nonces = ctx.wallet().get_accounts_nonces(vec![account_id]).await?; + let nonces = ctx.wallet_mut().get_accounts_nonces(&[account_id]).await?; let private_key = ctx .wallet() .get_account_public_signing_key(account_id) @@ -66,7 +50,7 @@ async fn deploy_and_execute_program() -> Result<()> { // block tokio::time::sleep(Duration::from_secs(2 * TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let post_state_account = ctx.sequencer_client().get_account(account_id).await?; + let post_state_account = get_account(&ctx, account_id).await?; let expected_data: &[u8] = &[]; assert_eq!(post_state_account.program_owner, claimer.id()); @@ -78,3 +62,37 @@ async fn deploy_and_execute_program() -> Result<()> { Ok(()) } + +#[test] +async fn deploy_invalid_program_fails() -> Result<()> { + // An invalid program bytecode is rejected by the sequencer during block production, so the + // deployment transaction is never included in a block. Shrink the wallet's polling window so + // the command gives up quickly instead of waiting for the full default timeout. + let mut ctx = TestContext::builder() + .with_wallet_config_overrides(WalletConfigOverrides { + seq_poll_timeout: Some(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)), + seq_tx_poll_max_blocks: Some(5), + seq_poll_max_retries: Some(2), + ..WalletConfigOverrides::default() + }) + .build() + .await?; + + let mut tempfile = tempfile::NamedTempFile::new()?; + tempfile.write_all(b"this is not a valid program binary")?; + + let command = Command::DeployProgram { + binary_filepath: tempfile.path().to_owned(), + }; + + let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await; + + assert!( + result.is_err(), + "Deploying an invalid program should fail, but got: {result:?}" + ); + + info!("Deploying an invalid program failed as expected"); + + Ok(()) +} diff --git a/integration_tests/tests/sequencer_bootstrap.rs b/integration_tests/tests/sequencer_bootstrap.rs new file mode 100644 index 00000000..c4468c1b --- /dev/null +++ b/integration_tests/tests/sequencer_bootstrap.rs @@ -0,0 +1,541 @@ +//! End-to-end tests for the sequencer's startup bootstrap/reconstruction from +//! Bedrock (verify-and-reconstruct). Each test drives a real Bedrock node and one +//! or more sequencer instances sharing the same channel, exercising how a +//! sequencer reconciles its local store against what the channel serves. + +#![expect( + clippy::tests_outside_test_module, + reason = "Integration tests live at crate root and don't care about these lints" +)] + +use std::{path::Path, time::Duration}; + +use anyhow::{Context as _, Result, bail}; +use indexer_service_rpc::RpcClient as _; +use lee::{AccountId, PrivateKey, PublicKey}; +use logos_blockchain_core::mantle::ops::channel::ChannelId; +use sequencer_core::config::GenesisAction; +use sequencer_service_rpc::{RpcClient as _, SequencerClient}; +use test_fixtures::{ + config::{SequencerPartialConfig, UrlProtocol, addr_to_url}, + indexer_client::IndexerClient, + setup::{SequencerSetup, sequencer_client, setup_bedrock_node, setup_indexer}, +}; +use tokio::test; + +/// Finalization can lag several minutes under CI load; give the bootstrap and +/// reconstruction waits generous headroom so runner-speed variance does not flake. +const FINALIZE_TIMEOUT: Duration = Duration::from_mins(12); + +/// Block cadence for the tests: short so we don't wait long for local production. +fn fast_blocks() -> SequencerPartialConfig { + SequencerPartialConfig { + block_create_timeout: Duration::from_secs(2), + ..SequencerPartialConfig::default() + } +} + +/// Block cadence for a sequencer we don't want producing during the inspection +/// window, so its post-reconstruction tip stays put while we read it. +fn slow_blocks() -> SequencerPartialConfig { + SequencerPartialConfig { + block_create_timeout: Duration::from_secs(30), + ..SequencerPartialConfig::default() + } +} + +/// Polls the indexer's last finalized block id until it reaches `target`. The +/// indexer reads finalized channel history, so this is our oracle for "block +/// `target` is finalized on Bedrock" — exactly what a reconstructing sequencer +/// can read back. +async fn wait_for_finalized( + indexer: &IndexerClient, + target: u64, + timeout: Duration, +) -> Result { + let poll = async { + loop { + let finalized = indexer + .get_last_finalized_block_id() + .await + .context("Failed to read indexer last finalized block id")? + .unwrap_or(0); + if finalized >= target { + return Ok::<_, anyhow::Error>(finalized); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + }; + tokio::time::timeout(timeout, poll) + .await + .with_context(|| format!("Timed out waiting for indexer to finalize block {target}"))? +} + +/// Polls the sequencer's last block id until it reaches `target` or `timeout` elapses. +async fn wait_for_block_id( + client: &SequencerClient, + target: u64, + timeout: Duration, +) -> Result { + let poll = async { + loop { + let id = client + .get_last_block_id() + .await + .context("Failed to read sequencer last block id")?; + if id >= target { + return Ok::<_, anyhow::Error>(id); + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + }; + tokio::time::timeout(timeout, poll) + .await + .with_context(|| format!("Timed out waiting for block id {target}"))? +} + +/// Best-effort extraction of a panic payload's message (panics carry a `String` +/// or `&str`), for asserting a startup aborted for the *expected* reason. +fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { + payload + .downcast_ref::() + .cloned() + .or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_owned())) + .unwrap_or_else(|| "".to_owned()) +} + +/// A `SupplyAccount` genesis action for a fresh account, returning the vault +/// account id the funds land in (genesis supply goes into a claimable vault, not +/// the account directly), so tests can assert genesis state is present. +fn supplied_account(balance: u128) -> (AccountId, GenesisAction) { + let account_id = AccountId::from(&PublicKey::new_from_private_key( + &PrivateKey::new_os_random(), + )); + let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), account_id); + ( + vault_id, + GenesisAction::SupplyAccount { + account_id, + balance, + }, + ) +} + +/// Recursively copies the contents of `src` into `dst` (used to snapshot/restore a +/// sequencer's rocksdb directory while it is stopped). +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { + std::fs::create_dir_all(dst) + .with_context(|| format!("Failed to create dir {}", dst.display()))?; + for entry in + std::fs::read_dir(src).with_context(|| format!("Failed to read dir {}", src.display()))? + { + let entry = entry.context("Failed to read dir entry")?; + let target = dst.join(entry.file_name()); + if entry + .file_type() + .context("Failed to read file type")? + .is_dir() + { + copy_dir_recursive(&entry.path(), &target)?; + } else { + std::fs::copy(entry.path(), &target) + .with_context(|| format!("Failed to copy into {}", target.display()))?; + } + } + Ok(()) +} + +/// Case 1: local store is empty and the Bedrock channel is empty. +/// +/// The sequencer bootstraps genesis state, finds nothing to reconstruct, publishes +/// its own genesis to open the channel, and starts producing. +#[test] +async fn empty_local_and_empty_bedrock_bootstraps_from_genesis() -> Result<()> { + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock")?; + let home = tempfile::tempdir().context("Failed to create sequencer home")?; + + let (vault_id, supply) = supplied_account(12_345); + let genesis = vec![ + supply, + GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }, + ]; + + let handle = SequencerSetup::new(fast_blocks(), bedrock_addr) + .with_genesis(genesis) + .setup_at(home.path()) + .await + .context("Failed to start sequencer")?; + let client = sequencer_client(handle.addr())?; + + // Fresh store + empty channel: startup bootstrapped genesis state directly. + assert_eq!( + client.get_account_balance(vault_id).await?, + 12_345, + "genesis-supplied vault balance must be present after bootstrap" + ); + + // The sequencer is live and producing on the freshly opened channel. + let last = wait_for_block_id(&client, 3, Duration::from_secs(60)).await?; + assert!( + last >= 3, + "sequencer should keep producing blocks, last={last}" + ); + assert!(handle.is_healthy(), "sequencer must stay healthy"); + + Ok(()) +} + +/// Case 2: local store is empty, but the Bedrock channel already has blocks. +/// +/// A first sequencer opens the channel and produces blocks; a second sequencer +/// starts from an empty store on the same channel (reusing the first's bedrock +/// signing key) and reconstructs the finalized history into its state. +#[test] +async fn empty_local_reconstructs_from_populated_bedrock() -> Result<()> { + const PRODUCED_TARGET: u64 = 3; + + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock")?; + let (indexer_handle, _indexer_dir) = setup_indexer( + bedrock_addr, + test_fixtures::config::bedrock_channel_id(), + None, + ) + .await + .context("Failed to setup indexer")?; + let indexer_url = addr_to_url(UrlProtocol::Ws, indexer_handle.addr()) + .context("Failed to build indexer URL")?; + let indexer = IndexerClient::new(&indexer_url) + .await + .context("Failed to build indexer client")?; + + let (vault_id, supply) = supplied_account(7_777); + let genesis = vec![ + supply, + GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }, + ]; + + // Sequencer A opens the channel and produces a few blocks. + let home_a = tempfile::tempdir().context("Failed to create sequencer A home")?; + let handle_a = SequencerSetup::new(fast_blocks(), bedrock_addr) + .with_genesis(genesis.clone()) + .setup_at(home_a.path()) + .await + .context("Failed to start sequencer A")?; + let client_a = sequencer_client(handle_a.addr())?; + wait_for_block_id(&client_a, PRODUCED_TARGET, Duration::from_secs(60)).await?; + + // Wait until those blocks are finalized on Bedrock — reconstruction only + // reads finalized history. A stays alive so its publish task keeps flushing. + let finalized = wait_for_finalized(&indexer, PRODUCED_TARGET, FINALIZE_TIMEOUT).await?; + + // Stop A, then wipe just its L2 store (keeping the bedrock signing key) so it + // restarts from an empty store on the same channel/identity — a sequencer that + // lost its local DB. + drop(handle_a); + tokio::time::sleep(Duration::from_secs(2)).await; + std::fs::remove_dir_all(home_a.path().join("rocksdb")) + .context("Failed to wipe sequencer L2 store")?; + + // Sequencer B restarts on the same home from that empty store and reconstructs. + let handle_b = SequencerSetup::new(slow_blocks(), bedrock_addr) + .with_genesis(genesis) + .setup_at(home_a.path()) + .await + .context("Failed to start sequencer B")?; + let client_b = sequencer_client(handle_b.addr())?; + + // Reconstruction ran synchronously during B's startup: even though its local + // store was empty, its tip is past genesis, matching the finalized channel. + let tip_b = client_b.get_last_block_id().await?; + assert!( + tip_b >= finalized, + "B should reconstruct at least the finalized blocks; tip_b={tip_b}, finalized={finalized}" + ); + assert!( + tip_b > 1, + "B should have reconstructed blocks beyond genesis; tip_b={tip_b}" + ); + + // Genesis state was rebuilt as part of the reconstruction. + assert_eq!( + client_b.get_account_balance(vault_id).await?, + 7_777, + "reconstructed genesis vault balance must be present" + ); + assert!(handle_b.is_healthy(), "sequencer B must stay healthy"); + + Ok(()) +} + +/// Case 3: local store is not empty, but the Bedrock channel is empty. +/// +/// A sequencer produces blocks (committing to a channel), is stopped, and is +/// restarted against a fresh/empty channel — i.e. the channel it committed to +/// was wiped or the node points at a different chain. Startup must fail rather +/// than silently resume onto a foreign channel. Crucially this must hold even +/// though the sequencer only ever *produced* (so it never recorded a per-block +/// anchor): the committed-but-missing-channel invariant catches it. +#[test] +async fn nonempty_local_against_empty_channel_fails_startup() -> Result<()> { + const PRODUCED_TARGET: u64 = 3; + + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock")?; + + let (_vault_id, supply) = supplied_account(1); + let genesis = vec![ + supply, + GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }, + ]; + + // A opens the channel and produces blocks. They land in its local store + // immediately, so no need to wait for finalization. + let home_a = tempfile::tempdir().context("Failed to create sequencer A home")?; + let handle_a = SequencerSetup::new(fast_blocks(), bedrock_addr) + .with_genesis(genesis.clone()) + .setup_at(home_a.path()) + .await + .context("Failed to start sequencer A")?; + wait_for_block_id( + &sequencer_client(handle_a.addr())?, + PRODUCED_TARGET, + Duration::from_secs(60), + ) + .await?; + drop(handle_a); + tokio::time::sleep(Duration::from_secs(2)).await; + + // Restart on the SAME home (A's committed store: blocks + checkpoint) but + // pointed at a fresh, never-used channel — the channel it committed to is gone. + let empty_channel = ChannelId::from([0x5a_u8; 32]); + + // Startup aborts on the missing-channel invariant (a panic in + // `start_from_config`). Run it on a dedicated OS thread with its own runtime + // so the panic is isolated to `join()` instead of failing the test thread. + // The `timeout` future must be created *inside* `block_on` (it needs a running + // reactor), so build it in an `async` block rather than as an eager argument. + let home_a_path = home_a.path().to_owned(); + let outcome = std::thread::spawn(move || { + let runtime = tokio::runtime::Runtime::new().expect("Failed to build runtime"); + runtime.block_on(async { + tokio::time::timeout( + Duration::from_secs(90), + SequencerSetup::new(slow_blocks(), bedrock_addr) + .with_channel_id(empty_channel) + .with_genesis(genesis) + .setup_at(&home_a_path), + ) + .await + }) + }) + .join(); + + match outcome { + // Expected: `start_from_config` panicked on the missing-channel invariant. + // Assert the *reason*, so an unrelated panic fails the test rather than + // masquerading as success. + Err(panic) => { + let message = panic_message(&*panic); + assert!( + message.contains("Refusing to resume onto a foreign channel"), + "startup panicked for an unexpected reason: {message}" + ); + } + Ok(Err(_elapsed)) => { + bail!("Sequencer startup hung instead of failing against an empty channel") + } + Ok(Ok(Err(err))) => { + bail!("Sequencer expected to panic, but it failed with error: {err:#?}") + } + Ok(Ok(Ok(_handle))) => { + bail!("Sequencer startup unexpectedly succeeded against an empty channel") + } + } + + Ok(()) +} + +/// Case 4: both non-empty, but the local store is *ahead* of the finalized channel. +/// +/// A sequencer produces blocks faster than Bedrock finalizes them (the normal +/// steady state), so on restart its local tip leads the channel's finalized tip. +/// Startup must succeed: reconstruction re-verifies the finalized blocks it already +/// holds and leaves the extra, not-yet-finalized local blocks untouched. +#[test] +async fn local_ahead_of_channel_resumes() -> Result<()> { + const FINALIZED_TARGET: u64 = 2; + + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock")?; + let (indexer_handle, _indexer_dir) = setup_indexer( + bedrock_addr, + test_fixtures::config::bedrock_channel_id(), + None, + ) + .await + .context("Failed to setup indexer")?; + let indexer_url = addr_to_url(UrlProtocol::Ws, indexer_handle.addr()) + .context("Failed to build indexer URL")?; + let indexer = IndexerClient::new(&indexer_url) + .await + .context("Failed to build indexer client")?; + + let (vault_id, supply) = supplied_account(4_242); + let genesis = vec![ + supply, + GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }, + ]; + + // A produces continuously (fast cadence) while Bedrock finalizes slowly, so + // its local tip runs well ahead of the channel's finalized tip. + let home = tempfile::tempdir().context("Failed to create sequencer home")?; + let handle_a = SequencerSetup::new(fast_blocks(), bedrock_addr) + .with_genesis(genesis.clone()) + .setup_at(home.path()) + .await + .context("Failed to start sequencer A")?; + let client_a = sequencer_client(handle_a.addr())?; + let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, FINALIZE_TIMEOUT).await?; + let tip_before = client_a.get_last_block_id().await?; + assert!( + tip_before > finalized, + "local tip {tip_before} should lead the finalized tip {finalized}" + ); + + // Restart on the same home; slow cadence so its tip stays put while we inspect. + drop(handle_a); + tokio::time::sleep(Duration::from_secs(2)).await; + let handle_b = SequencerSetup::new(slow_blocks(), bedrock_addr) + .with_genesis(genesis) + .setup_at(home.path()) + .await + .context("Failed to restart sequencer")?; + let client_b = sequencer_client(handle_b.addr())?; + + // Reconstruction verified the finalized prefix and preserved the extra blocks. + let tip_b = client_b.get_last_block_id().await?; + assert!( + tip_b >= tip_before, + "restart must not lose locally-produced blocks; tip_b={tip_b}, before={tip_before}" + ); + assert_eq!( + client_b.get_account_balance(vault_id).await?, + 4_242, + "genesis state must survive the restart" + ); + assert!(handle_b.is_healthy(), "sequencer must stay healthy"); + + Ok(()) +} + +/// Case 5: both non-empty, but the local store is *behind* the finalized channel. +/// +/// We snapshot a sequencer's store at an early tip, let it keep extending and +/// finalizing the channel, then restore the early snapshot and restart. Startup +/// must reconstruct forward — replay the finalized blocks the local store is +/// missing — catching the local tip up to the channel. +/// +/// The snapshot/restore is essential here (not a gratuitous copy): a live +/// sequencer's local tip always leads finalization, so the only way to obtain a +/// local store that *lags* the finalized channel is to preserve an earlier state +/// while the same channel advances past it. +#[test] +async fn local_behind_channel_reconstructs_forward() -> Result<()> { + const SNAPSHOT_TIP: u64 = 2; + const FINALIZED_TARGET: u64 = 4; + + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock")?; + let (indexer_handle, _indexer_dir) = setup_indexer( + bedrock_addr, + test_fixtures::config::bedrock_channel_id(), + None, + ) + .await + .context("Failed to setup indexer")?; + let indexer_url = addr_to_url(UrlProtocol::Ws, indexer_handle.addr()) + .context("Failed to build indexer URL")?; + let indexer = IndexerClient::new(&indexer_url) + .await + .context("Failed to build indexer client")?; + + let (vault_id, supply) = supplied_account(5_005); + let genesis = vec![ + supply, + GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }, + ]; + + let home = tempfile::tempdir().context("Failed to create sequencer home")?; + let rocksdb = home.path().join("rocksdb"); + + // Bring the sequencer up to an early tip, then stop it so its store is at rest. + { + let handle = SequencerSetup::new(slow_blocks(), bedrock_addr) + .with_genesis(genesis.clone()) + .setup_at(home.path()) + .await + .context("Failed to start sequencer")?; + wait_for_block_id( + &sequencer_client(handle.addr())?, + SNAPSHOT_TIP, + Duration::from_secs(120), + ) + .await?; + drop(handle); + tokio::time::sleep(Duration::from_secs(2)).await; + } + + // Snapshot the early store (tip == SNAPSHOT_TIP), safe because it is at rest. + let snapshot = tempfile::tempdir().context("Failed to create snapshot dir")?; + copy_dir_recursive(&rocksdb, snapshot.path()).context("Failed to snapshot store")?; + + // Resume the sequencer (fast cadence) so it extends and finalizes the channel + // beyond the snapshot. + let finalized = { + let handle = SequencerSetup::new(fast_blocks(), bedrock_addr) + .with_genesis(genesis.clone()) + .setup_at(home.path()) + .await + .context("Failed to resume sequencer")?; + let finalized = wait_for_finalized(&indexer, FINALIZED_TARGET, FINALIZE_TIMEOUT).await?; + drop(handle); + tokio::time::sleep(Duration::from_secs(2)).await; + finalized + }; + + // Restore the early snapshot: the local store now lags the finalized channel. + std::fs::remove_dir_all(&rocksdb).context("Failed to remove store before restore")?; + copy_dir_recursive(snapshot.path(), &rocksdb).context("Failed to restore snapshot")?; + + // Restart: reconstruction must catch the lagging store up to the channel. + let handle = SequencerSetup::new(slow_blocks(), bedrock_addr) + .with_genesis(genesis) + .setup_at(home.path()) + .await + .context("Failed to restart sequencer from a lagging store")?; + let client = sequencer_client(handle.addr())?; + let tip = client.get_last_block_id().await?; + assert!( + tip >= finalized, + "lagging store must reconstruct forward to the finalized tip; tip={tip}, finalized={finalized}" + ); + assert!( + tip > SNAPSHOT_TIP, + "reconstruction must advance beyond the snapshot tip; tip={tip}" + ); + assert_eq!( + client.get_account_balance(vault_id).await?, + 5_005, + "genesis state must be intact after reconstruction" + ); + assert!(handle.is_healthy(), "sequencer must stay healthy"); + + Ok(()) +} diff --git a/integration_tests/tests/shared_accounts.rs b/integration_tests/tests/shared_accounts.rs index 39bdd36c..cc6e6e1d 100644 --- a/integration_tests/tests/shared_accounts.rs +++ b/integration_tests/tests/shared_accounts.rs @@ -19,7 +19,7 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention, sync_private, }; use log::info; use tokio::test; @@ -197,8 +197,7 @@ async fn fund_shared_account_from_public() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Sync private accounts - let command = Command::Account(AccountSubcommand::SyncPrivate); - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + sync_private(&mut ctx).await?; // Fund from a public account let from_public = ctx.existing_public_accounts()[0]; @@ -216,8 +215,7 @@ async fn fund_shared_account_from_public() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Sync private accounts - let command = Command::Account(AccountSubcommand::SyncPrivate); - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + sync_private(&mut ctx).await?; // Verify the shared account was updated let entry = ctx diff --git a/integration_tests/tests/token.rs b/integration_tests/tests/token.rs index 60bd3de8..215a1065 100644 --- a/integration_tests/tests/token.rs +++ b/integration_tests/tests/token.rs @@ -8,12 +8,11 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention, - verify_commitment_is_in_state, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, get_account, new_account, private_mention, + public_mention, sync_private, token_send_claiming_new_account, verify_commitment_is_in_state, }; use key_protocol::key_management::key_tree::chain_index::ChainIndex; use log::info; -use sequencer_service_rpc::RpcClient as _; use token_core::{TokenDefinition, TokenHolding}; use tokio::test; use wallet::{ @@ -30,52 +29,13 @@ async fn create_and_transfer_public_token() -> Result<()> { let mut ctx = TestContext::new().await?; // Create new account for the token definition - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: definition_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let definition_account_id = new_account(&mut ctx, false, None).await?; // Create new account for the token supply holder - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: supply_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let supply_account_id = new_account(&mut ctx, false, None).await?; // Create new account for receiving a token transaction - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: recipient_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let recipient_account_id = new_account(&mut ctx, false, None).await?; // Create new token let name = "A NAME".to_owned(); @@ -92,10 +52,7 @@ async fn create_and_transfer_public_token() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Check the status of the token definition account - let definition_acc = ctx - .sequencer_client() - .get_account(definition_account_id) - .await?; + let definition_acc = get_account(&ctx, definition_account_id).await?; let token_definition = TokenDefinition::try_from(&definition_acc.data)?; assert_eq!(definition_acc.program_owner, programs::token().id()); @@ -109,10 +66,7 @@ async fn create_and_transfer_public_token() -> Result<()> { ); // Check the status of the token holding account with the total supply - let supply_acc = ctx - .sequencer_client() - .get_account(supply_account_id) - .await?; + let supply_acc = get_account(&ctx, supply_account_id).await?; // The account must be owned by the token program assert_eq!(supply_acc.program_owner, programs::token().id()); @@ -125,28 +79,20 @@ async fn create_and_transfer_public_token() -> Result<()> { } ); - // Transfer 7 tokens from supply_acc to recipient_account_id + // Transfer 7 tokens from supply_acc to recipient_account_id. `recipient_account_id` is + // still unclaimed, so this bypasses the wallet CLI (which never signs with the recipient's + // key) and signs with the recipient's own key directly. let transfer_amount = 7; - let subcommand = TokenProgramAgnosticSubcommand::Send { - from: public_mention(supply_account_id), - to: Some(public_mention(recipient_account_id)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: transfer_amount, - }; - - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; - - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + token_send_claiming_new_account( + &mut ctx, + supply_account_id, + recipient_account_id, + transfer_amount, + ) + .await?; // Check the status of the supply account after transfer - let supply_acc = ctx - .sequencer_client() - .get_account(supply_account_id) - .await?; + let supply_acc = get_account(&ctx, supply_account_id).await?; assert_eq!(supply_acc.program_owner, programs::token().id()); let token_holding = TokenHolding::try_from(&supply_acc.data)?; assert_eq!( @@ -158,10 +104,7 @@ async fn create_and_transfer_public_token() -> Result<()> { ); // Check the status of the recipient account after transfer - let recipient_acc = ctx - .sequencer_client() - .get_account(recipient_account_id) - .await?; + let recipient_acc = get_account(&ctx, recipient_account_id).await?; assert_eq!(recipient_acc.program_owner, programs::token().id()); let token_holding = TokenHolding::try_from(&recipient_acc.data)?; assert_eq!( @@ -186,10 +129,7 @@ async fn create_and_transfer_public_token() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Check the status of the token definition account after burn - let definition_acc = ctx - .sequencer_client() - .get_account(definition_account_id) - .await?; + let definition_acc = get_account(&ctx, definition_account_id).await?; let token_definition = TokenDefinition::try_from(&definition_acc.data)?; assert_eq!( @@ -202,10 +142,7 @@ async fn create_and_transfer_public_token() -> Result<()> { ); // Check the status of the recipient account after burn - let recipient_acc = ctx - .sequencer_client() - .get_account(recipient_account_id) - .await?; + let recipient_acc = get_account(&ctx, recipient_account_id).await?; let token_holding = TokenHolding::try_from(&recipient_acc.data)?; assert_eq!( @@ -234,10 +171,7 @@ async fn create_and_transfer_public_token() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Check the status of the token definition account after mint - let definition_acc = ctx - .sequencer_client() - .get_account(definition_account_id) - .await?; + let definition_acc = get_account(&ctx, definition_account_id).await?; let token_definition = TokenDefinition::try_from(&definition_acc.data)?; assert_eq!( @@ -250,10 +184,7 @@ async fn create_and_transfer_public_token() -> Result<()> { ); // Check the status of the recipient account after mint - let recipient_acc = ctx - .sequencer_client() - .get_account(recipient_account_id) - .await?; + let recipient_acc = get_account(&ctx, recipient_account_id).await?; let token_holding = TokenHolding::try_from(&recipient_acc.data)?; assert_eq!( @@ -274,52 +205,13 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> { let mut ctx = TestContext::new().await?; // Create new account for the token definition (public) - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: definition_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let definition_account_id = new_account(&mut ctx, false, None).await?; // Create new account for the token supply holder (private) - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: supply_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let supply_account_id = new_account(&mut ctx, true, None).await?; // Create new account for receiving a token transaction (private) - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: recipient_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let recipient_account_id = new_account(&mut ctx, true, None).await?; // Create new token let name = "A NAME".to_owned(); @@ -337,10 +229,7 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Check the status of the token definition account - let definition_acc = ctx - .sequencer_client() - .get_account(definition_account_id) - .await?; + let definition_acc = get_account(&ctx, definition_account_id).await?; let token_definition = TokenDefinition::try_from(&definition_acc.data)?; assert_eq!(definition_acc.program_owner, programs::token().id()); @@ -402,10 +291,7 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Check the token definition account after burn - let definition_acc = ctx - .sequencer_client() - .get_account(definition_account_id) - .await?; + let definition_acc = get_account(&ctx, definition_account_id).await?; let token_definition = TokenDefinition::try_from(&definition_acc.data)?; assert_eq!( @@ -448,36 +334,10 @@ async fn create_token_with_private_definition() -> Result<()> { let mut ctx = TestContext::new().await?; // Create token definition account (private) - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: Some(ChainIndex::root()), - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: definition_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let definition_account_id = new_account(&mut ctx, true, Some(ChainIndex::root())).await?; // Create supply account (public) - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: Some(ChainIndex::root()), - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: supply_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let supply_account_id = new_account(&mut ctx, false, Some(ChainIndex::root())).await?; // Create token with private definition let name = "A NAME".to_owned(); @@ -502,10 +362,7 @@ async fn create_token_with_private_definition() -> Result<()> { assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await); // Verify supply account - let supply_acc = ctx - .sequencer_client() - .get_account(supply_account_id) - .await?; + let supply_acc = get_account(&ctx, supply_account_id).await?; assert_eq!(supply_acc.program_owner, programs::token().id()); let token_holding = TokenHolding::try_from(&supply_acc.data)?; @@ -518,36 +375,10 @@ async fn create_token_with_private_definition() -> Result<()> { ); // Create private recipient account - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: recipient_account_id_private, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let recipient_account_id_private = new_account(&mut ctx, true, None).await?; // Create public recipient account - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: recipient_account_id_public, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let recipient_account_id_public = new_account(&mut ctx, false, None).await?; // Mint to public account let mint_amount_public = 10; @@ -583,10 +414,7 @@ async fn create_token_with_private_definition() -> Result<()> { ); // Verify public recipient received tokens - let recipient_acc = ctx - .sequencer_client() - .get_account(recipient_account_id_public) - .await?; + let recipient_acc = get_account(&ctx, recipient_account_id_public).await?; let token_holding = TokenHolding::try_from(&recipient_acc.data)?; assert_eq!( @@ -646,36 +474,10 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> { let mut ctx = TestContext::new().await?; // Create token definition account (private) - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: definition_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let definition_account_id = new_account(&mut ctx, true, None).await?; // Create supply account (private) - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: supply_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let supply_account_id = new_account(&mut ctx, true, None).await?; // Create token with both private definition and supply let name = "A NAME".to_owned(); @@ -722,20 +524,7 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> { ); // Create recipient account - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: recipient_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let recipient_account_id = new_account(&mut ctx, true, None).await?; // Transfer tokens let transfer_amount = 7; @@ -804,52 +593,13 @@ async fn shielded_token_transfer() -> Result<()> { let mut ctx = TestContext::new().await?; // Create token definition account (public) - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: definition_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let definition_account_id = new_account(&mut ctx, false, None).await?; // Create supply account (public) - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: supply_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let supply_account_id = new_account(&mut ctx, false, None).await?; // Create recipient account (private) for shielded transfer - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: recipient_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let recipient_account_id = new_account(&mut ctx, true, None).await?; // Create token let name = "A NAME".to_owned(); @@ -884,10 +634,7 @@ async fn shielded_token_transfer() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Verify supply account balance - let supply_acc = ctx - .sequencer_client() - .get_account(supply_account_id) - .await?; + let supply_acc = get_account(&ctx, supply_account_id).await?; let token_holding = TokenHolding::try_from(&supply_acc.data)?; assert_eq!( token_holding, @@ -928,52 +675,13 @@ async fn deshielded_token_transfer() -> Result<()> { let mut ctx = TestContext::new().await?; // Create token definition account (public) - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: definition_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let definition_account_id = new_account(&mut ctx, false, None).await?; // Create supply account (private) - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: supply_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let supply_account_id = new_account(&mut ctx, true, None).await?; // Create recipient account (public) for deshielded transfer - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: recipient_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let recipient_account_id = new_account(&mut ctx, false, None).await?; // Create token with private supply let name = "A NAME".to_owned(); @@ -1029,10 +737,7 @@ async fn deshielded_token_transfer() -> Result<()> { ); // Verify recipient balance - let recipient_acc = ctx - .sequencer_client() - .get_account(recipient_account_id) - .await?; + let recipient_acc = get_account(&ctx, recipient_account_id).await?; let token_holding = TokenHolding::try_from(&recipient_acc.data)?; assert_eq!( token_holding, @@ -1052,36 +757,10 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> { let mut ctx = TestContext::new().await?; // Create token definition account (private) - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: definition_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let definition_account_id = new_account(&mut ctx, true, None).await?; // Create supply account (private) - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: supply_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let supply_account_id = new_account(&mut ctx, true, None).await?; // Create token let name = "A NAME".to_owned(); @@ -1099,20 +778,7 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Create new private account for claiming path - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: recipient_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let recipient_account_id = new_account(&mut ctx, true, None).await?; // Get keys for foreign mint (claiming path) let holder = ctx @@ -1143,8 +809,7 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Sync to claim the account - let command = Command::Account(AccountSubcommand::SyncPrivate {}); - wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + sync_private(&mut ctx).await?; // Verify commitment exists let recipient_commitment = ctx @@ -1224,10 +889,7 @@ async fn create_token_using_labels() -> Result<()> { info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - let definition_acc = ctx - .sequencer_client() - .get_account(definition_account_id) - .await?; + let definition_acc = get_account(&ctx, definition_account_id).await?; let token_definition = TokenDefinition::try_from(&definition_acc.data)?; assert_eq!(definition_acc.program_owner, programs::token().id()); @@ -1240,10 +902,7 @@ async fn create_token_using_labels() -> Result<()> { } ); - let supply_acc = ctx - .sequencer_client() - .get_account(supply_account_id) - .await?; + let supply_acc = get_account(&ctx, supply_account_id).await?; let token_holding = TokenHolding::try_from(&supply_acc.data)?; assert_eq!( token_holding, @@ -1263,20 +922,7 @@ async fn transfer_token_using_from_label() -> Result<()> { let mut ctx = TestContext::new().await?; // Create definition account - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: definition_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let definition_account_id = new_account(&mut ctx, false, None).await?; // Create supply account with a label let supply_label = Label::new("token-supply-sender"); @@ -1296,20 +942,7 @@ async fn transfer_token_using_from_label() -> Result<()> { }; // Create recipient account - let result = wallet::cli::execute_subcommand( - ctx.wallet_mut(), - Command::Account(AccountSubcommand::New(NewSubcommand::Public { - cci: None, - label: None, - })), - ) - .await?; - let SubcommandReturnValue::RegisterAccount { - account_id: recipient_account_id, - } = result - else { - anyhow::bail!("Expected RegisterAccount return value"); - }; + let recipient_account_id = new_account(&mut ctx, false, None).await?; // Create token let total_supply = 50; @@ -1324,26 +957,30 @@ async fn transfer_token_using_from_label() -> Result<()> { info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - // Transfer token using from_label instead of from + // Confirm the label resolves to the account created for it. + let resolved_sender = ctx + .wallet() + .storage() + .resolve_label(&supply_label) + .context("supply_label should resolve to an account")?; + assert_eq!( + resolved_sender, + wallet::account::AccountIdWithPrivacy::Public(supply_account_id) + ); + + // Transfer token from the label-resolved account. `recipient_account_id` is still + // unclaimed, so this bypasses the wallet CLI (which never signs with the recipient's key) + // and signs with the recipient's own key directly. let transfer_amount = 20; - let subcommand = TokenProgramAgnosticSubcommand::Send { - from: supply_label.into(), - to: Some(public_mention(recipient_account_id)), - to_npk: None, - to_vpk: None, - to_keys: None, - to_identifier: Some(0), - amount: transfer_amount, - }; - wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; + token_send_claiming_new_account( + &mut ctx, + supply_account_id, + recipient_account_id, + transfer_amount, + ) + .await?; - info!("Waiting for next block creation"); - tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - - let recipient_acc = ctx - .sequencer_client() - .get_account(recipient_account_id) - .await?; + let recipient_acc = get_account(&ctx, recipient_account_id).await?; let token_holding = TokenHolding::try_from(&recipient_acc.data)?; assert_eq!( token_holding, diff --git a/integration_tests/tests/tps.rs b/integration_tests/tests/tps.rs index a11668a8..bfcd4c64 100644 --- a/integration_tests/tests/tps.rs +++ b/integration_tests/tests/tps.rs @@ -15,7 +15,6 @@ use anyhow::{Context as _, Result}; use bytesize::ByteSize; use common::transaction::LeeTransaction; use integration_tests::{TestContext, config::SequencerPartialConfig}; -use key_protocol::key_management::ephemeral_key_holder::EphemeralKeyHolder; use lee::{ Account, AccountId, PrivacyPreservingTransaction, PrivateKey, PublicKey, PublicTransaction, privacy_preserving_transaction::{self as pptx, circuit}, @@ -23,7 +22,7 @@ use lee::{ public_transaction as putx, }; use lee_core::{ - EncryptedAccountData, InputAccountIdentity, MembershipProof, NullifierPublicKey, + DUMMY_COMMITMENT_HASH, InputAccountIdentity, MembershipProof, NullifierPublicKey, account::{AccountWithMetadata, Nonce, data::Data}, encryption::ViewingPublicKey, }; @@ -266,25 +265,17 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction { data: Data::default(), }, true, - AccountId::for_regular_private_account(&sender_npk, 0), + AccountId::for_regular_private_account(&sender_npk, &sender_vpk, 0), ); let recipient_nsk = [2; 32]; let recipient_vpk = ViewingPublicKey::from_seed(&[101_u8; 32], &[102_u8; 32]); let recipient_npk = NullifierPublicKey::from(&recipient_nsk); let recipient_pre = AccountWithMetadata::new( Account::default(), - false, - AccountId::for_regular_private_account(&recipient_npk, 0), + true, + AccountId::for_regular_private_account(&recipient_npk, &recipient_vpk, 0), ); - let eph_holder_from = EphemeralKeyHolder::new(&sender_vpk); - let sender_ss = eph_holder_from.calculate_shared_secret_sender(); - let sender_epk = eph_holder_from.ephemeral_public_key().clone(); - - let eph_holder_to = EphemeralKeyHolder::new(&recipient_vpk); - let recipient_ss = eph_holder_to.calculate_shared_secret_sender(); - let recipient_epk = eph_holder_to.ephemeral_public_key().clone(); - let balance_to_move: u128 = 1; let proof: MembershipProof = ( 1, @@ -301,19 +292,19 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction { .unwrap(), vec![ InputAccountIdentity::PrivateAuthorizedUpdate { - epk: sender_epk, - view_tag: EncryptedAccountData::compute_view_tag(&sender_npk, &sender_vpk), - ssk: sender_ss, + vpk: sender_vpk, + random_seed: [0; 32], + view_tag: 0, nsk: sender_nsk, membership_proof: proof, identifier: 0, }, - InputAccountIdentity::PrivateUnauthorized { - epk: recipient_epk, - view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk), + InputAccountIdentity::PrivateForeignInit { + vpk: recipient_vpk, + random_seed: [0; 32], npk: recipient_npk, - ssk: recipient_ss, identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, }, ], &program.into(), diff --git a/integration_tests/tests/two_zone.rs b/integration_tests/tests/two_zone.rs new file mode 100644 index 00000000..8f4b2697 --- /dev/null +++ b/integration_tests/tests/two_zone.rs @@ -0,0 +1,116 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "top-level test functions are conventional for integration tests" +)] + +//! Two zones (sequencer + indexer each, on separate channels) sharing one +//! Bedrock node, each producing and finalizing blocks independently. + +use std::{net::SocketAddr, time::Duration}; + +use anyhow::{Context as _, Result}; +use indexer_service_rpc::RpcClient as _; +use integration_tests::{ + config::{self, SequencerPartialConfig}, + indexer_client::IndexerClient, + setup::{SequencerSetup, setup_bedrock_node, setup_indexer}, +}; +use sequencer_service_rpc::{RpcClient as _, SequencerClientBuilder}; +use tokio::test; + +const ZONE_LIVE_TIMEOUT: Duration = Duration::from_secs(360); + +// Genesis is block 1, so reaching 2 means a block was produced past it. +const MIN_BLOCK_ID: u64 = 2; + +#[test] +async fn two_zones_share_one_bedrock_and_both_advance() -> Result<()> { + // Declared first so it outlives both zones (drops run in reverse order). + let (_bedrock, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to set up shared Bedrock node")?; + + let partial = SequencerPartialConfig::default(); + let channel_a = config::bedrock_channel_id(); + let channel_b = config::bedrock_channel_id_b(); + + // Empty genesis is enough: the clock transaction drives block production. + let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_a) + .with_genesis(vec![]) + .setup() + .await + .context("Failed to set up zone A sequencer")?; + let (idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None) + .await + .context("Failed to set up zone A indexer")?; + let (seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) + .with_channel_id(channel_b) + .with_genesis(vec![]) + .setup() + .await + .context("Failed to set up zone B sequencer")?; + let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, None) + .await + .context("Failed to set up zone B indexer")?; + + let (height_a, height_b) = tokio::try_join!( + wait_until_zone_live("A", seq_a.addr(), idx_a.addr()), + wait_until_zone_live("B", seq_b.addr(), idx_b.addr()), + )?; + + assert!( + height_a >= MIN_BLOCK_ID, + "Zone A indexer only reached block {height_a}, expected >= {MIN_BLOCK_ID}" + ); + assert!( + height_b >= MIN_BLOCK_ID, + "Zone B indexer only reached block {height_b}, expected >= {MIN_BLOCK_ID}" + ); + + Ok(()) +} + +/// Wait for the sequencer to produce past genesis and the indexer to finalize up +/// to it. Returns the indexer's finalized block id. +async fn wait_until_zone_live( + label: &str, + sequencer_addr: SocketAddr, + indexer_addr: SocketAddr, +) -> Result { + let sequencer_url = config::addr_to_url(config::UrlProtocol::Http, sequencer_addr) + .context("Failed to build sequencer URL")?; + let sequencer = SequencerClientBuilder::default() + .build(sequencer_url) + .context("Failed to build sequencer client")?; + + let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_addr) + .context("Failed to build indexer URL")?; + let indexer = IndexerClient::new(&indexer_url) + .await + .context("Failed to build indexer client")?; + + let wait = async { + loop { + if sequencer.get_last_block_id().await? >= MIN_BLOCK_ID { + break; + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + let target = sequencer.get_last_block_id().await?; + loop { + let finalized = indexer.get_last_finalized_block_id().await?.unwrap_or(0); + if finalized >= target { + log::info!( + "Zone {label} live: sequencer at {target}, indexer finalized {finalized}" + ); + return Ok::(finalized); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + }; + + tokio::time::timeout(ZONE_LIVE_TIMEOUT, wait) + .await + .with_context(|| format!("Zone {label} did not become live within {ZONE_LIVE_TIMEOUT:?}"))? +} diff --git a/integration_tests/tests/vault.rs b/integration_tests/tests/vault.rs index e9ea2075..70be480e 100644 --- a/integration_tests/tests/vault.rs +++ b/integration_tests/tests/vault.rs @@ -30,7 +30,7 @@ async fn public_transfer_and_public_claim() -> Result<()> { .get_account_balance(recipient_vault_id) .await?; - let transfer_result = wallet::cli::execute_subcommand( + wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Vault(VaultSubcommand::Transfer { from: public_mention(sender), @@ -39,10 +39,6 @@ async fn public_transfer_and_public_claim() -> Result<()> { }), ) .await?; - assert!( - matches!(transfer_result, SubcommandReturnValue::Empty), - "Expected Empty return value for public vault transfer" - ); let sender_balance_after_transfer = ctx.sequencer_client().get_account_balance(sender).await?; let recipient_balance_after_transfer = ctx @@ -64,7 +60,7 @@ async fn public_transfer_and_public_claim() -> Result<()> { recipient_vault_balance_before + amount ); - let claim_result = wallet::cli::execute_subcommand( + wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Vault(VaultSubcommand::Claim { account_id: public_mention(recipient), @@ -72,10 +68,6 @@ async fn public_transfer_and_public_claim() -> Result<()> { }), ) .await?; - assert!( - matches!(claim_result, SubcommandReturnValue::Empty), - "Expected Empty return value for public vault claim" - ); let sender_balance_after_claim = ctx.sequencer_client().get_account_balance(sender).await?; let recipient_balance_after_claim = ctx @@ -138,9 +130,9 @@ async fn private_transfer_and_private_claim() -> Result<()> { assert!( matches!( transfer_result, - SubcommandReturnValue::PrivacyPreservingTransfer { .. } + SubcommandReturnValue::TransactionExecuted { .. } ), - "Expected PrivacyPreservingTransfer return value for private vault transfer" + "Expected TransactionExecuted return value for private vault transfer" ); let sender_balance_after_transfer = ctx @@ -179,9 +171,9 @@ async fn private_transfer_and_private_claim() -> Result<()> { assert!( matches!( claim_result, - SubcommandReturnValue::PrivacyPreservingTransfer { .. } + SubcommandReturnValue::TransactionExecuted { .. } ), - "Expected PrivacyPreservingTransfer return value for private vault claim" + "Expected TransactionExecuted return value for private vault claim" ); let sender_balance_after_claim = ctx diff --git a/integration_tests/tests/wallet_ffi.rs b/integration_tests/tests/wallet_ffi.rs index 0ef53592..a19bc355 100644 --- a/integration_tests/tests/wallet_ffi.rs +++ b/integration_tests/tests/wallet_ffi.rs @@ -16,6 +16,7 @@ use std::{ ffi::{CStr, CString, c_char}, io::Write as _, path::Path, + str::FromStr as _, time::Duration, }; @@ -27,12 +28,13 @@ use lee::{ }; use lee_core::program::DEFAULT_PROGRAM_ID; use log::info; -use tempfile::tempdir; use wallet::{account::HumanReadableAccount, program_facades::vault::Vault}; use wallet_ffi::{ - FfiAccount, FfiAccountIdentity, FfiAccountList, FfiBytes32, FfiPrivateAccountKeys, - FfiProgramId, FfiPublicAccountKey, FfiTransferResult, FfiU128, WalletHandle, error, + FfiAccount, FfiAccountIdWithPrivacy, FfiAccountIdentity, FfiAccountList, FfiBytes32, + FfiPrivateAccountKeys, FfiProgramId, FfiPublicAccountKey, FfiTransferResult, FfiU128, + WalletHandle, error, generic_transaction::{FfiProgramWithDependencies, FfiTransactionResult}, + label::{AccountIdResolvedFromLabel, LabelAvailability, LabelList}, wallet::FfiCreateWalletOutput, }; @@ -40,12 +42,14 @@ unsafe extern "C" { fn wallet_ffi_create_new( config_path: *const c_char, storage_path: *const c_char, + metrics_path: *const c_char, password: *const c_char, ) -> FfiCreateWalletOutput; fn wallet_ffi_open( config_path: *const c_char, storage_path: *const c_char, + metrics_path: *const c_char, ) -> *mut WalletHandle; fn wallet_ffi_destroy(handle: *mut WalletHandle); @@ -166,13 +170,13 @@ unsafe extern "C" { fn wallet_ffi_free_transfer_result(result: *mut FfiTransferResult); - fn wallet_ffi_bridge_withdraw( - handle: *mut WalletHandle, - from: *const FfiBytes32, - amount: u64, - bedrock_account_pk: *const FfiBytes32, - out_result: *mut FfiTransferResult, - ) -> error::WalletFfiError; + // fn wallet_ffi_bridge_withdraw( + // handle: *mut WalletHandle, + // from: *const FfiBytes32, + // amount: u64, + // bedrock_account_pk: *const FfiBytes32, + // out_result: *mut FfiTransferResult, + // ) -> error::WalletFfiError; fn wallet_ffi_get_vault_balance( handle: *mut WalletHandle, @@ -257,6 +261,29 @@ unsafe extern "C" { fn wallet_ffi_free_transaction_result(result: *mut FfiTransactionResult); fn wallet_ffi_free_account_identity(account_identity: *mut FfiAccountIdentity); + + fn wallet_ffi_check_label_available( + handle: *mut WalletHandle, + label: *const c_char, + ) -> LabelAvailability; + + fn wallet_ffi_add_label( + handle: *mut WalletHandle, + label: *const c_char, + account_id_with_privacy: FfiAccountIdWithPrivacy, + ) -> error::WalletFfiError; + + fn wallet_ffi_resolve_label( + handle: *mut WalletHandle, + label: *const c_char, + ) -> AccountIdResolvedFromLabel; + + fn wallet_ffi_get_all_labels_for_account( + handle: *mut WalletHandle, + account_id_with_privacy: FfiAccountIdWithPrivacy, + ) -> LabelList; + + fn wallet_ffi_free_label_list(label_list: *mut LabelList) -> error::WalletFfiError; } fn new_wallet_ffi_with_test_context_config( @@ -265,6 +292,7 @@ fn new_wallet_ffi_with_test_context_config( ) -> Result { let config_path = home.join("wallet_config.json"); let storage_path = home.join("storage.json"); + let metrics_path = home.join("metrics.json"); let mut config = ctx.ctx().wallet().config().to_owned(); if let Some(config_overrides) = ctx.ctx().wallet().config_overrides().clone() { config.apply_overrides(config_overrides); @@ -281,12 +309,14 @@ fn new_wallet_ffi_with_test_context_config( let config_path = CString::new(config_path.to_str().unwrap())?; let storage_path = CString::new(storage_path.to_str().unwrap())?; + let metrics_path = CString::new(metrics_path.to_str().unwrap())?; let password = CString::new(ctx.ctx().wallet_password())?; let create_wallet_result = unsafe { wallet_ffi_create_new( config_path.as_ptr(), storage_path.as_ptr(), + metrics_path.as_ptr(), password.as_ptr(), ) }; @@ -340,47 +370,37 @@ fn new_wallet_ffi_with_test_context_config( Ok(create_wallet_result) } -fn new_wallet_ffi_with_default_config(password: &str) -> Result { - let tempdir = tempdir()?; - let config_path = tempdir.path().join("wallet_config.json"); - let storage_path = tempdir.path().join("storage.json"); - let config_path_c = CString::new(config_path.to_str().unwrap())?; - let storage_path_c = CString::new(storage_path.to_str().unwrap())?; - let password = CString::new(password)?; - - let create_wallet_result = unsafe { - wallet_ffi_create_new( - config_path_c.as_ptr(), - storage_path_c.as_ptr(), - password.as_ptr(), - ) - }; - - Ok(create_wallet_result) -} - fn load_existing_ffi_wallet(home: &Path) -> Result<*mut WalletHandle> { let config_path = home.join("wallet_config.json"); let storage_path = home.join("storage.json"); + let metrics_path = home.join("metrics.json"); let config_path = CString::new(config_path.to_str().unwrap())?; let storage_path = CString::new(storage_path.to_str().unwrap())?; + let metrics_path = CString::new(metrics_path.to_str().unwrap())?; - Ok(unsafe { wallet_ffi_open(config_path.as_ptr(), storage_path.as_ptr()) }) + Ok(unsafe { + wallet_ffi_open( + config_path.as_ptr(), + storage_path.as_ptr(), + metrics_path.as_ptr(), + ) + }) } #[test] fn wallet_ffi_create_public_accounts() -> Result<()> { - let password = "password_for_tests"; + let ctx = BlockingTestContext::new()?; let n_accounts = 10; // Create `n_accounts` public accounts with wallet FFI let new_public_account_ids_ffi = unsafe { let mut account_ids = Vec::new(); + let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, mnemonic: _, - } = new_wallet_ffi_with_default_config(password)?; + } = new_wallet_ffi_with_test_context_config(&ctx, home.path())?; for _ in 0..n_accounts { let mut out_account_id = FfiBytes32::from_bytes([0; 32]); wallet_ffi_create_account_public(wallet_ffi_handle, &raw mut out_account_id).unwrap(); @@ -410,16 +430,17 @@ fn wallet_ffi_create_public_accounts() -> Result<()> { #[test] fn wallet_ffi_create_private_accounts() -> Result<()> { - let password = "password_for_tests"; + let ctx = BlockingTestContext::new()?; let n_accounts = 10; // Create `n_accounts` receiving keys with wallet FFI let new_npks_ffi = unsafe { let mut npks = Vec::new(); + let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, mnemonic: _, - } = new_wallet_ffi_with_default_config(password)?; + } = new_wallet_ffi_with_test_context_config(&ctx, home.path())?; for _ in 0..n_accounts { let mut out_keys = FfiPrivateAccountKeys::default(); wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys).unwrap(); @@ -484,14 +505,14 @@ fn wallet_ffi_save_and_load_persistent_storage() -> Result<()> { #[test] fn test_wallet_ffi_list_accounts() -> Result<()> { - let password = "password_for_tests"; - + let ctx = BlockingTestContext::new()?; // Create the wallet FFI and track which account IDs were created as public/private let (wallet_ffi_handle, created_public_ids) = unsafe { + let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: handle, mnemonic: _, - } = new_wallet_ffi_with_default_config(password)?; + } = new_wallet_ffi_with_test_context_config(&ctx, home.path())?; let mut public_ids: Vec<[u8; 32]> = Vec::new(); // Create 5 public accounts and 5 receiving keys @@ -986,7 +1007,11 @@ fn test_wallet_ffi_transfer_shielded() -> Result<()> { let (to, to_keys) = unsafe { let mut out_keys = FfiPrivateAccountKeys::default(); wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys).unwrap(); - let account_id = lee::AccountId::for_regular_private_account(&out_keys.npk(), 0_u128); + let account_id = lee::AccountId::for_regular_private_account( + &out_keys.npk(), + &out_keys.vpk().unwrap(), + 0_u128, + ); let to: FfiBytes32 = account_id.into(); (to, out_keys) }; @@ -1129,7 +1154,11 @@ fn test_wallet_ffi_transfer_private() -> Result<()> { let (to, to_keys) = unsafe { let mut out_keys = FfiPrivateAccountKeys::default(); wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys).unwrap(); - let account_id = lee::AccountId::for_regular_private_account(&out_keys.npk(), 0_u128); + let account_id = lee::AccountId::for_regular_private_account( + &out_keys.npk(), + &out_keys.vpk().unwrap(), + 0_u128, + ); let to: FfiBytes32 = account_id.into(); (to, out_keys) }; @@ -1210,7 +1239,11 @@ fn restore_keys_from_seed_ffi() -> Result<()> { let (private_account_id_1, private_account_1_keys) = unsafe { let mut out_keys = FfiPrivateAccountKeys::default(); wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys).unwrap(); - let account_id = lee::AccountId::for_regular_private_account(&out_keys.npk(), 0_u128); + let account_id = lee::AccountId::for_regular_private_account( + &out_keys.npk(), + &out_keys.vpk().unwrap(), + 0_u128, + ); let to: FfiBytes32 = account_id.into(); (to, out_keys) }; @@ -1218,7 +1251,11 @@ fn restore_keys_from_seed_ffi() -> Result<()> { let (private_account_id_2, private_account_2_keys) = unsafe { let mut out_keys = FfiPrivateAccountKeys::default(); wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys).unwrap(); - let account_id = lee::AccountId::for_regular_private_account(&out_keys.npk(), 0_u128); + let account_id = lee::AccountId::for_regular_private_account( + &out_keys.npk(), + &out_keys.vpk().unwrap(), + 0_u128, + ); let to: FfiBytes32 = account_id.into(); (to, out_keys) }; @@ -1484,68 +1521,68 @@ fn restore_keys_from_seed_ffi() -> Result<()> { Ok(()) } -#[test] -fn test_wallet_ffi_bridge_withdraw() -> Result<()> { - let ctx = BlockingTestContext::new()?; - let home = tempfile::tempdir()?; - let FfiCreateWalletOutput { - wallet: wallet_ffi_handle, - mnemonic: _, - } = new_wallet_ffi_with_test_context_config(&ctx, home.path())?; - let from: FfiBytes32 = ctx.ctx().existing_public_accounts()[0].into(); - let bridge_account: FfiBytes32 = system_accounts::bridge_account_id().into(); - let bedrock_account_pk = FfiBytes32::from_bytes([0x42; 32]); - let amount = 100_u64; +// #[test] +// fn test_wallet_ffi_bridge_withdraw() -> Result<()> { +// let ctx = BlockingTestContext::new()?; +// let home = tempfile::tempdir()?; +// let FfiCreateWalletOutput { +// wallet: wallet_ffi_handle, +// mnemonic: _, +// } = new_wallet_ffi_with_test_context_config(&ctx, home.path())?; +// let from: FfiBytes32 = ctx.ctx().existing_public_accounts()[0].into(); +// let bridge_account: FfiBytes32 = system_accounts::bridge_account_id().into(); +// let bedrock_account_pk = FfiBytes32::from_bytes([0x42; 32]); +// let amount = 100_u64; - let mut transfer_result = FfiTransferResult::default(); - unsafe { - wallet_ffi_bridge_withdraw( - wallet_ffi_handle, - &raw const from, - amount, - &raw const bedrock_account_pk, - &raw mut transfer_result, - ) - .unwrap(); - } +// let mut transfer_result = FfiTransferResult::default(); +// unsafe { +// wallet_ffi_bridge_withdraw( +// wallet_ffi_handle, +// &raw const from, +// amount, +// &raw const bedrock_account_pk, +// &raw mut transfer_result, +// ) +// .unwrap(); +// } - info!("Waiting for next block creation"); - std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); +// info!("Waiting for next block creation"); +// std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); - let from_balance = unsafe { - let mut out_balance: [u8; 16] = [0; 16]; - wallet_ffi_get_balance( - wallet_ffi_handle, - &raw const from, - true, - &raw mut out_balance, - ) - .unwrap(); - u128::from_le_bytes(out_balance) - }; +// let from_balance = unsafe { +// let mut out_balance: [u8; 16] = [0; 16]; +// wallet_ffi_get_balance( +// wallet_ffi_handle, +// &raw const from, +// true, +// &raw mut out_balance, +// ) +// .unwrap(); +// u128::from_le_bytes(out_balance) +// }; - let bridge_balance = unsafe { - let mut out_balance: [u8; 16] = [0; 16]; - wallet_ffi_get_balance( - wallet_ffi_handle, - &raw const bridge_account, - true, - &raw mut out_balance, - ) - .unwrap(); - u128::from_le_bytes(out_balance) - }; +// let bridge_balance = unsafe { +// let mut out_balance: [u8; 16] = [0; 16]; +// wallet_ffi_get_balance( +// wallet_ffi_handle, +// &raw const bridge_account, +// true, +// &raw mut out_balance, +// ) +// .unwrap(); +// u128::from_le_bytes(out_balance) +// }; - assert_eq!(from_balance, 9900); - assert_eq!(bridge_balance, 1_000_100); +// assert_eq!(from_balance, 9900); +// assert_eq!(bridge_balance, 1_000_100); - unsafe { - wallet_ffi_free_transfer_result(&raw mut transfer_result); - wallet_ffi_destroy(wallet_ffi_handle); - } +// unsafe { +// wallet_ffi_free_transfer_result(&raw mut transfer_result); +// wallet_ffi_destroy(wallet_ffi_handle); +// } - Ok(()) -} +// Ok(()) +// } #[test] fn test_wallet_ffi_transfer_generic_public() -> Result<()> { @@ -1926,3 +1963,125 @@ fn test_wallet_ffi_vault_balance_and_claim_private() -> Result<()> { Ok(()) } + +#[test] +fn test_wallet_ffi_single_label() -> Result<()> { + let ctx = BlockingTestContext::new()?; + let home = tempfile::tempdir()?; + let FfiCreateWalletOutput { + wallet: wallet_ffi_handle, + mnemonic: _, + } = new_wallet_ffi_with_test_context_config(&ctx, home.path())?; + + let mut out_account_id_1 = FfiBytes32::from_bytes([0; 32]); + unsafe { + wallet_ffi_create_account_public(wallet_ffi_handle, &raw mut out_account_id_1).unwrap(); + } + + info!("Waiting for next block creation"); + std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); + + let lab_1 = CString::from_str("LABEL1").unwrap().into_raw(); + + let lab_1_availability = unsafe { wallet_ffi_check_label_available(wallet_ffi_handle, lab_1) }; + + assert_eq!(lab_1_availability.error, error::WalletFfiError::Success); + assert!(lab_1_availability.is_available); + + let acc_1_id_with_privacy = FfiAccountIdWithPrivacy { + account_id: out_account_id_1, + is_private: false, + }; + + let err = unsafe { wallet_ffi_add_label(wallet_ffi_handle, lab_1, acc_1_id_with_privacy) }; + + assert_eq!(err, error::WalletFfiError::Success); + + let lab_1_availability = unsafe { wallet_ffi_check_label_available(wallet_ffi_handle, lab_1) }; + + assert!(!lab_1_availability.is_available); + + let acc_resolved = unsafe { wallet_ffi_resolve_label(wallet_ffi_handle, lab_1) }; + + assert_eq!(acc_resolved.account_id, acc_1_id_with_privacy); + + unsafe { + wallet_ffi_free_string(lab_1); + wallet_ffi_destroy(wallet_ffi_handle); + } + + Ok(()) +} + +#[test] +fn test_wallet_ffi_more_labels() -> Result<()> { + let ctx = BlockingTestContext::new()?; + let home = tempfile::tempdir()?; + let FfiCreateWalletOutput { + wallet: wallet_ffi_handle, + mnemonic: _, + } = new_wallet_ffi_with_test_context_config(&ctx, home.path())?; + + let mut out_account_id_1 = FfiBytes32::from_bytes([0; 32]); + unsafe { + wallet_ffi_create_account_public(wallet_ffi_handle, &raw mut out_account_id_1).unwrap(); + } + + info!("Waiting for next block creation"); + std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); + + let lab_1 = CString::from_str("LABEL1").unwrap().into_raw(); + let lab_2 = CString::from_str("LABEL2").unwrap().into_raw(); + let lab_3 = CString::from_str("LABEL3").unwrap().into_raw(); + + let acc_1_id_with_privacy = FfiAccountIdWithPrivacy { + account_id: out_account_id_1, + is_private: false, + }; + + let err = unsafe { wallet_ffi_add_label(wallet_ffi_handle, lab_1, acc_1_id_with_privacy) }; + + assert_eq!(err, error::WalletFfiError::Success); + + let err = unsafe { wallet_ffi_add_label(wallet_ffi_handle, lab_2, acc_1_id_with_privacy) }; + + assert_eq!(err, error::WalletFfiError::Success); + + let err = unsafe { wallet_ffi_add_label(wallet_ffi_handle, lab_3, acc_1_id_with_privacy) }; + + assert_eq!(err, error::WalletFfiError::Success); + + let mut label_list_for_out_acc = + unsafe { wallet_ffi_get_all_labels_for_account(wallet_ffi_handle, acc_1_id_with_privacy) }; + + assert_eq!(label_list_for_out_acc.error, error::WalletFfiError::Success); + assert_eq!(label_list_for_out_acc.labels_size, 3); + + let lab_ref_1 = unsafe { &*label_list_for_out_acc.labels_data.add(0) }; + let lab_ref_c_str_1 = unsafe { CStr::from_ptr(*lab_ref_1) }; + + assert_eq!(lab_ref_c_str_1.to_str().unwrap(), "LABEL1"); + + let lab_ref_2 = unsafe { &*label_list_for_out_acc.labels_data.add(1) }; + let lab_ref_c_str_2 = unsafe { CStr::from_ptr(*lab_ref_2) }; + + assert_eq!(lab_ref_c_str_2.to_str().unwrap(), "LABEL2"); + + let lab_ref_3 = unsafe { &*label_list_for_out_acc.labels_data.add(2) }; + let lab_ref_c_str_3 = unsafe { CStr::from_ptr(*lab_ref_3) }; + + assert_eq!(lab_ref_c_str_3.to_str().unwrap(), "LABEL3"); + + let err = unsafe { wallet_ffi_free_label_list(&raw mut label_list_for_out_acc) }; + + assert_eq!(err, error::WalletFfiError::Success); + + unsafe { + wallet_ffi_free_string(lab_1); + wallet_ffi_free_string(lab_2); + wallet_ffi_free_string(lab_3); + wallet_ffi_destroy(wallet_ffi_handle); + } + + Ok(()) +} diff --git a/lee/key_protocol/src/key_management/ephemeral_key_holder.rs b/lee/key_protocol/src/key_management/ephemeral_key_holder.rs index a53ae47c..9bc81391 100644 --- a/lee/key_protocol/src/key_management/ephemeral_key_holder.rs +++ b/lee/key_protocol/src/key_management/ephemeral_key_holder.rs @@ -48,14 +48,3 @@ impl EphemeralKeyHolder { self.shared_secret } } - -/// Encapsulates a fresh shared secret toward `vpk` and returns `(shared_secret, ciphertext)`. -/// -/// Used when the local side is acting as an "ephemeral receiver" — i.e. generating a -/// one-sided encryption that only the holder of the VSK can decrypt. -#[must_use] -pub fn produce_one_sided_shared_secret_receiver( - vpk: &ViewingPublicKey, -) -> (SharedSecretKey, EphemeralPublicKey) { - SharedSecretKey::encapsulate(vpk) -} diff --git a/lee/key_protocol/src/key_management/group_key_holder.rs b/lee/key_protocol/src/key_management/group_key_holder.rs index 7fb24713..1aef6c91 100644 --- a/lee/key_protocol/src/key_management/group_key_holder.rs +++ b/lee/key_protocol/src/key_management/group_key_holder.rs @@ -1,7 +1,7 @@ use aes_gcm::{Aes256Gcm, KeyInit as _, aead::Aead as _}; use lee_core::{ - SharedSecretKey, - encryption::{EphemeralPublicKey, ViewingPublicKey}, + Identifier, SharedSecretKey, + encryption::{EphemeralPublicKey, ML_KEM_768_CIPHERTEXT_LEN, ViewingPublicKey}, program::{PdaSeed, ProgramId}, }; use rand::{RngCore as _, rngs::OsRng}; @@ -146,11 +146,28 @@ impl GroupKeyHolder { SecretSpendingKey(hasher.finalize_fixed().into()).produce_private_key_holder(None) } + /// Derive keys for a shared regular account from its `identifier`. + /// + /// Computes the derivation seed via the `SharedAccountTag` domain separator, then delegates + /// to [`Self::derive_keys_for_shared_account`]. + #[must_use] + pub fn derive_regular_shared_account_keys_from_identifier( + &self, + identifier: Identifier, + ) -> PrivateKeyHolder { + const PREFIX: &[u8; 32] = b"/LEE/v0.3/SharedAccountTag/\x00\x00\x00\x00\x00"; + let mut hasher = sha2::Sha256::new(); + hasher.update(PREFIX); + hasher.update(identifier.to_le_bytes()); + let derivation_seed: [u8; 32] = hasher.finalize().into(); + self.derive_keys_for_shared_account(&derivation_seed) + } + /// Encrypts this holder's GMS under the recipient's [`SealingPublicKey`]. /// /// Uses ML-KEM-768 encapsulation to derive a shared secret, then AES-256-GCM to encrypt /// the payload. The returned bytes are - /// `kem_ciphertext (1088) || nonce (12) || ciphertext+tag (48)` = 1148 bytes. + /// `kem_ciphertext (ML_KEM_768_CIPHERTEXT_LEN) || nonce (12) || ciphertext+tag (48)`. /// /// Each call generates a fresh KEM encapsulation, so two seals of the same holder produce /// different ciphertexts. @@ -170,7 +187,7 @@ impl GroupKeyHolder { .encrypt(&nonce, self.gms.as_ref()) .expect("AES-GCM encryption should not fail with valid key/nonce"); - let capacity = 1088_usize + let capacity = ML_KEM_768_CIPHERTEXT_LEN .checked_add(12) .and_then(|n| n.checked_add(ciphertext.len())) .expect("seal capacity overflow"); @@ -186,21 +203,21 @@ impl GroupKeyHolder { /// Returns `Err` if the ciphertext is too short or the AES-GCM authentication tag /// doesn't verify (wrong key or tampered data). pub fn unseal(sealed: &[u8], own_key: &SealingSecretKey) -> Result { - // kem_ciphertext (1088) + nonce (12) = header, then AES-GCM tag (16) minimum. - const KEM_CT_LEN: usize = 1088; - const HEADER_LEN: usize = KEM_CT_LEN + 12; + // kem_ciphertext (ML_KEM_768_CIPHERTEXT_LEN) + nonce (12) = header, then AES-GCM tag (16) + // minimum. + const HEADER_LEN: usize = ML_KEM_768_CIPHERTEXT_LEN + 12; const MIN_LEN: usize = HEADER_LEN + 16; if sealed.len() < MIN_LEN { return Err(SealError::TooShort); } - let kem_ct = EphemeralPublicKey(sealed[..KEM_CT_LEN].to_vec()); - let nonce = aes_gcm::Nonce::from_slice(&sealed[KEM_CT_LEN..HEADER_LEN]); + let kem_ct = EphemeralPublicKey(sealed[..ML_KEM_768_CIPHERTEXT_LEN].to_vec()); + let nonce = aes_gcm::Nonce::from_slice(&sealed[ML_KEM_768_CIPHERTEXT_LEN..HEADER_LEN]); let ciphertext = &sealed[HEADER_LEN..]; let shared = SharedSecretKey::decapsulate(&kem_ct, &own_key.d, &own_key.z) - .expect("key_protocol::group_key_holder::GroupKeyHolder::unseal: KEM_CT_LEN guarantees exactly 1088 bytes"); + .expect("key_protocol::group_key_holder::GroupKeyHolder::unseal: ML_KEM_768_CIPHERTEXT_LEN guarantees exactly 1088 bytes"); let aes_key = Self::seal_kdf(&shared); let cipher = Aes256Gcm::new(&aes_key.into()); @@ -334,10 +351,10 @@ mod tests { let program_id: ProgramId = [9; 8]; let holder = GroupKeyHolder::from_gms(gms); - let npk = holder - .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) - .generate_nullifier_public_key(); - let account_id = AccountId::for_private_pda(&program_id, &seed, &npk, u128::MAX); + let keys = holder.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed); + let npk = keys.generate_nullifier_public_key(); + let vpk = keys.generate_viewing_public_key(); + let account_id = AccountId::for_private_pda(&program_id, &seed, &npk, &vpk, u128::MAX); let expected_npk = NullifierPublicKey([ 136, 176, 234, 71, 208, 8, 143, 142, 126, 155, 132, 18, 71, 27, 88, 56, 100, 90, 79, @@ -346,7 +363,7 @@ mod tests { // AccountId is derived from (program_id, seed, npk), so it changes when npk changes. // We verify npk is pinned, and AccountId is deterministically derived from it. let expected_account_id = - AccountId::for_private_pda(&program_id, &seed, &expected_npk, u128::MAX); + AccountId::for_private_pda(&program_id, &seed, &expected_npk, &vpk, u128::MAX); assert_eq!(npk, expected_npk); assert_eq!(account_id, expected_account_id); @@ -543,13 +560,16 @@ mod tests { let bob_holder = GroupKeyHolder::unseal(&sealed, &bob_vsk).expect("Bob should unseal the GMS"); - let bob_npk = bob_holder - .derive_keys_for_pda(&TEST_PROGRAM_ID, &pda_seed) - .generate_nullifier_public_key(); + let bob_group_keys = bob_holder.derive_keys_for_pda(&TEST_PROGRAM_ID, &pda_seed); + let bob_npk = bob_group_keys.generate_nullifier_public_key(); assert_eq!(alice_npk, bob_npk); - let alice_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &alice_npk, 0); - let bob_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &bob_npk, 0); + let alice_vpk = alice_keys.generate_viewing_public_key(); + let bob_group_vpk = bob_group_keys.generate_viewing_public_key(); + let alice_account_id = + AccountId::for_private_pda(&program_id, &pda_seed, &alice_npk, &alice_vpk, 0); + let bob_account_id = + AccountId::for_private_pda(&program_id, &pda_seed, &bob_npk, &bob_group_vpk, 0); assert_eq!(alice_account_id, bob_account_id); } diff --git a/lee/key_protocol/src/key_management/key_tree/chain_index.rs b/lee/key_protocol/src/key_management/key_tree/chain_index.rs index b22dc779..6ea2e8a1 100644 --- a/lee/key_protocol/src/key_management/key_tree/chain_index.rs +++ b/lee/key_protocol/src/key_management/key_tree/chain_index.rs @@ -139,7 +139,7 @@ impl ChainIndex { .map(|item| Self(item.into_iter().copied().collect())) } - pub fn chain_ids_at_depth(depth: usize) -> impl Iterator { + fn collect_chain_ids_at_depth(depth: usize) -> Vec { let mut stack = vec![Self(vec![0; depth])]; let mut cumulative_stack = vec![Self(vec![0; depth])]; @@ -152,23 +152,18 @@ impl ChainIndex { } } - cumulative_stack.into_iter().unique() + cumulative_stack + } + + pub fn chain_ids_at_depth(depth: usize) -> impl Iterator { + Self::collect_chain_ids_at_depth(depth).into_iter().unique() } pub fn chain_ids_at_depth_rev(depth: usize) -> impl Iterator { - let mut stack = vec![Self(vec![0; depth])]; - let mut cumulative_stack = vec![Self(vec![0; depth])]; - - while let Some(top_id) = stack.pop() { - if let Some(collapsed_id) = top_id.collapse_back() { - for id in collapsed_id.shuffle_iter() { - stack.push(id.clone()); - cumulative_stack.push(id); - } - } - } - - cumulative_stack.into_iter().rev().unique() + Self::collect_chain_ids_at_depth(depth) + .into_iter() + .rev() + .unique() } } diff --git a/lee/key_protocol/src/key_management/key_tree/keys_private.rs b/lee/key_protocol/src/key_management/key_tree/keys_private.rs index 5a27be79..8165e808 100644 --- a/lee/key_protocol/src/key_management/key_tree/keys_private.rs +++ b/lee/key_protocol/src/key_management/key_tree/keys_private.rs @@ -6,7 +6,7 @@ use sha2::Digest as _; use crate::key_management::{ KeyChain, - key_tree::traits::KeyTreeNode, + key_tree::{split_hash, traits::KeyTreeNode}, secret_holders::{PrivateKeyHolder, SecretSpendingKey}, }; @@ -23,38 +23,11 @@ impl ChildKeysPrivate { #[must_use] pub fn root(seed: [u8; 64]) -> Self { let hash_value = hmac_sha512::HMAC::mac(seed, b"LEE_master_priv"); + let (first, ccc) = split_hash(&hash_value); - let ssk = SecretSpendingKey( - *hash_value - .first_chunk::<32>() - .expect("hash_value is 64 bytes, must be safe to get first 32"), - ); - let ccc = *hash_value - .last_chunk::<32>() - .expect("hash_value is 64 bytes, must be safe to get last 32"); + let ssk = SecretSpendingKey(first); - let nsk = ssk.generate_nullifier_secret_key(None); - let vsk = ssk.generate_viewing_secret_seed_key(None); - - let npk = NullifierPublicKey::from(&nsk); - let vpk = ViewingPublicKey::from(&vsk); - - Self { - value: ( - KeyChain { - secret_spending_key: ssk, - nullifier_public_key: npk, - viewing_public_key: vpk, - private_key_holder: PrivateKeyHolder { - nullifier_secret_key: nsk, - viewing_secret_key: vsk, - }, - }, - BTreeMap::from_iter([(PrivateAccountKind::Regular(0), lee::Account::default())]), - ), - ccc, - cci: None, - } + Self::from_ssk_and_ccc(ssk, ccc, None) } #[must_use] @@ -77,18 +50,16 @@ impl ChildKeysPrivate { input.extend_from_slice(&cci.to_be_bytes()); let hash_value = hmac_sha512::HMAC::mac(input, self.ccc); + let (first, ccc) = split_hash(&hash_value); - let ssk = SecretSpendingKey( - *hash_value - .first_chunk::<32>() - .expect("hash_value is 64 bytes, must be safe to get first 32"), - ); - let ccc = *hash_value - .last_chunk::<32>() - .expect("hash_value is 64 bytes, must be safe to get last 32"); + let ssk = SecretSpendingKey(first); - let nsk = ssk.generate_nullifier_secret_key(Some(cci)); - let vsk = ssk.generate_viewing_secret_seed_key(Some(cci)); + Self::from_ssk_and_ccc(ssk, ccc, Some(cci)) + } + + fn from_ssk_and_ccc(ssk: SecretSpendingKey, ccc: [u8; 32], cci: Option) -> Self { + let nsk = ssk.generate_nullifier_secret_key(cci); + let vsk = ssk.generate_viewing_secret_seed_key(cci); let npk = NullifierPublicKey::from(&nsk); let vpk = ViewingPublicKey::from(&vsk); @@ -107,7 +78,7 @@ impl ChildKeysPrivate { BTreeMap::from_iter([(PrivateAccountKind::Regular(0), lee::Account::default())]), ), ccc, - cci: Some(cci), + cci, } } } @@ -123,10 +94,11 @@ impl KeyTreeNode for ChildKeysPrivate { fn account_ids(&self) -> impl Iterator { let npk = self.value.0.nullifier_public_key; + let vpk = self.value.0.viewing_public_key.clone(); self.value .1 .keys() - .map(move |kind| lee::AccountId::for_private_account(&npk, kind)) + .map(move |kind| lee::AccountId::for_private_account(&npk, &vpk, kind)) } } @@ -137,16 +109,16 @@ mod tests { use super::*; use crate::key_management::{self, secret_holders::ViewingSecretKey}; + const SEED: [u8; 64] = [ + 252, 56, 204, 83, 232, 123, 209, 188, 187, 167, 39, 213, 71, 39, 58, 65, 125, 134, 255, 49, + 43, 108, 92, 53, 173, 164, 94, 142, 150, 74, 21, 163, 43, 144, 226, 87, 199, 18, 129, 223, + 176, 198, 5, 150, 157, 70, 210, 254, 14, 105, 89, 191, 246, 27, 52, 170, 56, 114, 39, 38, + 118, 197, 205, 225, + ]; + #[test] fn master_key_generation() { - let seed: [u8; 64] = [ - 252, 56, 204, 83, 232, 123, 209, 188, 187, 167, 39, 213, 71, 39, 58, 65, 125, 134, 255, - 49, 43, 108, 92, 53, 173, 164, 94, 142, 150, 74, 21, 163, 43, 144, 226, 87, 199, 18, - 129, 223, 176, 198, 5, 150, 157, 70, 210, 254, 14, 105, 89, 191, 246, 27, 52, 170, 56, - 114, 39, 38, 118, 197, 205, 225, - ]; - - let keys = ChildKeysPrivate::root(seed); + let keys = ChildKeysPrivate::root(SEED); let expected_ssk = key_management::secret_holders::SecretSpendingKey([ 246, 79, 26, 124, 135, 95, 52, 51, 201, 27, 48, 194, 2, 144, 51, 219, 245, 128, 139, @@ -179,6 +151,7 @@ mod tests { ], ); + // Length matches MlKem768EncapsulationKey::LEN. let expected_vpk: [u8; 1184] = [ 127, 229, 162, 212, 104, 117, 4, 150, 192, 103, 122, 195, 14, 35, 12, 60, 52, 23, 220, 150, 100, 203, 34, 34, 127, 232, 156, 43, 218, 109, 6, 160, 67, 35, 210, 194, 25, 181, @@ -253,14 +226,7 @@ mod tests { #[test] fn child_keys_generation() { - let seed: [u8; 64] = [ - 252, 56, 204, 83, 232, 123, 209, 188, 187, 167, 39, 213, 71, 39, 58, 65, 125, 134, 255, - 49, 43, 108, 92, 53, 173, 164, 94, 142, 150, 74, 21, 163, 43, 144, 226, 87, 199, 18, - 129, 223, 176, 198, 5, 150, 157, 70, 210, 254, 14, 105, 89, 191, 246, 27, 52, 170, 56, - 114, 39, 38, 118, 197, 205, 225, - ]; - - let root_node = ChildKeysPrivate::root(seed); + let root_node = ChildKeysPrivate::root(SEED); let child_node = ChildKeysPrivate::nth_child(&root_node, 42_u32); let expected_ssk = key_management::secret_holders::SecretSpendingKey([ @@ -293,6 +259,7 @@ mod tests { ], ); + // Length matches MlKem768EncapsulationKey::LEN. let expected_vpk: [u8; 1184] = [ 215, 229, 207, 120, 148, 177, 148, 197, 72, 222, 134, 3, 231, 146, 123, 226, 36, 84, 232, 179, 205, 16, 241, 142, 9, 81, 58, 54, 12, 115, 148, 182, 19, 245, 22, 203, 57, diff --git a/lee/key_protocol/src/key_management/key_tree/keys_public.rs b/lee/key_protocol/src/key_management/key_tree/keys_public.rs index 947fb83c..4caad0e7 100644 --- a/lee/key_protocol/src/key_management/key_tree/keys_public.rs +++ b/lee/key_protocol/src/key_management/key_tree/keys_public.rs @@ -1,7 +1,7 @@ use k256::elliptic_curve::PrimeField as _; use serde::{Deserialize, Serialize}; -use crate::key_management::key_tree::traits::KeyTreeNode; +use crate::key_management::key_tree::{split_hash, traits::KeyTreeNode}; #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(any(test, feature = "test_utils"), derive(PartialEq, Eq))] @@ -21,52 +21,32 @@ impl ChildKeysPublic { #[must_use] pub fn root(seed: [u8; 64]) -> Self { let hash_value = hmac_sha512::HMAC::mac(seed, "LEE_master_pub"); + let (first, cc) = split_hash(&hash_value); - let sk = lee::PrivateKey::try_new( - *hash_value - .first_chunk::<32>() - .expect("hash_value is 64 bytes, must be safe to get first 32"), - ) - .expect("Expect a valid Private Key"); - let ssk = lee::PrivateKey::tweak(sk.value()).expect("`key_protocol::key_management::keys_public::root()`: Invalid private key produced from `tweak`"); + let sk = lee::PrivateKey::try_new(first).expect("Expect a valid Private Key"); - let cc = *hash_value - .last_chunk::<32>() - .expect("hash_value is 64 bytes, must be safe to get last 32"); - let pk = lee::PublicKey::new_from_private_key(&ssk); - - Self { - sk, - ssk, - pk, - cc, - cci: None, - } + Self::from_sk_and_cc(sk, cc, None) } #[must_use] pub fn nth_child(&self, cci: u32) -> Self { let hash_value = self.compute_hash_value(cci); + let (first, cc) = split_hash(&hash_value); - let lhs = k256::Scalar::from_repr( - (*hash_value - .first_chunk::<32>() - .expect("hash_value is 64 bytes, must be safe to get first 32")) - .into(), - ) - .expect("Expect a valid k256 scalar"); + let lhs = k256::Scalar::from_repr(first.into()).expect("Expect a valid k256 scalar"); let rhs = k256::Scalar::from_repr((*self.sk.value()).into()).expect("Expect a valid k256 scalar"); let sk = lee::PrivateKey::try_new(lhs.add(&rhs).to_bytes().into()) .expect("Expect a valid private key"); - let ssk = lee::PrivateKey::tweak(sk.value()).expect("`key_protocol::key_management::keys_public::nth_child()`: Invalid private key produced from `tweak`"); - - let cc = *hash_value - .last_chunk::<32>() - .expect("hash_value is 64 bytes, must be safe to get last 32"); + Self::from_sk_and_cc(sk, cc, Some(cci)) + } + fn from_sk_and_cc(sk: lee::PrivateKey, cc: [u8; 32], cci: Option) -> Self { + let ssk = lee::PrivateKey::tweak(sk.value()).expect( + "`key_protocol::key_management::keys_public::ChildKeysPublic`: Invalid private key produced from `tweak`", + ); let pk = lee::PublicKey::new_from_private_key(&ssk); Self { @@ -74,7 +54,7 @@ impl ChildKeysPublic { ssk, pk, cc, - cci: Some(cci), + cci, } } @@ -128,15 +108,16 @@ mod tests { use super::*; + const SEED: [u8; 64] = [ + 88, 189, 37, 237, 199, 125, 151, 226, 69, 153, 165, 113, 191, 69, 188, 221, 9, 34, 173, + 134, 61, 109, 34, 103, 121, 39, 237, 14, 107, 194, 24, 194, 191, 14, 237, 185, 12, 87, 22, + 227, 38, 71, 17, 144, 251, 118, 217, 115, 33, 222, 201, 61, 203, 246, 121, 214, 6, 187, + 148, 92, 44, 253, 210, 37, + ]; + #[test] fn master_keys_generation() { - let seed = [ - 88, 189, 37, 237, 199, 125, 151, 226, 69, 153, 165, 113, 191, 69, 188, 221, 9, 34, 173, - 134, 61, 109, 34, 103, 121, 39, 237, 14, 107, 194, 24, 194, 191, 14, 237, 185, 12, 87, - 22, 227, 38, 71, 17, 144, 251, 118, 217, 115, 33, 222, 201, 61, 203, 246, 121, 214, 6, - 187, 148, 92, 44, 253, 210, 37, - ]; - let keys = ChildKeysPublic::root(seed); + let keys = ChildKeysPublic::root(SEED); let expected_cc = [ 238, 94, 84, 154, 56, 224, 80, 218, 133, 249, 179, 222, 9, 24, 17, 252, 120, 127, 222, @@ -169,13 +150,7 @@ mod tests { #[test] fn child_keys_generation() { - let seed = [ - 88, 189, 37, 237, 199, 125, 151, 226, 69, 153, 165, 113, 191, 69, 188, 221, 9, 34, 173, - 134, 61, 109, 34, 103, 121, 39, 237, 14, 107, 194, 24, 194, 191, 14, 237, 185, 12, 87, - 22, 227, 38, 71, 17, 144, 251, 118, 217, 115, 33, 222, 201, 61, 203, 246, 121, 214, 6, - 187, 148, 92, 44, 253, 210, 37, - ]; - let root_keys = ChildKeysPublic::root(seed); + let root_keys = ChildKeysPublic::root(SEED); let cci = (2_u32).pow(31) + 13; let child_keys = ChildKeysPublic::nth_child(&root_keys, cci); diff --git a/lee/key_protocol/src/key_management/key_tree/mod.rs b/lee/key_protocol/src/key_management/key_tree/mod.rs index c15c09a5..463c757a 100644 --- a/lee/key_protocol/src/key_management/key_tree/mod.rs +++ b/lee/key_protocol/src/key_management/key_tree/mod.rs @@ -39,16 +39,7 @@ impl KeyTree { .try_into() .expect("SeedHolder seed is 64 bytes long"); - let root_keys = N::from_seed(seed_fit); - let account_id_map = root_keys - .account_ids() - .map(|id| (id, ChainIndex::root())) - .collect(); - - Self { - key_map: BTreeMap::from_iter([(ChainIndex::root(), root_keys)]), - account_id_map, - } + Self::new_from_root(N::from_seed(seed_fit)) } pub fn new_from_root(root: N) -> Self { @@ -63,6 +54,15 @@ impl KeyTree { } } + fn insert_child(&mut self, child_keys: N, chain_index: ChainIndex) -> ChainIndex { + for account_id in child_keys.account_ids() { + self.account_id_map.insert(account_id, chain_index.clone()); + } + self.key_map.insert(chain_index.clone(), child_keys); + + chain_index + } + pub fn generate_new_node(&mut self, parent_cci: &ChainIndex) -> Option { let parent_keys = self.key_map.get(parent_cci)?; let next_child_id = self @@ -71,14 +71,8 @@ impl KeyTree { let next_cci = parent_cci.nth_child(next_child_id); let child_keys = parent_keys.derive_child(next_child_id); - let account_ids = child_keys.account_ids(); - for account_id in account_ids { - self.account_id_map.insert(account_id, next_cci.clone()); - } - self.key_map.insert(next_cci.clone(), child_keys); - - Some(next_cci) + Some(self.insert_child(child_keys, next_cci)) } pub fn fill_node(&mut self, chain_index: &ChainIndex) -> Option { @@ -86,14 +80,8 @@ impl KeyTree { let child_id = *chain_index.chain().last()?; let child_keys = parent_keys.derive_child(child_id); - let account_ids = child_keys.account_ids(); - for account_id in account_ids { - self.account_id_map.insert(account_id, chain_index.clone()); - } - self.key_map.insert(chain_index.clone(), child_keys); - - Some(chain_index.clone()) + Some(self.insert_child(child_keys, chain_index.clone())) } #[must_use] @@ -200,24 +188,27 @@ impl KeyTree { } impl KeyTree { + /// Pairs `cci` with the account ID of the node stored at it. + fn account_id_for_cci(&self, cci: ChainIndex) -> Option<(lee::AccountId, ChainIndex)> { + let node = self.key_map.get(&cci)?; + let account_id = node.account_ids().next()?; + Some((account_id, cci)) + } + /// Generate a new public key node, returning the account ID and chain index. pub fn generate_new_public_node( &mut self, parent_cci: &ChainIndex, ) -> Option<(lee::AccountId, ChainIndex)> { let cci = self.generate_new_node(parent_cci)?; - let node = self.key_map.get(&cci)?; - let account_id = node.account_ids().next()?; - Some((account_id, cci)) + self.account_id_for_cci(cci) } /// Generate a new public key node using layered placement, returning the account ID and chain /// index. pub fn generate_new_public_node_layered(&mut self) -> Option<(lee::AccountId, ChainIndex)> { let cci = self.generate_new_node_layered()?; - let node = self.key_map.get(&cci)?; - let account_id = node.account_ids().next()?; - Some((account_id, cci)) + self.account_id_for_cci(cci) } /// Cleanup of non-initialized accounts in a public tree. @@ -277,6 +268,7 @@ impl KeyTree { let node = self.key_map.get(cci)?; let account_id = lee::AccountId::for_regular_private_account( &node.value.0.nullifier_public_key, + &node.value.0.viewing_public_key, identifier, ); if self.account_id_map.contains_key(&account_id) { @@ -322,6 +314,16 @@ impl KeyTree { } } +const fn split_hash(hash_value: &[u8; 64]) -> ([u8; 32], [u8; 32]) { + let first = *hash_value + .first_chunk::<32>() + .expect("hash_value is 64 bytes, must be safe to get first 32"); + let last = *hash_value + .last_chunk::<32>() + .expect("hash_value is 64 bytes, must be safe to get last 32"); + (first, last) +} + #[cfg(test)] mod tests { #![expect(clippy::shadow_unrelated, reason = "We don't care about this in tests")] @@ -339,6 +341,18 @@ mod tests { } } + #[test] + fn split_hash_splits_into_first_and_last_32_bytes() { + let mut hash_value = [0_u8; 64]; + hash_value[..32].fill(0xAA); + hash_value[32..].fill(0xBB); + + let (first, last) = split_hash(&hash_value); + + assert_eq!(first, [0xAA; 32]); + assert_eq!(last, [0xBB; 32]); + } + #[test] fn simple_key_tree() { let seed_holder = seed_holder_for_tests(); diff --git a/lee/key_protocol/src/key_management/mod.rs b/lee/key_protocol/src/key_management/mod.rs index 459badf0..3a066fd9 100644 --- a/lee/key_protocol/src/key_management/mod.rs +++ b/lee/key_protocol/src/key_management/mod.rs @@ -25,8 +25,22 @@ impl KeyChain { #[must_use] pub fn new_os_random() -> Self { // Currently dropping SeedHolder at the end of initialization. - // Now entirely sure if we need it in the future. + // Not entirely sure if we need it in the future. let seed_holder = SeedHolder::new_os_random(); + + Self::from_seed_holder(&seed_holder) + } + + #[must_use] + pub fn new_mnemonic(passphrase: &str) -> (Self, bip39::Mnemonic) { + // Currently dropping SeedHolder at the end of initialization. + // Not entirely sure if we need it in the future. + let (seed_holder, mnemonic) = SeedHolder::new_mnemonic(passphrase); + + (Self::from_seed_holder(&seed_holder), mnemonic) + } + + fn from_seed_holder(seed_holder: &SeedHolder) -> Self { let secret_spending_key = seed_holder.produce_top_secret_key_holder(); let private_key_holder = secret_spending_key.produce_private_key_holder(None); @@ -42,29 +56,6 @@ impl KeyChain { } } - #[must_use] - pub fn new_mnemonic(passphrase: &str) -> (Self, bip39::Mnemonic) { - // Currently dropping SeedHolder at the end of initialization. - // Not entirely sure if we need it in the future. - let (seed_holder, mnemonic) = SeedHolder::new_mnemonic(passphrase); - let secret_spending_key = seed_holder.produce_top_secret_key_holder(); - - let private_key_holder = secret_spending_key.produce_private_key_holder(None); - - let nullifier_public_key = private_key_holder.generate_nullifier_public_key(); - let viewing_public_key = private_key_holder.generate_viewing_public_key(); - - ( - Self { - secret_spending_key, - private_key_holder, - nullifier_public_key, - viewing_public_key, - }, - mnemonic, - ) - } - #[must_use] pub fn calculate_shared_secret_receiver( &self, diff --git a/lee/key_protocol/src/key_management/secret_holders.rs b/lee/key_protocol/src/key_management/secret_holders.rs index 7bda4ffb..b8225a4b 100644 --- a/lee/key_protocol/src/key_management/secret_holders.rs +++ b/lee/key_protocol/src/key_management/secret_holders.rs @@ -43,16 +43,7 @@ pub struct PrivateKeyHolder { impl SeedHolder { #[must_use] pub fn new_os_random() -> Self { - let mut enthopy_bytes: [u8; 32] = [0; 32]; - OsRng.fill_bytes(&mut enthopy_bytes); - - let mnemonic = Mnemonic::from_entropy(&enthopy_bytes) - .expect("Enthropy must be a multiple of 32 bytes"); - let seed_wide = mnemonic.to_seed("mnemonic"); - - Self { - seed: seed_wide.to_vec(), - } + Self::new_mnemonic("mnemonic").0 } #[must_use] @@ -62,14 +53,8 @@ impl SeedHolder { let mnemonic = Mnemonic::from_entropy(&entropy_bytes).expect("Entropy must be a multiple of 32 bytes"); - let seed_wide = mnemonic.to_seed(passphrase); - ( - Self { - seed: seed_wide.to_vec(), - }, - mnemonic, - ) + (Self::from_mnemonic(&mnemonic, passphrase), mnemonic) } #[must_use] @@ -107,10 +92,7 @@ impl SecretSpendingKey { const SUFFIX_1: &[u8; 1] = &[1]; const SUFFIX_2: &[u8; 19] = &[0; 19]; - let index = match index { - None => 0_u32, - _ => index.expect("Expect a valid u32"), - }; + let index = index.unwrap_or(0); let mut hasher = sha2::Sha256::new(); hasher.update(PREFIX); @@ -129,10 +111,7 @@ impl SecretSpendingKey { const SUFFIX_1: &[u8; 1] = &[2]; const SUFFIX_2: &[u8; 19] = &[0; 19]; - let index = match index { - None => 0_u32, - _ => index.expect("Expect a valid u32"), - }; + let index = index.unwrap_or(0); let mut bytes: Vec = Vec::with_capacity(64); bytes.extend_from_slice(PREFIX); @@ -146,14 +125,7 @@ impl SecretSpendingKey { let full_seed = hmac_sha512::HMAC::mac(bytes, b"LEE_viewing_seed"); - ViewingSecretKey::new( - *full_seed - .first_chunk::<32>() - .expect("hash_value is 64 bytes, must be safe to get first 32"), - *full_seed - .last_chunk::<32>() - .expect("hash_value is 64 bytes, must be safe to get last 32"), - ) + Self::generate_viewing_secret_key(full_seed) } #[must_use] @@ -181,7 +153,7 @@ impl From<&ViewingSecretKey> for ViewingPublicKey { seed_bytes[32..].copy_from_slice(&sk.z); let dk = ::DecapsulationKey::from_seed(Seed::from(seed_bytes)); Self::from_bytes(dk.encapsulation_key().to_bytes().to_vec()) - .expect("key_protocol::secret_holders::From<&ViewingSecretKey>: ML-KEM-768 encapsulation key is always 1184 bytes") + .expect("key_protocol::secret_holders::From<&ViewingSecretKey>: ML-KEM-768 encapsulation key is always ViewingPublicKey::LEN bytes") } } @@ -201,7 +173,6 @@ impl PrivateKeyHolder { mod tests { use super::*; - // TODO? are these necessary? #[test] fn seed_generation_test() { let seed_holder = SeedHolder::new_os_random(); diff --git a/lee/privacy_preserving_circuit/Cargo.toml b/lee/privacy_preserving_circuit/Cargo.toml index 26dfa132..a4f74644 100644 --- a/lee/privacy_preserving_circuit/Cargo.toml +++ b/lee/privacy_preserving_circuit/Cargo.toml @@ -15,3 +15,6 @@ workspace = true [dependencies] lee_core.workspace = true risc0-zkvm.workspace = true + +[dev-dependencies] +lee_core = { workspace = true, features = ["host"] } diff --git a/lee/privacy_preserving_circuit/src/execution_state.rs b/lee/privacy_preserving_circuit/src/execution_state.rs index 8d920068..09ad30af 100644 --- a/lee/privacy_preserving_circuit/src/execution_state.rs +++ b/lee/privacy_preserving_circuit/src/execution_state.rs @@ -6,6 +6,7 @@ use std::{ use lee_core::{ Identifier, InputAccountIdentity, NullifierPublicKey, account::{Account, AccountId, AccountWithMetadata}, + encryption::ViewingPublicKey, program::{ AccountPostState, BlockValidityWindow, ChainedCall, Claim, DEFAULT_PROGRAM_ID, MAX_NUMBER_CHAINED_CALLS, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow, @@ -21,7 +22,7 @@ pub struct ExecutionState { block_validity_window: BlockValidityWindow, timestamp_validity_window: TimestampValidityWindow, /// Positions (in `pre_states`) of private-PDA accounts whose supplied npk has been bound to - /// their `AccountId` via a proven `AccountId::for_private_pda(program_id, seed, npk, + /// their `AccountId` via a proven `AccountId::for_private_pda(program_id, seed, npk, vpk, /// identifier)` check. /// Two proof paths populate this set: a `Claim::Pda(seed)` in a program's `post_state` on /// that `pre_state`, or a caller's `ChainedCall.pda_seeds` entry matching that `pre_state` @@ -43,12 +44,13 @@ pub struct ExecutionState { /// `AccountId` entry or as an equality check against the existing one, making the rule: one /// `(program, seed)` → one account per tx. pda_family_binding: HashMap<(ProgramId, PdaSeed), AccountId>, - /// Map from a private-PDA `pre_state`'s position in `account_identities` to the (npk, + /// Map from a private-PDA `pre_state`'s position in `account_identities` to the (npk, vpk, /// identifier) supplied for that position. Built once in `derive_from_outputs` by walking - /// `account_identities` and consulting `npk_if_private_pda`. Used later by the claim and + /// `account_identities` and consulting `npk_vpk_if_private_pda`. Used later by the claim and /// caller-seeds authorization paths to verify - /// `AccountId::for_private_pda(program_id, seed, npk, identifier) == pre_state.account_id`. - private_pda_npk_by_position: HashMap, + /// `AccountId::for_private_pda(program_id, seed, npk, vpk, identifier) == + /// pre_state.account_id`. + private_pda_by_position: HashMap, authorized_accounts: HashSet, } @@ -63,11 +65,13 @@ impl ExecutionState { // in `account_identities`. The vec is documented as 1:1 with the program's pre_state // order, so position here matches `pre_state_position` used downstream in // `validate_and_sync_states`. - let mut private_pda_npk_by_position: HashMap = - HashMap::new(); + let mut private_pda_by_position: HashMap< + usize, + (NullifierPublicKey, ViewingPublicKey, Identifier), + > = HashMap::new(); for (pos, account_identity) in account_identities.iter().enumerate() { - if let Some((npk, identifier)) = account_identity.npk_if_private_pda() { - private_pda_npk_by_position.insert(pos, (npk, identifier)); + if let Some((npk, vpk, identifier)) = account_identity.npk_vpk_if_private_pda() { + private_pda_by_position.insert(pos, (npk, vpk, identifier)); } } @@ -107,7 +111,7 @@ impl ExecutionState { timestamp_validity_window, private_pda_bound_positions: HashMap::new(), pda_family_binding: HashMap::new(), - private_pda_npk_by_position, + private_pda_by_position, authorized_accounts: HashSet::new(), }; @@ -289,7 +293,7 @@ impl ExecutionState { let is_authorized = resolve_authorization_and_record_bindings( &mut self.pda_family_binding, &mut self.private_pda_bound_positions, - &self.private_pda_npk_by_position, + &self.private_pda_by_position, &mut self.authorized_accounts, pre_account_id, pre_state_position, @@ -309,6 +313,7 @@ impl ExecutionState { let external_seed = match account_identities.get(pre_state_position) { Some(InputAccountIdentity::PrivatePdaInit { npk, + vpk, identifier, seed: Some((seed, authority_program_id)), .. @@ -317,6 +322,7 @@ impl ExecutionState { authority_program_id, seed, npk, + vpk, *identifier, ); assert_eq!( @@ -327,6 +333,7 @@ impl ExecutionState { } Some(InputAccountIdentity::PrivatePdaUpdate { nsk, + vpk, identifier, seed: Some((seed, authority_program_id)), .. @@ -336,6 +343,7 @@ impl ExecutionState { authority_program_id, seed, &npk, + vpk, *identifier, ); assert_eq!( @@ -416,14 +424,19 @@ impl ExecutionState { match claim { Claim::Authorized => {} Claim::Pda(seed) => { - let (npk, identifier) = self - .private_pda_npk_by_position + let (npk, vpk, identifier) = self + .private_pda_by_position .get(&pre_state_position) .expect( "private PDA pre_state must have an npk in the position map", ); - let pda = - AccountId::for_private_pda(&program_id, &seed, npk, *identifier); + let pda = AccountId::for_private_pda( + &program_id, + &seed, + npk, + vpk, + *identifier, + ); assert_eq!( pre_account_id, pda, "Invalid private PDA claim for account {pre_account_id}" @@ -548,7 +561,7 @@ fn bind_private_pda_position( fn resolve_authorization_and_record_bindings( pda_family_binding: &mut HashMap<(ProgramId, PdaSeed), AccountId>, private_pda_bound_positions: &mut HashMap, - private_pda_npk_by_position: &HashMap, + private_pda_by_position: &HashMap, authorized_accounts: &mut HashSet, pre_account_id: AccountId, pre_state_position: usize, @@ -562,9 +575,10 @@ fn resolve_authorization_and_record_bindings( if AccountId::for_public_pda(&caller, seed) == pre_account_id { return Some((*seed, false, caller)); } - if let Some((npk, identifier)) = - private_pda_npk_by_position.get(&pre_state_position) - && AccountId::for_private_pda(&caller, seed, npk, *identifier) == pre_account_id + if let Some((npk, vpk, identifier)) = + private_pda_by_position.get(&pre_state_position) + && AccountId::for_private_pda(&caller, seed, npk, vpk, *identifier) + == pre_account_id { return Some((*seed, true, caller)); } diff --git a/lee/privacy_preserving_circuit/src/main.rs b/lee/privacy_preserving_circuit/src/main.rs index a342665d..1fc06157 100644 --- a/lee/privacy_preserving_circuit/src/main.rs +++ b/lee/privacy_preserving_circuit/src/main.rs @@ -9,6 +9,7 @@ fn main() { program_outputs, account_identities, program_id, + dummy_inputs, } = env::read(); let execution_state = execution_state::ExecutionState::derive_from_outputs( @@ -17,7 +18,7 @@ fn main() { program_outputs, ); - let output = output::compute_circuit_output(execution_state, &account_identities); + let output = output::compute_circuit_output(execution_state, &account_identities, dummy_inputs); env::commit(&output); } diff --git a/lee/privacy_preserving_circuit/src/output.rs b/lee/privacy_preserving_circuit/src/output.rs index 8c8ec2a4..fe31e71a 100644 --- a/lee/privacy_preserving_circuit/src/output.rs +++ b/lee/privacy_preserving_circuit/src/output.rs @@ -1,9 +1,10 @@ use lee_core::{ - Commitment, CommitmentSetDigest, DUMMY_COMMITMENT_HASH, EncryptedAccountData, EncryptionScheme, - EphemeralPublicKey, InputAccountIdentity, MembershipProof, Nullifier, NullifierPublicKey, + Commitment, CommitmentSetDigest, DummyInput, EncryptedAccountData, EncryptionScheme, + EphemeralSecretKey, InputAccountIdentity, MembershipProof, Nullifier, NullifierPublicKey, NullifierSecretKey, PrivacyPreservingCircuitOutput, PrivateAccountKind, SharedSecretKey, account::{Account, AccountId, Nonce}, compute_digest_for_path, + encryption::{ViewTag, ViewingPublicKey}, }; use crate::execution_state::ExecutionState; @@ -11,6 +12,7 @@ use crate::execution_state::ExecutionState; pub fn compute_circuit_output( execution_state: ExecutionState, account_identities: &[InputAccountIdentity], + dummy_inputs: Vec, ) -> PrivacyPreservingCircuitOutput { let (block_validity_window, timestamp_validity_window, pda_seed_by_position, states_iter) = execution_state.into_parts(); @@ -30,7 +32,6 @@ pub fn compute_circuit_output( "Invalid account_identities length" ); - let mut output_index = 0; for (pos, (account_identity, (pre_state, post_state))) in account_identities.iter().zip(states_iter).enumerate() { @@ -40,14 +41,14 @@ pub fn compute_circuit_output( output.public_post_states.push(post_state); } InputAccountIdentity::PrivateAuthorizedInit { - epk, - view_tag, - ssk, + vpk, + random_seed, nsk, identifier, + commitment_root, } => { let npk = NullifierPublicKey::from(nsk); - let account_id = AccountId::for_regular_private_account(&npk, *identifier); + let account_id = AccountId::for_regular_private_account(&npk, vpk, *identifier); assert_eq!(account_id, pre_state.account_id, "AccountId mismatch"); assert!( @@ -62,33 +63,33 @@ pub fn compute_circuit_output( let new_nullifier = ( Nullifier::for_account_initialization(&account_id), - DUMMY_COMMITMENT_HASH, + *commitment_root, ); let new_nonce = Nonce::private_account_nonce_init(&account_id); + let view_tag = EncryptedAccountData::compute_view_tag(&npk, vpk); emit_private_output( &mut output, - &mut output_index, post_state, &account_id, &PrivateAccountKind::Regular(*identifier), - ssk, - epk, - *view_tag, + view_tag, + vpk, + random_seed, new_nullifier, new_nonce, ); } InputAccountIdentity::PrivateAuthorizedUpdate { - epk, + vpk, + random_seed, view_tag, - ssk, nsk, membership_proof, identifier, } => { let npk = NullifierPublicKey::from(nsk); - let account_id = AccountId::for_regular_private_account(&npk, *identifier); + let account_id = AccountId::for_regular_private_account(&npk, vpk, *identifier); assert_eq!(account_id, pre_state.account_id, "AccountId mismatch"); assert!( @@ -106,25 +107,24 @@ pub fn compute_circuit_output( emit_private_output( &mut output, - &mut output_index, post_state, &account_id, &PrivateAccountKind::Regular(*identifier), - ssk, - epk, *view_tag, + vpk, + random_seed, new_nullifier, new_nonce, ); } - InputAccountIdentity::PrivateUnauthorized { - epk, - view_tag, + InputAccountIdentity::PrivateForeignInit { + vpk, + random_seed, npk, - ssk, identifier, + commitment_root, } => { - let account_id = AccountId::for_regular_private_account(npk, *identifier); + let account_id = AccountId::for_regular_private_account(npk, vpk, *identifier); assert_eq!(account_id, pre_state.account_id, "AccountId mismatch"); assert_eq!( @@ -133,41 +133,41 @@ pub fn compute_circuit_output( "Found new private account with non default values", ); assert!( - !pre_state.is_authorized, - "Found new private account marked as authorized." + pre_state.is_authorized, + "Found new private account marked as unauthorized." ); let new_nullifier = ( Nullifier::for_account_initialization(&account_id), - DUMMY_COMMITMENT_HASH, + *commitment_root, ); let new_nonce = Nonce::private_account_nonce_init(&account_id); + let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk); emit_private_output( &mut output, - &mut output_index, post_state, &account_id, &PrivateAccountKind::Regular(*identifier), - ssk, - epk, - *view_tag, + view_tag, + vpk, + random_seed, new_nullifier, new_nonce, ); } InputAccountIdentity::PrivatePdaInit { - epk, - view_tag, - npk: _, - ssk, + vpk, + random_seed, + npk, identifier, + commitment_root, seed: _, } => { // The npk-to-account_id binding is established upstream in // `validate_and_sync_states` via `Claim::Pda(seed)` or a caller `pda_seeds` // match. Here we only enforce the init pre-conditions. The supplied npk on - // the variant has been recorded into `private_pda_npk_by_position` and used + // the variant has been recorded into `private_pda_by_position` and used // for the binding check; we use `pre_state.account_id` directly for nullifier // and commitment derivation. assert!( @@ -182,7 +182,7 @@ pub fn compute_circuit_output( let new_nullifier = ( Nullifier::for_account_initialization(&pre_state.account_id), - DUMMY_COMMITMENT_HASH, + *commitment_root, ); let new_nonce = Nonce::private_account_nonce_init(&pre_state.account_id); @@ -190,9 +190,9 @@ pub fn compute_circuit_output( let (authority_program_id, seed) = pda_seed_by_position .get(&pos) .expect("PrivatePdaInit position must be in pda_seed_by_position"); + let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk); emit_private_output( &mut output, - &mut output_index, post_state, &account_id, &PrivateAccountKind::Pda { @@ -200,17 +200,17 @@ pub fn compute_circuit_output( seed: *seed, identifier: *identifier, }, - ssk, - epk, - *view_tag, + view_tag, + vpk, + random_seed, new_nullifier, new_nonce, ); } InputAccountIdentity::PrivatePdaUpdate { - epk, + vpk, + random_seed, view_tag, - ssk, nsk, membership_proof, identifier, @@ -240,7 +240,6 @@ pub fn compute_circuit_output( .expect("PrivatePdaUpdate position must be in pda_seed_by_position"); emit_private_output( &mut output, - &mut output_index, post_state, &account_id, &PrivateAccountKind::Pda { @@ -248,9 +247,9 @@ pub fn compute_circuit_output( seed: *seed, identifier: *identifier, }, - ssk, - epk, *view_tag, + vpk, + random_seed, new_nullifier, new_nonce, ); @@ -258,50 +257,85 @@ pub fn compute_circuit_output( } } + for dummy in dummy_inputs { + emit_dummy_output(&mut output, dummy); + } + + obfuscate_output_ordering(&mut output); + output } +fn obfuscate_output_ordering(output: &mut PrivacyPreservingCircuitOutput) { + output + .new_commitments + .sort_unstable_by_key(Commitment::to_byte_array); + + let mut notes: Vec<_> = core::mem::take(&mut output.new_nullifiers) + .into_iter() + .zip(core::mem::take(&mut output.encrypted_private_post_states)) + .collect(); + notes.sort_unstable_by_key(|((nullifier, _), _)| nullifier.to_byte_array()); + (output.new_nullifiers, output.encrypted_private_post_states) = notes.into_iter().unzip(); +} + +fn emit_dummy_output(output: &mut PrivacyPreservingCircuitOutput, dummy: DummyInput) { + // Note: the nullifiers and commitments are generated from seeds. + // The prover is responsible for their randomness. + let nullifier = Nullifier::for_dummy(&dummy.nullifier_seed); + let commitment = Commitment::for_dummy(&nullifier, &dummy.commitment_seed); + output + .new_nullifiers + .push((nullifier, dummy.commitment_root)); + output.new_commitments.push(commitment); + // Note: the encrypted post states are pushed as fed into the circuit. + // That means that the prover is responsible for managing the randomness + // so as to not reveal the padding. + // + // In particular, it is recommended to generate the ML KEM ciphertext + // explicitly as these are not uniformly random. + output.encrypted_private_post_states.push(dummy.note); +} + #[expect( clippy::too_many_arguments, reason = "Inputs are distinct concerns from the variant arms; bundling would be artificial" )] fn emit_private_output( output: &mut PrivacyPreservingCircuitOutput, - output_index: &mut u32, post_state: Account, account_id: &AccountId, kind: &PrivateAccountKind, - shared_secret: &SharedSecretKey, - epk: &EphemeralPublicKey, - view_tag: u8, + view_tag: ViewTag, + vpk: &ViewingPublicKey, + random_seed: &[u8; 32], new_nullifier: (Nullifier, CommitmentSetDigest), new_nonce: Nonce, ) { - output.new_nullifiers.push(new_nullifier); - let mut post_with_updated_nonce = post_state; post_with_updated_nonce.nonce = new_nonce; let commitment_post = Commitment::new(account_id, &post_with_updated_nonce); + + let esk = EphemeralSecretKey::new(account_id, random_seed, &new_nonce); + let (shared_secret, epk) = SharedSecretKey::encapsulate_deterministic(vpk, &esk); + let encrypted_account = EncryptionScheme::encrypt( &post_with_updated_nonce, kind, - shared_secret, - &commitment_post, - *output_index, + &shared_secret, + &new_nullifier.0, ); + output.new_nullifiers.push(new_nullifier); output.new_commitments.push(commitment_post); output .encrypted_private_post_states .push(EncryptedAccountData { ciphertext: encrypted_account, - epk: epk.clone(), + epk, view_tag, }); - *output_index = output_index - .checked_add(1) - .unwrap_or_else(|| panic!("Too many private accounts, output index overflow")); } fn compute_update_nullifier_and_set_digest( @@ -315,3 +349,83 @@ fn compute_update_nullifier_and_set_digest( let nullifier = Nullifier::for_account_update(&commitment_pre, nsk); (nullifier, set_digest) } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use lee_core::{DUMMY_COMMITMENT_HASH, EphemeralPublicKey}; + + use super::*; + + fn note(tag: u8) -> (Nullifier, Commitment, EncryptedAccountData) { + let nullifier = Nullifier::for_dummy(&[tag; 32]); + let commitment = Commitment::for_dummy(&nullifier, &[tag; 32]); + let ciphertext = EncryptionScheme::encrypt( + &Account::default(), + &PrivateAccountKind::Regular(0), + &SharedSecretKey([0; 32]), + &nullifier, + ); + let encrypted = EncryptedAccountData { + ciphertext, + epk: EphemeralPublicKey(vec![tag]), + view_tag: 0, + }; + (nullifier, commitment, encrypted) + } + + #[test] + fn obfuscate_byte_sorts_commitments_and_nullifiers() { + let mut output = PrivacyPreservingCircuitOutput::default(); + for tag in 0..3 { + let (nullifier, commitment, encrypted) = note(tag); + output + .new_nullifiers + .push((nullifier, DUMMY_COMMITMENT_HASH)); + output.new_commitments.push(commitment); + output.encrypted_private_post_states.push(encrypted); + } + + obfuscate_output_ordering(&mut output); + + assert!( + output + .new_commitments + .is_sorted_by_key(Commitment::to_byte_array) + ); + assert!( + output + .new_nullifiers + .is_sorted_by_key(|(nullifier, _)| nullifier.to_byte_array()) + ); + } + + #[test] + fn obfuscate_keeps_each_nullifier_with_its_ciphertext() { + let mut output = PrivacyPreservingCircuitOutput::default(); + for tag in 0..3 { + let (nullifier, _, encrypted) = note(tag); + output + .new_nullifiers + .push((nullifier, DUMMY_COMMITMENT_HASH)); + output.encrypted_private_post_states.push(encrypted); + } + let paired: HashMap<[u8; 32], EphemeralPublicKey> = output + .new_nullifiers + .iter() + .zip(&output.encrypted_private_post_states) + .map(|((nullifier, _), note)| (nullifier.to_byte_array(), note.epk.clone())) + .collect(); + + obfuscate_output_ordering(&mut output); + + for ((nullifier, _), note) in output + .new_nullifiers + .iter() + .zip(&output.encrypted_private_post_states) + { + assert_eq!(paired[&nullifier.to_byte_array()], note.epk); + } + } +} diff --git a/lee/state_machine/Cargo.toml b/lee/state_machine/Cargo.toml index d4014465..df43da8d 100644 --- a/lee/state_machine/Cargo.toml +++ b/lee/state_machine/Cargo.toml @@ -28,10 +28,8 @@ build_utils.workspace = true [dev-dependencies] lee_core = { workspace = true, features = ["test_utils"] } -token_core.workspace = true test_methods = { path = "test_methods" } -env_logger.workspace = true hex-literal = "1.0.0" test-case = "3.3.1" diff --git a/lee/state_machine/core/Cargo.toml b/lee/state_machine/core/Cargo.toml index 6e1f0ff0..585637ae 100644 --- a/lee/state_machine/core/Cargo.toml +++ b/lee/state_machine/core/Cargo.toml @@ -16,7 +16,7 @@ thiserror.workspace = true bytemuck.workspace = true bytesize.workspace = true base58.workspace = true -ml-kem = { workspace = true, optional = true, features = ["getrandom"] } +ml-kem = { workspace = true } chacha20 = { version = "0.10" } [dev-dependencies] @@ -24,5 +24,5 @@ serde_json.workspace = true [features] default = [] -host = ["dep:ml-kem"] +host = ["ml-kem/getrandom"] test_utils = ["host"] diff --git a/lee/state_machine/core/src/circuit_io.rs b/lee/state_machine/core/src/circuit_io.rs index 78bfa24f..baa2d0c5 100644 --- a/lee/state_machine/core/src/circuit_io.rs +++ b/lee/state_machine/core/src/circuit_io.rs @@ -2,9 +2,9 @@ use serde::{Deserialize, Serialize}; use crate::{ Commitment, CommitmentSetDigest, Identifier, MembershipProof, Nullifier, NullifierPublicKey, - NullifierSecretKey, SharedSecretKey, + NullifierSecretKey, account::{Account, AccountWithMetadata}, - encryption::{EncryptedAccountData, EphemeralPublicKey, ViewTag}, + encryption::{EncryptedAccountData, ViewTag, ViewingPublicKey}, program::{BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow}, }; @@ -14,15 +14,14 @@ pub struct PrivacyPreservingCircuitInput { pub program_outputs: Vec, /// One entry per `pre_state`, in the same order as the program's `pre_states`. /// Length must equal the number of `pre_states` derived from `program_outputs`. - /// The guest's `private_pda_npk_by_position` and `private_pda_bound_positions` + /// The guest's `private_pda_by_position` and `private_pda_bound_positions` /// rely on this position alignment. pub account_identities: Vec, /// Program ID. pub program_id: ProgramId, + pub dummy_inputs: Vec, } -/// Per-account input to the privacy-preserving circuit. Each variant carries exactly the fields -/// the guest needs for that account's code path. #[derive(Serialize, Deserialize, Clone)] pub enum InputAccountIdentity { /// Public account. The guest reads pre/post state from `program_outputs` and emits no @@ -30,47 +29,47 @@ pub enum InputAccountIdentity { Public, /// Init of an authorized standalone private account: no membership proof. The `pre_state` /// must be `Account::default()`. The `account_id` is derived as - /// `AccountId::for_regular_private_account(&NullifierPublicKey::from(nsk), identifier)` and - /// matched against `pre_state.account_id`. + /// `AccountId::for_regular_private_account(&NullifierPublicKey::from(nsk), vpk, identifier)` + /// and matched against `pre_state.account_id`. PrivateAuthorizedInit { - epk: EphemeralPublicKey, - view_tag: ViewTag, - ssk: SharedSecretKey, + vpk: ViewingPublicKey, + random_seed: [u8; 32], nsk: NullifierSecretKey, identifier: Identifier, + commitment_root: CommitmentSetDigest, }, /// Update of an authorized standalone private account: existing on-chain commitment, with /// membership proof. PrivateAuthorizedUpdate { - epk: EphemeralPublicKey, + vpk: ViewingPublicKey, + random_seed: [u8; 32], view_tag: ViewTag, - ssk: SharedSecretKey, nsk: NullifierSecretKey, membership_proof: MembershipProof, identifier: Identifier, }, /// Init of a standalone private account the caller does not own (e.g. a recipient who /// doesn't yet exist on chain). No `nsk`, no membership proof. - PrivateUnauthorized { - epk: EphemeralPublicKey, - view_tag: ViewTag, + PrivateForeignInit { + vpk: ViewingPublicKey, + random_seed: [u8; 32], npk: NullifierPublicKey, - ssk: SharedSecretKey, identifier: Identifier, + commitment_root: CommitmentSetDigest, }, /// Init of a private PDA, unauthorized. The npk-to-account_id binding is proven upstream /// via `Claim::Pda(seed)` or a caller's `pda_seeds` match. The identifier diversifies the /// PDA within the `(program_id, seed, npk)` family: `AccountId::for_private_pda` uses it /// as the 4th input. PrivatePdaInit { - epk: EphemeralPublicKey, - view_tag: ViewTag, + vpk: ViewingPublicKey, + random_seed: [u8; 32], npk: NullifierPublicKey, - ssk: SharedSecretKey, identifier: Identifier, + commitment_root: CommitmentSetDigest, /// When `Some((seed, authority_program_id))`, the circuit binds this position via the /// external derivation check - /// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) == + /// `AccountId::for_private_pda(authority_program_id, seed, npk, vpk, identifier) == /// pre_state.account_id` rather than requiring a `Claim::Pda` or caller /// `pda_seeds` to establish the binding. The `pre_state` must have `is_authorized /// == false`. @@ -80,21 +79,35 @@ pub enum InputAccountIdentity { /// from `nsk`. Authorization may be established upstream by a caller `pda_seeds` match or a /// previously-seen authorization in a chained call. PrivatePdaUpdate { - epk: EphemeralPublicKey, + vpk: ViewingPublicKey, + random_seed: [u8; 32], view_tag: ViewTag, - ssk: SharedSecretKey, nsk: NullifierSecretKey, membership_proof: MembershipProof, identifier: Identifier, /// When `Some((seed, authority_program_id))`, the circuit binds this position via the /// external derivation check - /// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) == + /// `AccountId::for_private_pda(authority_program_id, seed, npk, vpk, identifier) == /// pre_state.account_id` rather than requiring a caller `pda_seeds` to establish /// the binding. The `pre_state` must have `is_authorized == false`. seed: Option<(PdaSeed, ProgramId)>, }, } +/// A struct containing necessary data for dummy nullifier and +/// commitment generation. +#[derive(Serialize, Deserialize)] +pub struct DummyInput { + /// The seed used for generating the dummy nullifier. + pub nullifier_seed: [u8; 32], + /// The seed used for generating the dummy commitment. + pub commitment_seed: [u8; 32], + /// The dummy ciphertext, epk, and view tag. + pub note: EncryptedAccountData, + /// The dummy root. + pub commitment_root: CommitmentSetDigest, +} + impl InputAccountIdentity { #[must_use] pub const fn is_public(&self) -> bool { @@ -109,27 +122,33 @@ impl InputAccountIdentity { ) } - /// For private PDA variants, return the `(npk, identifier)` pair. `Init` carries both - /// directly; `Update` derives `npk` from `nsk`. For non-PDA variants returns `None`. #[must_use] - pub fn npk_if_private_pda(&self) -> Option<(NullifierPublicKey, Identifier)> { + pub fn npk_vpk_if_private_pda( + &self, + ) -> Option<(NullifierPublicKey, ViewingPublicKey, Identifier)> { match self { Self::PrivatePdaInit { - npk, identifier, .. - } => Some((*npk, *identifier)), + npk, + vpk, + identifier, + .. + } => Some((*npk, vpk.clone(), *identifier)), Self::PrivatePdaUpdate { - nsk, identifier, .. - } => Some((NullifierPublicKey::from(nsk), *identifier)), + nsk, + vpk, + identifier, + .. + } => Some((NullifierPublicKey::from(nsk), vpk.clone(), *identifier)), Self::Public | Self::PrivateAuthorizedInit { .. } | Self::PrivateAuthorizedUpdate { .. } - | Self::PrivateUnauthorized { .. } => None, + | Self::PrivateForeignInit { .. } => None, } } } #[derive(Serialize, Deserialize)] -#[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq))] +#[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq, Default))] pub struct PrivacyPreservingCircuitOutput { pub public_pre_states: Vec, pub public_post_states: Vec, @@ -158,7 +177,7 @@ mod tests { use crate::{ Commitment, Nullifier, account::{Account, AccountId, AccountWithMetadata, Nonce}, - encryption::Ciphertext, + encryption::{Ciphertext, EphemeralPublicKey}, }; #[test] diff --git a/lee/state_machine/core/src/commitment.rs b/lee/state_machine/core/src/commitment.rs index 92085d7d..da861eed 100644 --- a/lee/state_machine/core/src/commitment.rs +++ b/lee/state_machine/core/src/commitment.rs @@ -2,7 +2,10 @@ use borsh::{BorshDeserialize, BorshSerialize}; use risc0_zkvm::sha::{Impl, Sha256 as _}; use serde::{Deserialize, Serialize}; -use crate::account::{Account, AccountId}; +use crate::{ + Nullifier, + account::{Account, AccountId}, +}; /// A commitment to all zero data. /// ```python @@ -32,7 +35,7 @@ pub const DUMMY_COMMITMENT_HASH: [u8; 32] = [ #[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize)] #[cfg_attr( any(feature = "host", test), - derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord) + derive(Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord) )] pub struct Commitment(pub(super) [u8; 32]); @@ -78,6 +81,15 @@ impl Commitment { bytes.extend_from_slice(&account_bytes_with_hashed_data); Self(Impl::hash_bytes(&bytes).as_bytes().try_into().unwrap()) } + + #[must_use] + pub fn for_dummy(nullifier: &Nullifier, commitment_seed: &[u8; 32]) -> Self { + const DUMMY_PREFIX: &[u8; 32] = b"/LEE/v0.3/Commitment/Dummy/\x00\x00\x00\x00\x00"; + let mut bytes = DUMMY_PREFIX.to_vec(); + bytes.extend_from_slice(&nullifier.0); + bytes.extend_from_slice(commitment_seed); + Self(Impl::hash_bytes(&bytes).as_bytes().try_into().unwrap()) + } } pub type CommitmentSetDigest = [u8; 32]; @@ -117,7 +129,7 @@ mod tests { use risc0_zkvm::sha::{Impl, Sha256 as _}; use crate::{ - Commitment, DUMMY_COMMITMENT, DUMMY_COMMITMENT_HASH, + Commitment, DUMMY_COMMITMENT, DUMMY_COMMITMENT_HASH, Nullifier, account::{Account, AccountId}, }; @@ -138,4 +150,18 @@ mod tests { .unwrap(); assert_eq!(DUMMY_COMMITMENT_HASH, expected_dummy_commitment_hash); } + + #[test] + fn for_dummy_matches_pinned_value() { + let nullifier = Nullifier::for_dummy(&[0; 32]); + let commitment_seed = [1; 32]; + let expected_commitment = Commitment([ + 106, 88, 233, 248, 28, 251, 254, 48, 62, 53, 61, 248, 25, 148, 223, 133, 108, 213, 184, + 83, 73, 145, 122, 104, 89, 220, 111, 132, 40, 87, 12, 105, + ]); + assert_eq!( + Commitment::for_dummy(&nullifier, &commitment_seed), + expected_commitment + ); + } } diff --git a/lee/state_machine/core/src/encoding.rs b/lee/state_machine/core/src/encoding.rs index 59df4b06..e9b4a845 100644 --- a/lee/state_machine/core/src/encoding.rs +++ b/lee/state_machine/core/src/encoding.rs @@ -7,7 +7,7 @@ use std::io::Read as _; #[cfg(feature = "host")] use crate::Nullifier; #[cfg(feature = "host")] -use crate::encryption::EphemeralPublicKey; +use crate::encryption::{EphemeralPublicKey, ML_KEM_768_CIPHERTEXT_LEN}; #[cfg(feature = "host")] use crate::error::LeeCoreError; use crate::{ @@ -97,11 +97,6 @@ impl NullifierPublicKey { #[cfg(feature = "host")] impl Nullifier { - #[must_use] - pub const fn to_byte_array(&self) -> [u8; 32] { - self.0 - } - #[cfg(feature = "host")] #[must_use] pub const fn from_byte_array(bytes: [u8; 32]) -> Self { @@ -168,7 +163,7 @@ impl EphemeralPublicKey { /// Deserializes an ML-KEM-768 ciphertext from a cursor. /// Reads exactly 1088 bytes — the fixed ciphertext size for ML-KEM-768. pub fn from_cursor(cursor: &mut Cursor<&[u8]>) -> Result { - let mut value = vec![0_u8; 1088]; + let mut value = vec![0_u8; ML_KEM_768_CIPHERTEXT_LEN]; cursor.read_exact(&mut value)?; Ok(Self(value)) } diff --git a/lee/state_machine/core/src/encryption/mod.rs b/lee/state_machine/core/src/encryption/mod.rs index 5fa80b60..19f7e741 100644 --- a/lee/state_machine/core/src/encryption/mod.rs +++ b/lee/state_machine/core/src/encryption/mod.rs @@ -5,20 +5,46 @@ use chacha20::{ }; use risc0_zkvm::sha::{Impl, Sha256 as _}; use serde::{Deserialize, Serialize}; -#[cfg(feature = "host")] pub use shared_key_derivation::{MlKem768EncapsulationKey, ViewingPublicKey}; -use crate::{Commitment, account::Account, program::PrivateAccountKind}; -#[cfg(feature = "host")] +use crate::{Nullifier, account::Account, program::PrivateAccountKind}; pub mod shared_key_derivation; +/// Length in bytes of an ML-KEM-768 ciphertext (the `EphemeralPublicKey` payload). +pub const ML_KEM_768_CIPHERTEXT_LEN: usize = 1088; + pub type Scalar = [u8; 32]; +#[derive(Serialize, Deserialize, Clone, Copy)] +pub struct EphemeralSecretKey(pub [u8; 32]); + +impl EphemeralSecretKey { + /// Derives an ephemeral secret key from OS randomness and account-specific values. + /// + /// For updates, `nonce` carries `nsk`-derived entropy, making `esk` strong even + /// with a compromised RNG. For inits, `nonce` is deterministic, so `random_seed` + /// is the sole entropy source. + #[must_use] + pub fn new( + account_id: &crate::account::AccountId, + random_seed: &[u8; 32], + nonce: &crate::account::Nonce, + ) -> Self { + const PREFIX: &[u8; 14] = b"/LEE/v0.3/esk/"; + let mut input = [0_u8; 14 + 32 + 32 + 16]; + input[0..14].copy_from_slice(PREFIX); + input[14..46].copy_from_slice(account_id.value()); + input[46..78].copy_from_slice(random_seed); + input[78..94].copy_from_slice(&nonce.0.to_le_bytes()); + Self(Impl::hash_bytes(&input).as_bytes().try_into().unwrap()) + } +} + #[derive(Serialize, Deserialize, Clone, Copy)] pub struct SharedSecretKey(pub [u8; 32]); /// The ML-KEM-768 ciphertext produced during encapsulation; transmitted on-wire in place of the -/// former ECDH ephemeral public key. Always 1088 bytes for ML-KEM-768. +/// former ECDH ephemeral public key. Always `ML_KEM_768_CIPHERTEXT_LEN` (1088) bytes. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct EphemeralPublicKey(pub Vec); @@ -52,6 +78,18 @@ pub struct EncryptedAccountData { pub view_tag: ViewTag, } +impl EncryptedAccountData { + #[must_use] + pub fn compute_view_tag(npk: &crate::NullifierPublicKey, vpk: &ViewingPublicKey) -> ViewTag { + const PREFIX: &[u8; 18] = b"/LEE/v0.3/ViewTag/"; + let mut bytes = [0_u8; 18 + 32 + ViewingPublicKey::LEN]; + bytes[0..18].copy_from_slice(PREFIX); + bytes[18..50].copy_from_slice(&npk.to_byte_array()); + bytes[50..].copy_from_slice(vpk.to_bytes()); + Impl::hash_bytes(&bytes).as_bytes()[0] + } +} + #[cfg(feature = "host")] impl EncryptedAccountData { #[must_use] @@ -68,16 +106,6 @@ impl EncryptedAccountData { view_tag, } } - - /// Computes the tag as the first byte of SHA256("/LEE/v0.3/ViewTag/" || npk || vpk). - #[must_use] - pub fn compute_view_tag(npk: &crate::NullifierPublicKey, vpk: &ViewingPublicKey) -> ViewTag { - let mut bytes = Vec::new(); - bytes.extend_from_slice(b"/LEE/v0.3/ViewTag/"); - bytes.extend_from_slice(&npk.to_byte_array()); - bytes.extend_from_slice(vpk.to_bytes()); - Impl::hash_bytes(&bytes).as_bytes()[0] - } } impl EncryptionScheme { @@ -86,39 +114,32 @@ impl EncryptionScheme { account: &Account, kind: &PrivateAccountKind, shared_secret: &SharedSecretKey, - commitment: &Commitment, - output_index: u32, + nullifier: &Nullifier, ) -> Ciphertext { // Plaintext: PrivateAccountKind::HEADER_LEN bytes header || account bytes. // Both variants produce the same header length — see PrivateAccountKind::to_header_bytes. let mut buffer = kind.to_header_bytes().to_vec(); buffer.extend_from_slice(&account.to_bytes()); - Self::symmetric_transform(&mut buffer, shared_secret, commitment, output_index); + Self::symmetric_transform(&mut buffer, shared_secret, nullifier); Ciphertext(buffer) } fn symmetric_transform( buffer: &mut [u8], shared_secret: &SharedSecretKey, - commitment: &Commitment, - output_index: u32, + nullifier: &Nullifier, ) { - let key = Self::kdf(shared_secret, commitment, output_index); + let key = Self::kdf(shared_secret, nullifier); let mut cipher = ChaCha20::new(&key.into(), &[0; 12].into()); cipher.apply_keystream(buffer); } - fn kdf( - shared_secret: &SharedSecretKey, - commitment: &Commitment, - output_index: u32, - ) -> [u8; 32] { - let mut bytes = Vec::new(); - - bytes.extend_from_slice(b"LEE/v0.2/KDF-SHA256/"); - bytes.extend_from_slice(&shared_secret.0); - bytes.extend_from_slice(&commitment.to_byte_array()); - bytes.extend_from_slice(&output_index.to_le_bytes()); + fn kdf(shared_secret: &SharedSecretKey, nullifier: &Nullifier) -> [u8; 32] { + const PREFIX: &[u8; 20] = b"LEE/v0.3/KDF-SHA256/"; + let mut bytes = [0_u8; 20 + 32 + 32]; + bytes[0..20].copy_from_slice(PREFIX); + bytes[20..52].copy_from_slice(&shared_secret.0); + bytes[52..84].copy_from_slice(&nullifier.to_byte_array()); Impl::hash_bytes(&bytes).as_bytes().try_into().unwrap() } @@ -132,12 +153,11 @@ impl EncryptionScheme { pub fn decrypt( ciphertext: &Ciphertext, shared_secret: &SharedSecretKey, - commitment: &Commitment, - output_index: u32, + nullifier: &Nullifier, ) -> Option<(PrivateAccountKind, Account)> { use std::io::Cursor; let mut buffer = ciphertext.0.clone(); - Self::symmetric_transform(&mut buffer, shared_secret, commitment, output_index); + Self::symmetric_transform(&mut buffer, shared_secret, nullifier); if buffer.len() < PrivateAccountKind::HEADER_LEN { return None; @@ -152,8 +172,7 @@ impl EncryptionScheme { println!( "Failed to decode {ciphertext:?} \n with secret {:?} ,\n - commitment {commitment:?} ,\n - and output_index {output_index} ,\n + nullifier {nullifier:?} ,\n with error {err:?}", shared_secret.0 ); @@ -175,14 +194,13 @@ mod tests { fn encrypt_same_length_for_account_and_pda() { let account = Account::default(); let secret = SharedSecretKey([0_u8; 32]); - let commitment = crate::Commitment::new(&AccountId::new([0_u8; 32]), &Account::default()); + let nullifier = Nullifier::for_account_initialization(&AccountId::new([0_u8; 32])); let account_ct = EncryptionScheme::encrypt( &account, &PrivateAccountKind::Regular(42), &secret, - &commitment, - 0, + &nullifier, ); let pda_ct = EncryptionScheme::encrypt( &account, @@ -192,8 +210,7 @@ mod tests { identifier: 42, }, &secret, - &commitment, - 0, + &nullifier, ); assert_eq!(account_ct.0.len(), pda_ct.0.len()); @@ -217,11 +234,11 @@ mod tests { ..Account::default() }; let kind = PrivateAccountKind::Regular(0); - let commitment = crate::Commitment::new(&AccountId::new([7_u8; 32]), &account); + let nullifier = Nullifier::for_account_initialization(&AccountId::new([7_u8; 32])); - let ct = EncryptionScheme::encrypt(&account, &kind, &sender_ss, &commitment, 0); + let ct = EncryptionScheme::encrypt(&account, &kind, &sender_ss, &nullifier); let (decoded_kind, decoded_account) = - EncryptionScheme::decrypt(&ct, &receiver_ss, &commitment, 0) + EncryptionScheme::decrypt(&ct, &receiver_ss, &nullifier) .expect("decryption must succeed with correct shared secret"); assert_eq!(decoded_account, account); @@ -229,10 +246,55 @@ mod tests { // Wrong shared secret must not decrypt correctly. let wrong_ss = SharedSecretKey([0_u8; 32]); - let bad = EncryptionScheme::decrypt(&ct, &wrong_ss, &commitment, 0); + let bad_via_ss = EncryptionScheme::decrypt(&ct, &wrong_ss, &nullifier); assert!( - bad.is_none() || bad.is_some_and(|(_, a)| a.balance != 999), + bad_via_ss.is_none() || bad_via_ss.is_some_and(|(_, a)| a.balance != 999), "wrong shared secret must not produce the correct plaintext" ); + + // Wrong nullifier must not decrypt correctly. + let wrong_nullifier = Nullifier::for_account_initialization(&AccountId::new([9; 32])); + let bad_via_nlf = EncryptionScheme::decrypt(&ct, &receiver_ss, &wrong_nullifier); + assert!( + bad_via_nlf.is_none() || bad_via_nlf.is_some_and(|(_, a)| a.balance != 999), + "wrong nullifier must not produce the correct plaintext" + ); + } + + #[test] + fn esk_is_deterministic() { + let account_id = AccountId::new([1_u8; 32]); + let random_seed = [2_u8; 32]; + let nonce = crate::account::Nonce(42); + let esk1 = EphemeralSecretKey::new(&account_id, &random_seed, &nonce); + let esk2 = EphemeralSecretKey::new(&account_id, &random_seed, &nonce); + assert_eq!(esk1.0, esk2.0); + } + + #[test] + fn esk_differs_for_different_account_id() { + let random_seed = [2_u8; 32]; + let nonce = crate::account::Nonce(42); + let esk_a = EphemeralSecretKey::new(&AccountId::new([0_u8; 32]), &random_seed, &nonce); + let esk_b = EphemeralSecretKey::new(&AccountId::new([1_u8; 32]), &random_seed, &nonce); + assert_ne!(esk_a.0, esk_b.0); + } + + #[test] + fn esk_differs_for_different_random_seed() { + let account_id = AccountId::new([1_u8; 32]); + let nonce = crate::account::Nonce(42); + let esk_a = EphemeralSecretKey::new(&account_id, &[0_u8; 32], &nonce); + let esk_b = EphemeralSecretKey::new(&account_id, &[1_u8; 32], &nonce); + assert_ne!(esk_a.0, esk_b.0); + } + + #[test] + fn esk_differs_for_different_nonce() { + let account_id = AccountId::new([1_u8; 32]); + let random_seed = [2_u8; 32]; + let esk_a = EphemeralSecretKey::new(&account_id, &random_seed, &crate::account::Nonce(0)); + let esk_b = EphemeralSecretKey::new(&account_id, &random_seed, &crate::account::Nonce(1)); + assert_ne!(esk_a.0, esk_b.0); } } diff --git a/lee/state_machine/core/src/encryption/shared_key_derivation.rs b/lee/state_machine/core/src/encryption/shared_key_derivation.rs index 5c982c6f..3255080b 100644 --- a/lee/state_machine/core/src/encryption/shared_key_derivation.rs +++ b/lee/state_machine/core/src/encryption/shared_key_derivation.rs @@ -1,5 +1,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; -use ml_kem::{Decapsulate as _, Encapsulate as _, KeyExport as _, Seed}; +#[cfg(feature = "host")] +use ml_kem::Encapsulate as _; +use ml_kem::{Decapsulate as _, KeyExport as _, Seed}; use serde::{Deserialize, Serialize}; use crate::{EphemeralPublicKey, SharedSecretKey}; @@ -26,6 +28,7 @@ impl MlKem768EncapsulationKey { pub const LEN: usize = 1184; /// Construct from raw bytes, returning an error if the length is not [`Self::LEN`]. + #[cfg(feature = "host")] pub fn from_bytes(bytes: Vec) -> Result { if bytes.len() != Self::LEN { return Err(crate::error::LeeCoreError::DeserializationError(format!( @@ -59,12 +62,13 @@ impl SharedSecretKey { /// Returns `(shared_secret, ciphertext)`. The ciphertext must be included in the transaction /// as the `EphemeralPublicKey`; the receiver recovers the same shared secret via /// [`Self::decapsulate`]. + #[cfg(feature = "host")] #[must_use] pub fn encapsulate(ek: &MlKem768EncapsulationKey) -> (Self, EphemeralPublicKey) { let ek_bytes: ml_kem::kem::Key = ek.0.as_slice() .try_into() - .expect("MlKem768EncapsulationKey must be 1184 bytes"); + .expect("MlKem768EncapsulationKey must be MlKem768EncapsulationKey::LEN bytes"); let ek_obj = ml_kem::EncapsulationKey768::new(&ek_bytes).expect( "MlKem768EncapsulationKey bytes must encode a valid ML-KEM-768 encapsulation key", ); @@ -76,35 +80,23 @@ impl SharedSecretKey { (Self(ss_bytes), EphemeralPublicKey(ct.to_vec())) } - /// Deterministically encapsulate a shared secret toward `ek` for use in tests. + /// Deterministically encapsulate a shared secret toward `ek` using a + /// pre-derived `esk` as the ML-KEM encapsulation randomness. /// - /// The shared secret has no secret entropy — it is fully determined by `ek`, - /// `message_hash`, and `output_index`, all of which are public. This makes it - /// unsuitable for real encryption but useful for producing stable, reproducible - /// shared secrets in unit tests. Use a distinct `output_index` per output to - /// avoid EPK collisions across multiple outputs in the same test. - /// - /// For production use [`Self::encapsulate`], which draws randomness from the OS. - #[cfg(any(test, feature = "test_utils"))] + /// The `esk` must be derived via `derive_esk(account_id, random_seed, nonce)` + /// which binds it to the account and incorporates OS entropy. #[must_use] pub fn encapsulate_deterministic( ek: &MlKem768EncapsulationKey, - message_hash: &[u8; 32], - output_index: u32, + esk: &crate::encryption::EphemeralSecretKey, ) -> (Self, EphemeralPublicKey) { - use risc0_zkvm::sha::{Impl, Sha256 as _}; - - let mut input = Vec::with_capacity(36); - input.extend_from_slice(message_hash); - input.extend_from_slice(&output_index.to_le_bytes()); - let hash = Impl::hash_bytes(&input); - let m: ml_kem::B32 = - ml_kem::array::Array::try_from(hash.as_bytes()).expect("SHA-256 output is 32 bytes"); + let m: ml_kem::B32 = ml_kem::array::Array::try_from(esk.0.as_slice()) + .expect("EphemeralSecretKey is 32 bytes"); let ek_bytes: ml_kem::kem::Key = ek.0.as_slice() .try_into() - .expect("MlKem768EncapsulationKey must be 1184 bytes"); + .expect("MlKem768EncapsulationKey must be MlKem768EncapsulationKey::LEN bytes"); let ek_obj = ml_kem::EncapsulationKey768::new(&ek_bytes).expect( "MlKem768EncapsulationKey bytes must encode a valid ML-KEM-768 encapsulation key", ); @@ -118,8 +110,9 @@ impl SharedSecretKey { /// Receiver: decapsulate the shared secret from a KEM ciphertext. /// - /// Returns `None` if the `EphemeralPublicKey` is not exactly 1088 bytes — callers on - /// the wallet scan path should skip the output rather than panic on malformed chain data. + /// Returns `None` if the `EphemeralPublicKey` is not exactly [`ML_KEM_768_CIPHERTEXT_LEN`] + /// bytes — callers on the wallet scan path should skip the output rather than panic on + /// malformed chain data. /// /// `d` and `z` are the two 32-byte halves of the FIPS 203 `ViewingSecretKey` seed. #[must_use] @@ -146,6 +139,7 @@ mod tests { use ml_kem::KeyExport as _; use super::*; + use crate::ML_KEM_768_CIPHERTEXT_LEN; #[test] fn encapsulate_decapsulate_round_trip() { @@ -164,11 +158,15 @@ mod tests { let receiver_ss = SharedSecretKey::decapsulate(&epk, &d, &z).unwrap(); assert_eq!(sender_ss.0, receiver_ss.0, "shared secrets must match"); - assert_eq!(epk.0.len(), 1088, "ML-KEM-768 ciphertext is 1088 bytes"); + assert_eq!( + epk.0.len(), + ML_KEM_768_CIPHERTEXT_LEN, + "ML-KEM-768 ciphertext length" + ); assert_eq!( ek.0.len(), - 1184, - "ML-KEM-768 encapsulation key is 1184 bytes" + MlKem768EncapsulationKey::LEN, + "ML-KEM-768 encapsulation key length" ); } @@ -177,15 +175,15 @@ mod tests { let d = [1_u8; 32]; let z = [2_u8; 32]; - // Too short — 100 bytes instead of 1088. + // Too short — 100 bytes instead of ML_KEM_768_CIPHERTEXT_LEN. let short_epk = EphemeralPublicKey(vec![42_u8; 100]); assert!( SharedSecretKey::decapsulate(&short_epk, &d, &z).is_none(), "short EphemeralPublicKey must return None" ); - // Too long — 1089 bytes instead of 1088. - let long_epk = EphemeralPublicKey(vec![42_u8; 1089]); + // Too long — ML_KEM_768_CIPHERTEXT_LEN + 1. + let long_epk = EphemeralPublicKey(vec![42_u8; ML_KEM_768_CIPHERTEXT_LEN + 1]); assert!( SharedSecretKey::decapsulate(&long_epk, &d, &z).is_none(), "long EphemeralPublicKey must return None" diff --git a/lee/state_machine/core/src/lib.rs b/lee/state_machine/core/src/lib.rs index 9ad2858e..62dbd3cc 100644 --- a/lee/state_machine/core/src/lib.rs +++ b/lee/state_machine/core/src/lib.rs @@ -4,14 +4,15 @@ )] pub use circuit_io::{ - InputAccountIdentity, PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput, + DummyInput, InputAccountIdentity, PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput, }; pub use commitment::{ Commitment, CommitmentSetDigest, DUMMY_COMMITMENT, DUMMY_COMMITMENT_HASH, MembershipProof, compute_digest_for_path, }; pub use encryption::{ - EncryptedAccountData, EncryptionScheme, EphemeralPublicKey, SharedSecretKey, ViewTag, + EncryptedAccountData, EncryptionScheme, EphemeralPublicKey, EphemeralSecretKey, + ML_KEM_768_CIPHERTEXT_LEN, SharedSecretKey, ViewTag, }; pub use nullifier::{Identifier, Nullifier, NullifierPublicKey, NullifierSecretKey}; pub use program::PrivateAccountKind; diff --git a/lee/state_machine/core/src/nullifier.rs b/lee/state_machine/core/src/nullifier.rs index 0490ac00..59e7b5c2 100644 --- a/lee/state_machine/core/src/nullifier.rs +++ b/lee/state_machine/core/src/nullifier.rs @@ -2,7 +2,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; use risc0_zkvm::sha::{Impl, Sha256 as _}; use serde::{Deserialize, Serialize}; -use crate::{Commitment, account::AccountId}; +use crate::{Commitment, account::AccountId, encryption::ViewingPublicKey}; const PRIVATE_ACCOUNT_ID_PREFIX: &[u8; 32] = b"/LEE/v0.3/AccountId/Private/\x00\x00\x00\x00"; @@ -16,12 +16,16 @@ impl AccountId { /// Derives an [`AccountId`] for a regular (non-PDA) private account from the nullifier public /// key and identifier. #[must_use] - pub fn for_regular_private_account(npk: &NullifierPublicKey, identifier: Identifier) -> Self { - // 32 bytes prefix || 32 bytes npk || 16 bytes identifier - let mut bytes = [0; 80]; + pub fn for_regular_private_account( + npk: &NullifierPublicKey, + vpk: &ViewingPublicKey, + identifier: Identifier, + ) -> Self { + let mut bytes = [0_u8; 32 + 32 + ViewingPublicKey::LEN + 16]; bytes[0..32].copy_from_slice(PRIVATE_ACCOUNT_ID_PREFIX); bytes[32..64].copy_from_slice(&npk.0); - bytes[64..80].copy_from_slice(&identifier.to_le_bytes()); + bytes[64..64 + ViewingPublicKey::LEN].copy_from_slice(vpk.to_bytes()); + bytes[64 + ViewingPublicKey::LEN..].copy_from_slice(&identifier.to_le_bytes()); Self::new( Impl::hash_bytes(&bytes) @@ -32,9 +36,9 @@ impl AccountId { } } -impl From<(&NullifierPublicKey, Identifier)> for AccountId { - fn from((npk, identifier): (&NullifierPublicKey, Identifier)) -> Self { - Self::for_regular_private_account(npk, identifier) +impl From<(&NullifierPublicKey, &ViewingPublicKey, Identifier)> for AccountId { + fn from((npk, vpk, identifier): (&NullifierPublicKey, &ViewingPublicKey, Identifier)) -> Self { + Self::for_regular_private_account(npk, vpk, identifier) } } @@ -105,6 +109,19 @@ impl Nullifier { bytes.extend_from_slice(account_id.value()); Self(Impl::hash_bytes(&bytes).as_bytes().try_into().unwrap()) } + + #[must_use] + pub fn for_dummy(nullifier_seed: &[u8; 32]) -> Self { + const DUMMY_PREFIX: &[u8; 32] = b"/LEE/v0.3/Nullifier/Dummy/\x00\x00\x00\x00\x00\x00"; + let mut bytes = DUMMY_PREFIX.to_vec(); + bytes.extend_from_slice(nullifier_seed); + Self(Impl::hash_bytes(&bytes).as_bytes().try_into().unwrap()) + } + + #[must_use] + pub const fn to_byte_array(&self) -> [u8; 32] { + self.0 + } } #[cfg(test)] @@ -158,12 +175,13 @@ mod tests { 196, 134, 22, 224, 211, 237, 120, 136, 225, 188, 220, 249, 28, ]; let npk = NullifierPublicKey::from(&nsk); + let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]); let expected_account_id = AccountId::new([ - 165, 52, 40, 32, 231, 171, 113, 10, 65, 241, 156, 72, 154, 207, 122, 192, 15, 46, 50, - 253, 105, 164, 89, 84, 40, 191, 182, 119, 64, 255, 67, 142, + 242, 239, 57, 244, 89, 109, 65, 201, 223, 100, 43, 87, 205, 83, 148, 161, 176, 22, 208, + 220, 68, 135, 10, 171, 182, 80, 54, 74, 228, 244, 236, 7, ]); - let account_id = AccountId::for_regular_private_account(&npk, 0); + let account_id = AccountId::for_regular_private_account(&npk, &vpk, 0); assert_eq!(account_id, expected_account_id); } @@ -175,12 +193,13 @@ mod tests { 196, 134, 22, 224, 211, 237, 120, 136, 225, 188, 220, 249, 28, ]; let npk = NullifierPublicKey::from(&nsk); + let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]); let expected_account_id = AccountId::new([ - 203, 201, 109, 245, 40, 54, 195, 12, 55, 33, 0, 86, 245, 65, 70, 156, 24, 249, 26, 95, - 56, 247, 99, 121, 165, 182, 234, 255, 19, 127, 191, 72, + 149, 125, 157, 109, 119, 81, 9, 163, 231, 181, 214, 43, 57, 113, 221, 72, 180, 149, + 189, 170, 32, 181, 255, 231, 19, 92, 235, 59, 153, 185, 172, 206, ]); - let account_id = AccountId::for_regular_private_account(&npk, 1); + let account_id = AccountId::for_regular_private_account(&npk, &vpk, 1); assert_eq!(account_id, expected_account_id); } @@ -193,13 +212,24 @@ mod tests { 196, 134, 22, 224, 211, 237, 120, 136, 225, 188, 220, 249, 28, ]; let npk = NullifierPublicKey::from(&nsk); + let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]); let expected_account_id = AccountId::new([ - 178, 16, 226, 206, 217, 38, 38, 45, 155, 240, 226, 253, 168, 87, 146, 70, 72, 32, 174, - 19, 245, 25, 214, 162, 209, 135, 252, 82, 27, 2, 174, 196, + 30, 232, 222, 201, 233, 125, 124, 194, 58, 39, 121, 96, 185, 84, 168, 109, 80, 111, + 159, 112, 84, 100, 133, 244, 16, 34, 221, 35, 128, 131, 98, 159, ]); - let account_id = AccountId::for_regular_private_account(&npk, identifier); + let account_id = AccountId::for_regular_private_account(&npk, &vpk, identifier); assert_eq!(account_id, expected_account_id); } + + #[test] + fn for_dummy_matches_pinned_value() { + let nullifier_seed = [0; 32]; + let expected_nullifier = Nullifier([ + 244, 220, 48, 137, 204, 138, 180, 41, 108, 86, 40, 46, 187, 7, 232, 57, 57, 167, 143, + 157, 125, 171, 137, 46, 64, 206, 191, 211, 231, 0, 11, 86, + ]); + assert_eq!(Nullifier::for_dummy(&nullifier_seed), expected_nullifier); + } } diff --git a/lee/state_machine/core/src/program.rs b/lee/state_machine/core/src/program/mod.rs similarity index 66% rename from lee/state_machine/core/src/program.rs rename to lee/state_machine/core/src/program/mod.rs index c5949dcf..770bcf2d 100644 --- a/lee/state_machine/core/src/program.rs +++ b/lee/state_machine/core/src/program/mod.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::{ BlockId, Identifier, NullifierPublicKey, Timestamp, account::{Account, AccountId, AccountWithMetadata}, + encryption::ViewingPublicKey, }; pub const DEFAULT_PROGRAM_ID: ProgramId = [0; 8]; @@ -154,19 +155,21 @@ impl AccountId { program_id: &ProgramId, seed: &PdaSeed, npk: &NullifierPublicKey, + vpk: &ViewingPublicKey, identifier: Identifier, ) -> Self { use risc0_zkvm::sha::{Impl, Sha256 as _}; const PRIVATE_PDA_PREFIX: &[u8; 32] = b"/LEE/v0.3/AccountId/PrivatePDA/\x00"; - let mut bytes = [0_u8; 144]; + let mut bytes = [0_u8; 32 + 32 + 32 + 32 + ViewingPublicKey::LEN + 16]; bytes[0..32].copy_from_slice(PRIVATE_PDA_PREFIX); let program_id_bytes: &[u8] = bytemuck::try_cast_slice(program_id).expect("ProgramId should be castable to &[u8]"); bytes[32..64].copy_from_slice(program_id_bytes); bytes[64..96].copy_from_slice(&seed.0); bytes[96..128].copy_from_slice(&npk.to_byte_array()); - bytes[128..144].copy_from_slice(&identifier.to_le_bytes()); + bytes[128..128 + ViewingPublicKey::LEN].copy_from_slice(vpk.to_bytes()); + bytes[128 + ViewingPublicKey::LEN..].copy_from_slice(&identifier.to_le_bytes()); Self::new( Impl::hash_bytes(&bytes) .as_bytes() @@ -177,16 +180,20 @@ impl AccountId { /// Derives the [`AccountId`] for a private account from the nullifier public key and kind. #[must_use] - pub fn for_private_account(npk: &NullifierPublicKey, kind: &PrivateAccountKind) -> Self { + pub fn for_private_account( + npk: &NullifierPublicKey, + vpk: &ViewingPublicKey, + kind: &PrivateAccountKind, + ) -> Self { match kind { PrivateAccountKind::Regular(identifier) => { - Self::for_regular_private_account(npk, *identifier) + Self::for_regular_private_account(npk, vpk, *identifier) } PrivateAccountKind::Pda { program_id, seed, identifier, - } => Self::for_private_pda(program_id, seed, npk, *identifier), + } => Self::for_private_pda(program_id, seed, npk, vpk, *identifier), } } } @@ -230,7 +237,7 @@ impl ChainedCall { /// Represents the final state of an `Account` after a program execution. /// /// A post state may optionally request that the executing program -/// becomes the owner of the account (a “claim”). This is used to signal +/// becomes the owner of the account (a "claim"). This is used to signal /// that the program intends to take ownership of the account. #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(any(feature = "host", test), derive(PartialEq, Eq))] @@ -317,7 +324,7 @@ impl AccountPostState { pub type BlockValidityWindow = ValidityWindow; pub type TimestampValidityWindow = ValidityWindow; -#[derive(Clone, Copy, Serialize, Deserialize)] +#[derive(Clone, Copy, Default, Serialize, Deserialize)] #[cfg_attr( any(feature = "host", test), derive(Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize) @@ -766,338 +773,4 @@ fn validate_uniqueness_of_account_ids(pre_states: &[AccountWithMetadata]) -> boo } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn validity_window_unbounded_accepts_any_value() { - let w: ValidityWindow = ValidityWindow::new_unbounded(); - assert!(w.is_valid_for(0)); - assert!(w.is_valid_for(u64::MAX)); - } - - #[test] - fn validity_window_bounded_range_includes_from_excludes_to() { - let w: ValidityWindow = (Some(5), Some(10)).try_into().unwrap(); - assert!(!w.is_valid_for(4)); - assert!(w.is_valid_for(5)); - assert!(w.is_valid_for(9)); - assert!(!w.is_valid_for(10)); - } - - #[test] - fn validity_window_only_from_bound() { - let w: ValidityWindow = (Some(5), None).try_into().unwrap(); - assert!(!w.is_valid_for(4)); - assert!(w.is_valid_for(5)); - assert!(w.is_valid_for(u64::MAX)); - } - - #[test] - fn validity_window_only_to_bound() { - let w: ValidityWindow = (None, Some(5)).try_into().unwrap(); - assert!(w.is_valid_for(0)); - assert!(w.is_valid_for(4)); - assert!(!w.is_valid_for(5)); - } - - #[test] - fn validity_window_adjacent_bounds_are_invalid() { - // [5, 5) is an empty range — from == to - assert!(ValidityWindow::::try_from((Some(5), Some(5))).is_err()); - } - - #[test] - fn validity_window_inverted_bounds_are_invalid() { - assert!(ValidityWindow::::try_from((Some(10), Some(5))).is_err()); - } - - #[test] - fn validity_window_getters_match_construction() { - let w: ValidityWindow = (Some(3), Some(7)).try_into().unwrap(); - assert_eq!(w.start(), Some(3)); - assert_eq!(w.end(), Some(7)); - } - - #[test] - fn validity_window_getters_for_unbounded() { - let w: ValidityWindow = ValidityWindow::new_unbounded(); - assert_eq!(w.start(), None); - assert_eq!(w.end(), None); - } - - #[test] - fn validity_window_from_range() { - let w: ValidityWindow = ValidityWindow::try_from(5_u64..10).unwrap(); - assert_eq!(w.start(), Some(5)); - assert_eq!(w.end(), Some(10)); - } - - #[test] - fn validity_window_from_range_empty_is_invalid() { - assert!(ValidityWindow::::try_from(5_u64..5).is_err()); - } - - #[test] - fn validity_window_from_range_inverted_is_invalid() { - let from = 10_u64; - let to = 5_u64; - assert!(ValidityWindow::::try_from(from..to).is_err()); - } - - #[test] - fn validity_window_from_range_from() { - let w: ValidityWindow = (5_u64..).into(); - assert_eq!(w.start(), Some(5)); - assert_eq!(w.end(), None); - } - - #[test] - fn validity_window_from_range_to() { - let w: ValidityWindow = (..10_u64).into(); - assert_eq!(w.start(), None); - assert_eq!(w.end(), Some(10)); - } - - #[test] - fn validity_window_from_range_full() { - let w: ValidityWindow = (..).into(); - assert_eq!(w.start(), None); - assert_eq!(w.end(), None); - } - - #[test] - fn program_output_try_with_block_validity_window_range() { - let output = ProgramOutput::new(DEFAULT_PROGRAM_ID, None, vec![], vec![], vec![]) - .try_with_block_validity_window(10_u64..100) - .unwrap(); - assert_eq!(output.block_validity_window.start(), Some(10)); - assert_eq!(output.block_validity_window.end(), Some(100)); - } - - #[test] - fn program_output_with_block_validity_window_range_from() { - let output = ProgramOutput::new(DEFAULT_PROGRAM_ID, None, vec![], vec![], vec![]) - .with_block_validity_window(10_u64..); - assert_eq!(output.block_validity_window.start(), Some(10)); - assert_eq!(output.block_validity_window.end(), None); - } - - #[test] - fn program_output_with_block_validity_window_range_to() { - let output = ProgramOutput::new(DEFAULT_PROGRAM_ID, None, vec![], vec![], vec![]) - .with_block_validity_window(..100_u64); - assert_eq!(output.block_validity_window.start(), None); - assert_eq!(output.block_validity_window.end(), Some(100)); - } - - #[test] - fn program_output_try_with_block_validity_window_empty_range_fails() { - let result = ProgramOutput::new(DEFAULT_PROGRAM_ID, None, vec![], vec![], vec![]) - .try_with_block_validity_window(5_u64..5); - assert!(result.is_err()); - } - - #[test] - fn post_state_new_with_claim_constructor() { - let account = Account { - program_owner: [1, 2, 3, 4, 5, 6, 7, 8], - balance: 1337, - data: vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(), - nonce: 10_u128.into(), - }; - - let account_post_state = AccountPostState::new_claimed(account.clone(), Claim::Authorized); - - assert_eq!(account, account_post_state.account); - assert_eq!(account_post_state.required_claim(), Some(Claim::Authorized)); - } - - #[test] - fn post_state_new_without_claim_constructor() { - let account = Account { - program_owner: [1, 2, 3, 4, 5, 6, 7, 8], - balance: 1337, - data: vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(), - nonce: 10_u128.into(), - }; - - let account_post_state = AccountPostState::new(account.clone()); - - assert_eq!(account, account_post_state.account); - assert!(account_post_state.required_claim().is_none()); - } - - #[test] - fn post_state_account_getter() { - let mut account = Account { - program_owner: [1, 2, 3, 4, 5, 6, 7, 8], - balance: 1337, - data: vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(), - nonce: 10_u128.into(), - }; - - let mut account_post_state = AccountPostState::new(account.clone()); - - assert_eq!(account_post_state.account(), &account); - assert_eq!(account_post_state.account_mut(), &mut account); - } - - // ---- AccountId::for_private_pda tests ---- - - /// Pins `AccountId::for_private_pda` against a hardcoded expected output for a specific - /// `(program_id, seed, npk, identifier)` tuple. Any change to `PRIVATE_PDA_PREFIX`, byte - /// ordering, or the underlying hash breaks this test. - #[test] - fn for_private_pda_matches_pinned_value() { - let program_id: ProgramId = [1; 8]; - let seed = PdaSeed::new([2; 32]); - let npk = NullifierPublicKey([3; 32]); - let identifier: Identifier = u128::MAX; - let expected = AccountId::new([ - 59, 239, 182, 97, 14, 220, 96, 115, 238, 133, 143, 33, 234, 82, 237, 255, 148, 110, 54, - 124, 98, 159, 245, 101, 146, 182, 150, 54, 37, 62, 25, 17, - ]); - assert_eq!( - AccountId::for_private_pda(&program_id, &seed, &npk, identifier), - expected - ); - } - - /// Two groups with different viewing keys at the same (program, seed) get different addresses. - #[test] - fn for_private_pda_differs_for_different_npk() { - let program_id: ProgramId = [1; 8]; - let seed = PdaSeed::new([2; 32]); - let npk_a = NullifierPublicKey([3; 32]); - let npk_b = NullifierPublicKey([4; 32]); - assert_ne!( - AccountId::for_private_pda(&program_id, &seed, &npk_a, u128::MAX), - AccountId::for_private_pda(&program_id, &seed, &npk_b, u128::MAX), - ); - } - - /// Different seeds produce different addresses, even with the same program and npk. - #[test] - fn for_private_pda_differs_for_different_seed() { - let program_id: ProgramId = [1; 8]; - let seed_a = PdaSeed::new([2; 32]); - let seed_b = PdaSeed::new([5; 32]); - let npk = NullifierPublicKey([3; 32]); - assert_ne!( - AccountId::for_private_pda(&program_id, &seed_a, &npk, u128::MAX), - AccountId::for_private_pda(&program_id, &seed_b, &npk, u128::MAX), - ); - } - - /// Different programs produce different addresses, even with the same seed and npk. - #[test] - fn for_private_pda_differs_for_different_program_id() { - let program_id_a: ProgramId = [1; 8]; - let program_id_b: ProgramId = [9; 8]; - let seed = PdaSeed::new([2; 32]); - let npk = NullifierPublicKey([3; 32]); - assert_ne!( - AccountId::for_private_pda(&program_id_a, &seed, &npk, u128::MAX), - AccountId::for_private_pda(&program_id_b, &seed, &npk, u128::MAX), - ); - } - - /// Different identifiers produce different addresses for the same `(program_id, seed, npk)`, - /// confirming that each `(program_id, seed, npk)` tuple controls a family of 2^128 addresses. - #[test] - fn for_private_pda_differs_for_different_identifier() { - let program_id: ProgramId = [1; 8]; - let seed = PdaSeed::new([2; 32]); - let npk = NullifierPublicKey([3; 32]); - assert_ne!( - AccountId::for_private_pda(&program_id, &seed, &npk, 0), - AccountId::for_private_pda(&program_id, &seed, &npk, 1), - ); - assert_ne!( - AccountId::for_private_pda(&program_id, &seed, &npk, 0), - AccountId::for_private_pda(&program_id, &seed, &npk, u128::MAX), - ); - } - - /// A private PDA at the same (program, seed) has a different address than a public PDA, - /// because the private formula uses a different prefix and includes npk. - #[test] - fn for_private_pda_differs_from_public_pda() { - let program_id: ProgramId = [1; 8]; - let seed = PdaSeed::new([2; 32]); - let npk = NullifierPublicKey([3; 32]); - let private_id = AccountId::for_private_pda(&program_id, &seed, &npk, u128::MAX); - let public_id = AccountId::for_public_pda(&program_id, &seed); - assert_ne!(private_id, public_id); - } - - #[cfg(feature = "host")] - #[test] - fn private_account_kind_header_round_trips() { - let regular = PrivateAccountKind::Regular(42); - let pda = PrivateAccountKind::Pda { - program_id: [1_u32; 8], - seed: PdaSeed::new([2_u8; 32]), - identifier: u128::MAX, - }; - assert_eq!( - PrivateAccountKind::from_header_bytes(®ular.to_header_bytes()), - Some(regular) - ); - assert_eq!( - PrivateAccountKind::from_header_bytes(&pda.to_header_bytes()), - Some(pda) - ); - } - - #[cfg(feature = "host")] - #[test] - fn private_account_kind_unknown_discriminant_returns_none() { - let mut bytes = [0_u8; PrivateAccountKind::HEADER_LEN]; - bytes[0] = 0xFF; - assert_eq!(PrivateAccountKind::from_header_bytes(&bytes), None); - } - - #[test] - fn for_private_account_dispatches_correctly() { - let program_id: ProgramId = [1; 8]; - let seed = PdaSeed::new([2; 32]); - let npk = NullifierPublicKey([3; 32]); - let identifier: Identifier = 77; - - assert_eq!( - AccountId::for_private_account(&npk, &PrivateAccountKind::Regular(identifier)), - AccountId::for_regular_private_account(&npk, identifier), - ); - assert_eq!( - AccountId::for_private_account( - &npk, - &PrivateAccountKind::Pda { - program_id, - seed, - identifier - } - ), - AccountId::for_private_pda(&program_id, &seed, &npk, identifier), - ); - } - - #[test] - fn compute_public_authorized_pdas_with_seeds() { - let caller: ProgramId = [1; 8]; - let seed = PdaSeed::new([2; 32]); - let result = compute_public_authorized_pdas(Some(caller), &[seed]); - let expected = AccountId::for_public_pda(&caller, &seed); - assert!(result.contains(&expected)); - assert_eq!(result.len(), 1); - } - - /// With no caller (top-level call), the result is always empty. - #[test] - fn compute_public_authorized_pdas_no_caller_returns_empty() { - let seed = PdaSeed::new([2; 32]); - let result = compute_public_authorized_pdas(None, &[seed]); - assert!(result.is_empty()); - } -} +mod tests; diff --git a/lee/state_machine/core/src/program/tests.rs b/lee/state_machine/core/src/program/tests.rs new file mode 100644 index 00000000..19a259a8 --- /dev/null +++ b/lee/state_machine/core/src/program/tests.rs @@ -0,0 +1,341 @@ +use super::*; + +#[test] +fn validity_window_unbounded_accepts_any_value() { + let w: ValidityWindow = ValidityWindow::new_unbounded(); + assert!(w.is_valid_for(0)); + assert!(w.is_valid_for(u64::MAX)); +} + +#[test] +fn validity_window_bounded_range_includes_from_excludes_to() { + let w: ValidityWindow = (Some(5), Some(10)).try_into().unwrap(); + assert!(!w.is_valid_for(4)); + assert!(w.is_valid_for(5)); + assert!(w.is_valid_for(9)); + assert!(!w.is_valid_for(10)); +} + +#[test] +fn validity_window_only_from_bound() { + let w: ValidityWindow = (Some(5), None).try_into().unwrap(); + assert!(!w.is_valid_for(4)); + assert!(w.is_valid_for(5)); + assert!(w.is_valid_for(u64::MAX)); +} + +#[test] +fn validity_window_only_to_bound() { + let w: ValidityWindow = (None, Some(5)).try_into().unwrap(); + assert!(w.is_valid_for(0)); + assert!(w.is_valid_for(4)); + assert!(!w.is_valid_for(5)); +} + +#[test] +fn validity_window_adjacent_bounds_are_invalid() { + // [5, 5) is an empty range — from == to + assert!(ValidityWindow::::try_from((Some(5), Some(5))).is_err()); +} + +#[test] +fn validity_window_inverted_bounds_are_invalid() { + assert!(ValidityWindow::::try_from((Some(10), Some(5))).is_err()); +} + +#[test] +fn validity_window_getters_match_construction() { + let w: ValidityWindow = (Some(3), Some(7)).try_into().unwrap(); + assert_eq!(w.start(), Some(3)); + assert_eq!(w.end(), Some(7)); +} + +#[test] +fn validity_window_getters_for_unbounded() { + let w: ValidityWindow = ValidityWindow::new_unbounded(); + assert_eq!(w.start(), None); + assert_eq!(w.end(), None); +} + +#[test] +fn validity_window_from_range() { + let w: ValidityWindow = ValidityWindow::try_from(5_u64..10).unwrap(); + assert_eq!(w.start(), Some(5)); + assert_eq!(w.end(), Some(10)); +} + +#[test] +fn validity_window_from_range_empty_is_invalid() { + assert!(ValidityWindow::::try_from(5_u64..5).is_err()); +} + +#[test] +fn validity_window_from_range_inverted_is_invalid() { + let from = 10_u64; + let to = 5_u64; + assert!(ValidityWindow::::try_from(from..to).is_err()); +} + +#[test] +fn validity_window_from_range_from() { + let w: ValidityWindow = (5_u64..).into(); + assert_eq!(w.start(), Some(5)); + assert_eq!(w.end(), None); +} + +#[test] +fn validity_window_from_range_to() { + let w: ValidityWindow = (..10_u64).into(); + assert_eq!(w.start(), None); + assert_eq!(w.end(), Some(10)); +} + +#[test] +fn validity_window_from_range_full() { + let w: ValidityWindow = (..).into(); + assert_eq!(w.start(), None); + assert_eq!(w.end(), None); +} + +#[test] +fn program_output_try_with_block_validity_window_range() { + let output = ProgramOutput::new(DEFAULT_PROGRAM_ID, None, vec![], vec![], vec![]) + .try_with_block_validity_window(10_u64..100) + .unwrap(); + assert_eq!(output.block_validity_window.start(), Some(10)); + assert_eq!(output.block_validity_window.end(), Some(100)); +} + +#[test] +fn program_output_with_block_validity_window_range_from() { + let output = ProgramOutput::new(DEFAULT_PROGRAM_ID, None, vec![], vec![], vec![]) + .with_block_validity_window(10_u64..); + assert_eq!(output.block_validity_window.start(), Some(10)); + assert_eq!(output.block_validity_window.end(), None); +} + +#[test] +fn program_output_with_block_validity_window_range_to() { + let output = ProgramOutput::new(DEFAULT_PROGRAM_ID, None, vec![], vec![], vec![]) + .with_block_validity_window(..100_u64); + assert_eq!(output.block_validity_window.start(), None); + assert_eq!(output.block_validity_window.end(), Some(100)); +} + +#[test] +fn program_output_try_with_block_validity_window_empty_range_fails() { + let result = ProgramOutput::new(DEFAULT_PROGRAM_ID, None, vec![], vec![], vec![]) + .try_with_block_validity_window(5_u64..5); + assert!(result.is_err()); +} + +#[test] +fn post_state_new_with_claim_constructor() { + let account = Account { + program_owner: [1, 2, 3, 4, 5, 6, 7, 8], + balance: 1337, + data: vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(), + nonce: 10_u128.into(), + }; + + let account_post_state = AccountPostState::new_claimed(account.clone(), Claim::Authorized); + + assert_eq!(account, account_post_state.account); + assert_eq!(account_post_state.required_claim(), Some(Claim::Authorized)); +} + +#[test] +fn post_state_new_without_claim_constructor() { + let account = Account { + program_owner: [1, 2, 3, 4, 5, 6, 7, 8], + balance: 1337, + data: vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(), + nonce: 10_u128.into(), + }; + + let account_post_state = AccountPostState::new(account.clone()); + + assert_eq!(account, account_post_state.account); + assert!(account_post_state.required_claim().is_none()); +} + +#[test] +fn post_state_account_getter() { + let mut account = Account { + program_owner: [1, 2, 3, 4, 5, 6, 7, 8], + balance: 1337, + data: vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(), + nonce: 10_u128.into(), + }; + + let mut account_post_state = AccountPostState::new(account.clone()); + + assert_eq!(account_post_state.account(), &account); + assert_eq!(account_post_state.account_mut(), &mut account); +} + +// ---- AccountId::for_private_pda tests ---- + +/// Pins `AccountId::for_private_pda` against a hardcoded expected output for a specific +/// `(program_id, seed, npk, identifier)` tuple. Any change to `PRIVATE_PDA_PREFIX`, byte +/// ordering, or the underlying hash breaks this test. +#[test] +fn for_private_pda_matches_pinned_value() { + let program_id: ProgramId = [1; 8]; + let seed = PdaSeed::new([2; 32]); + let npk = NullifierPublicKey([3; 32]); + let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]); + let identifier: Identifier = u128::MAX; + let expected = AccountId::new([ + 5, 87, 128, 244, 206, 244, 65, 130, 178, 88, 225, 183, 0, 159, 201, 201, 212, 206, 6, 156, + 13, 55, 32, 139, 91, 222, 209, 83, 172, 148, 123, 179, + ]); + assert_eq!( + AccountId::for_private_pda(&program_id, &seed, &npk, &vpk, identifier), + expected + ); +} + +/// Two groups with different viewing keys at the same (program, seed) get different addresses. +#[test] +fn for_private_pda_differs_for_different_npk() { + let program_id: ProgramId = [1; 8]; + let seed = PdaSeed::new([2; 32]); + let npk_a = NullifierPublicKey([3; 32]); + let npk_b = NullifierPublicKey([4; 32]); + let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]); + assert_ne!( + AccountId::for_private_pda(&program_id, &seed, &npk_a, &vpk, u128::MAX), + AccountId::for_private_pda(&program_id, &seed, &npk_b, &vpk, u128::MAX), + ); +} + +/// Different seeds produce different addresses, even with the same program and npk. +#[test] +fn for_private_pda_differs_for_different_seed() { + let program_id: ProgramId = [1; 8]; + let seed_a = PdaSeed::new([2; 32]); + let seed_b = PdaSeed::new([5; 32]); + let npk = NullifierPublicKey([3; 32]); + let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]); + assert_ne!( + AccountId::for_private_pda(&program_id, &seed_a, &npk, &vpk, u128::MAX), + AccountId::for_private_pda(&program_id, &seed_b, &npk, &vpk, u128::MAX), + ); +} + +/// Different programs produce different addresses, even with the same seed and npk. +#[test] +fn for_private_pda_differs_for_different_program_id() { + let program_id_a: ProgramId = [1; 8]; + let program_id_b: ProgramId = [9; 8]; + let seed = PdaSeed::new([2; 32]); + let npk = NullifierPublicKey([3; 32]); + let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]); + assert_ne!( + AccountId::for_private_pda(&program_id_a, &seed, &npk, &vpk, u128::MAX), + AccountId::for_private_pda(&program_id_b, &seed, &npk, &vpk, u128::MAX), + ); +} + +/// Different identifiers produce different addresses for the same `(program_id, seed, npk)`, +/// confirming that each `(program_id, seed, npk)` tuple controls a family of 2^128 addresses. +#[test] +fn for_private_pda_differs_for_different_identifier() { + let program_id: ProgramId = [1; 8]; + let seed = PdaSeed::new([2; 32]); + let npk = NullifierPublicKey([3; 32]); + let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]); + assert_ne!( + AccountId::for_private_pda(&program_id, &seed, &npk, &vpk, 0), + AccountId::for_private_pda(&program_id, &seed, &npk, &vpk, 1), + ); + assert_ne!( + AccountId::for_private_pda(&program_id, &seed, &npk, &vpk, 0), + AccountId::for_private_pda(&program_id, &seed, &npk, &vpk, u128::MAX), + ); +} + +/// A private PDA at the same (program, seed) has a different address than a public PDA, +/// because the private formula uses a different prefix and includes npk. +#[test] +fn for_private_pda_differs_from_public_pda() { + let program_id: ProgramId = [1; 8]; + let seed = PdaSeed::new([2; 32]); + let npk = NullifierPublicKey([3; 32]); + let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]); + let private_id = AccountId::for_private_pda(&program_id, &seed, &npk, &vpk, u128::MAX); + let public_id = AccountId::for_public_pda(&program_id, &seed); + assert_ne!(private_id, public_id); +} + +#[cfg(feature = "host")] +#[test] +fn private_account_kind_header_round_trips() { + let regular = PrivateAccountKind::Regular(42); + let pda = PrivateAccountKind::Pda { + program_id: [1_u32; 8], + seed: PdaSeed::new([2_u8; 32]), + identifier: u128::MAX, + }; + assert_eq!( + PrivateAccountKind::from_header_bytes(®ular.to_header_bytes()), + Some(regular) + ); + assert_eq!( + PrivateAccountKind::from_header_bytes(&pda.to_header_bytes()), + Some(pda) + ); +} + +#[cfg(feature = "host")] +#[test] +fn private_account_kind_unknown_discriminant_returns_none() { + let mut bytes = [0_u8; PrivateAccountKind::HEADER_LEN]; + bytes[0] = 0xFF; + assert_eq!(PrivateAccountKind::from_header_bytes(&bytes), None); +} + +#[test] +fn for_private_account_dispatches_correctly() { + let program_id: ProgramId = [1; 8]; + let seed = PdaSeed::new([2; 32]); + let npk = NullifierPublicKey([3; 32]); + let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]); + let identifier: Identifier = 77; + + assert_eq!( + AccountId::for_private_account(&npk, &vpk, &PrivateAccountKind::Regular(identifier)), + AccountId::for_regular_private_account(&npk, &vpk, identifier), + ); + assert_eq!( + AccountId::for_private_account( + &npk, + &vpk, + &PrivateAccountKind::Pda { + program_id, + seed, + identifier + } + ), + AccountId::for_private_pda(&program_id, &seed, &npk, &vpk, identifier), + ); +} + +#[test] +fn compute_public_authorized_pdas_with_seeds() { + let caller: ProgramId = [1; 8]; + let seed = PdaSeed::new([2; 32]); + let result = compute_public_authorized_pdas(Some(caller), &[seed]); + let expected = AccountId::for_public_pda(&caller, &seed); + assert!(result.contains(&expected)); + assert_eq!(result.len(), 1); +} + +/// With no caller (top-level call), the result is always empty. +#[test] +fn compute_public_authorized_pdas_no_caller_returns_empty() { + let seed = PdaSeed::new([2; 32]); + let result = compute_public_authorized_pdas(None, &[seed]); + assert!(result.is_empty()); +} diff --git a/lee/state_machine/src/error.rs b/lee/state_machine/src/error.rs index 2f073746..94e66e8b 100644 --- a/lee/state_machine/src/error.rs +++ b/lee/state_machine/src/error.rs @@ -131,6 +131,11 @@ pub enum InvalidProgramBehaviorError { #[error("Called program {program_id:?} which is not listed in dependencies")] UndeclaredProgramDependency { program_id: ProgramId }, + + #[error( + "Account {account_id} was declared in the transaction but is missing from the program output" + )] + DeclaredAccountMissingFromOutput { account_id: AccountId }, } #[cfg(test)] diff --git a/lee/state_machine/src/lib.rs b/lee/state_machine/src/lib.rs index f8cc034a..9886127b 100644 --- a/lee/state_machine/src/lib.rs +++ b/lee/state_machine/src/lib.rs @@ -5,7 +5,7 @@ pub use lee_core::{ GENESIS_BLOCK_ID, SharedSecretKey, - account::{Account, AccountId, Data}, + account::{Account, AccountId, Balance, Data}, encryption::EphemeralPublicKey, program::ProgramId, }; @@ -30,6 +30,8 @@ pub mod program_deployment_transaction; pub mod public_transaction; mod signature; mod state; +#[cfg(feature = "test-utils")] +pub mod test_utils; mod validated_state_diff; mod privacy_preserving_circuit { @@ -77,6 +79,14 @@ mod test_methods { ) } + #[must_use] + pub const fn dropped_account() -> Program { + Program::new_unchecked( + test_methods::DROPPED_ACCOUNT_ID, + Cow::Borrowed(test_methods::DROPPED_ACCOUNT_ELF), + ) + } + #[must_use] pub const fn program_owner_changer() -> Program { Program::new_unchecked( diff --git a/lee/state_machine/src/merkle_tree/mod.rs b/lee/state_machine/src/merkle_tree/mod.rs index e439d092..37e706f7 100644 --- a/lee/state_machine/src/merkle_tree/mod.rs +++ b/lee/state_machine/src/merkle_tree/mod.rs @@ -8,8 +8,8 @@ mod default_values; type Value = [u8; 32]; type Node = [u8; 32]; -#[cfg_attr(test, derive(Debug, PartialEq, Eq))] -#[derive(Clone, BorshSerialize, BorshDeserialize)] +#[cfg_attr(test, derive(Debug))] +#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct MerkleTree { nodes: Vec, capacity: usize, @@ -164,398 +164,4 @@ const fn prev_power_of_two(x: usize) -> usize { } #[cfg(test)] -mod tests { - use hex_literal::hex; - - use super::*; - - impl MerkleTree { - pub fn new(values: &[Value]) -> Self { - let mut this = Self::with_capacity(values.len()); - for value in values.iter().copied() { - this.insert(value); - } - this - } - } - - #[test] - fn empty_merkle_tree() { - let tree = MerkleTree::with_capacity(4); - let expected_root = - hex!("0000000000000000000000000000000000000000000000000000000000000000"); - assert_eq!(tree.root(), expected_root); - assert_eq!(tree.capacity, 4); - assert_eq!(tree.length, 0); - } - - #[test] - fn merkle_tree_0() { - let values = [[0; 32]]; - let tree = MerkleTree::new(&values); - assert_eq!(tree.root(), hash_value(&[0; 32])); - assert_eq!(tree.capacity, 1); - assert_eq!(tree.length, 1); - } - - #[test] - fn merkle_tree_1() { - let values = [[1; 32], [2; 32], [3; 32], [4; 32]]; - let tree = MerkleTree::new(&values); - let expected_root = - hex!("48c73f7821a58a8d2a703e5b39c571c0aa20cf14abcd0af8f2b955bc202998de"); - assert_eq!(tree.root(), expected_root); - assert_eq!(tree.capacity, 4); - assert_eq!(tree.length, 4); - } - - #[test] - fn merkle_tree_2() { - let values = [[1; 32], [2; 32], [3; 32], [0; 32]]; - let tree = MerkleTree::new(&values); - let expected_root = - hex!("c9bbb83096df85157a146e7d770455a98412dee0633187ee86fee6c8a45b831a"); - assert_eq!(tree.root(), expected_root); - assert_eq!(tree.capacity, 4); - assert_eq!(tree.length, 4); - } - - #[test] - fn merkle_tree_3() { - let values = [[1; 32], [2; 32], [3; 32]]; - let tree = MerkleTree::new(&values); - let expected_root = - hex!("c8d3d8d2b13f27ceeccdc699119871f9f32ea7ed86ff45d0ad11f77b28cd7568"); - assert_eq!(tree.root(), expected_root); - assert_eq!(tree.capacity, 4); - assert_eq!(tree.length, 3); - } - - #[test] - fn merkle_tree_4() { - let values = [[11; 32], [12; 32], [13; 32], [14; 32], [15; 32]]; - let tree = MerkleTree::new(&values); - let expected_root = - hex!("ef418aed5aa20702d4d94c92da79a4012f2e36f1008bfdb3cd1e38749dca2499"); - - assert_eq!(tree.root(), expected_root); - assert_eq!(tree.capacity, 8); - assert_eq!(tree.length, 5); - } - - #[test] - fn merkle_tree_5() { - let values = [ - [11; 32], [12; 32], [12; 32], [13; 32], [14; 32], [15; 32], [15; 32], [13; 32], - [13; 32], [15; 32], [11; 32], - ]; - let tree = MerkleTree::new(&values); - let expected_root = - hex!("3f72d2ff55921a86c48e5988ec3e19ee9d0d5aa3e23197842970a903508ed767"); - assert_eq!(tree.root(), expected_root); - assert_eq!(tree.capacity, 16); - assert_eq!(tree.length, 11); - } - - #[test] - fn merkle_tree_6() { - let values = [[1; 32], [2; 32], [3; 32], [4; 32], [5; 32]]; - let tree = MerkleTree::new(&values); - let expected_root = - hex!("069cb8259a06fe6edb3fa7ff7933a6dd7dca6fca299314379794a688926c3792"); - assert_eq!(tree.root(), expected_root); - } - - #[test] - fn with_capacity_4() { - let tree = MerkleTree::with_capacity(4); - - assert_eq!(tree.length, 0); - assert_eq!(tree.nodes.len(), 7); - for i in 3..7 { - assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[0], "{i}"); - } - for i in 1..3 { - assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[1], "{i}"); - } - assert_eq!(*tree.get_node(0), default_values::DEFAULT_VALUES[2]); - } - - #[test] - fn with_capacity_5() { - let tree = MerkleTree::with_capacity(5); - - assert_eq!(tree.length, 0); - assert_eq!(tree.nodes.len(), 15); - for i in 7..15 { - assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[0]); - } - for i in 3..7 { - assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[1]); - } - for i in 1..3 { - assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[2]); - } - assert_eq!(*tree.get_node(0), default_values::DEFAULT_VALUES[3]); - } - - #[test] - fn with_capacity_6() { - let mut tree = MerkleTree::with_capacity(100); - - let values = [[1; 32], [2; 32], [3; 32], [4; 32]]; - - let expected_root = - hex!("48c73f7821a58a8d2a703e5b39c571c0aa20cf14abcd0af8f2b955bc202998de"); - - assert_eq!(0, tree.insert(values[0])); - assert_eq!(1, tree.insert(values[1])); - assert_eq!(2, tree.insert(values[2])); - assert_eq!(3, tree.insert(values[3])); - - assert_eq!(tree.root(), expected_root); - } - - #[test] - fn with_capacity_7() { - let mut tree = MerkleTree::with_capacity(599); - - let values = [[1; 32], [2; 32], [3; 32]]; - - let expected_root = - hex!("c8d3d8d2b13f27ceeccdc699119871f9f32ea7ed86ff45d0ad11f77b28cd7568"); - - assert_eq!(0, tree.insert(values[0])); - assert_eq!(1, tree.insert(values[1])); - assert_eq!(2, tree.insert(values[2])); - - assert_eq!(tree.root(), expected_root); - } - - #[test] - fn with_capacity_8() { - let mut tree = MerkleTree::with_capacity(1); - - let values = [[1; 32], [2; 32], [3; 32]]; - - let expected_root = - hex!("c8d3d8d2b13f27ceeccdc699119871f9f32ea7ed86ff45d0ad11f77b28cd7568"); - - assert_eq!(0, tree.insert(values[0])); - assert_eq!(1, tree.insert(values[1])); - assert_eq!(2, tree.insert(values[2])); - - assert_eq!(tree.root(), expected_root); - } - - #[test] - fn insert_value_1() { - let mut tree = MerkleTree::with_capacity(1); - - let values = [[1; 32], [2; 32], [3; 32]]; - let expected_tree = MerkleTree::new(&values); - - assert_eq!(0, tree.insert(values[0])); - assert_eq!(1, tree.insert(values[1])); - assert_eq!(2, tree.insert(values[2])); - - assert_eq!(expected_tree, tree); - } - - #[test] - fn insert_value_2() { - let mut tree = MerkleTree::with_capacity(1); - - let values = [[1; 32], [2; 32], [3; 32], [4; 32]]; - let expected_tree = MerkleTree::new(&values); - - assert_eq!(0, tree.insert(values[0])); - assert_eq!(1, tree.insert(values[1])); - assert_eq!(2, tree.insert(values[2])); - assert_eq!(3, tree.insert(values[3])); - - assert_eq!(expected_tree, tree); - } - - #[test] - fn insert_value_3() { - let mut tree = MerkleTree::with_capacity(1); - - let values = [[11; 32], [12; 32], [13; 32], [14; 32], [15; 32]]; - let expected_tree = MerkleTree::new(&values); - - tree.insert(values[0]); - tree.insert(values[1]); - tree.insert(values[2]); - tree.insert(values[3]); - tree.insert(values[4]); - - assert_eq!(expected_tree, tree); - } - - // Reference implementation - fn verify_authentication_path(value: &Value, index: usize, path: &[Node], root: &Node) -> bool { - let mut result = hash_value(value); - let mut level_index = index; - for node in path { - let is_left_child = level_index & 1 == 0; - if is_left_child { - result = hash_two(&result, node); - } else { - result = hash_two(node, &result); - } - level_index >>= 1; - } - &result == root - } - - #[test] - fn authentication_path_1() { - let values = [[1; 32], [2; 32], [3; 32], [4; 32]]; - let tree = MerkleTree::new(&values); - let expected_authentication_path = vec![ - hex!("9f4fb68f3e1dac82202f9aa581ce0bbf1f765df0e9ac3c8c57e20f685abab8ed"), - hex!("50a27d4746f357cb700cbe9d4883b77fb64f0128828a3489dc6a6f21ddbf2414"), - ]; - - let authentication_path = tree.get_authentication_path_for(2).unwrap(); - assert_eq!(authentication_path, expected_authentication_path); - } - - #[test] - fn authentication_path_2() { - let values = [[1; 32], [2; 32], [3; 32]]; - let tree = MerkleTree::new(&values); - let expected_authentication_path = vec![ - hex!("75877bb41d393b5fb8455ce60ecd8dda001d06316496b14dfa7f895656eeca4a"), - hex!("a41b855d2db4de9052cd7be5ec67d6586629cb9f6e3246a4afa5ba313f07a9c5"), - ]; - - let authentication_path = tree.get_authentication_path_for(0).unwrap(); - assert_eq!(authentication_path, expected_authentication_path); - } - - #[test] - fn authentication_path_3() { - let values = [[1; 32], [2; 32], [3; 32], [4; 32], [5; 32]]; - let tree = MerkleTree::new(&values); - let expected_authentication_path = vec![ - hex!("0000000000000000000000000000000000000000000000000000000000000000"), - hex!("f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b"), - hex!("48c73f7821a58a8d2a703e5b39c571c0aa20cf14abcd0af8f2b955bc202998de"), - ]; - - let authentication_path = tree.get_authentication_path_for(4).unwrap(); - assert_eq!(authentication_path, expected_authentication_path); - } - - #[test] - fn authentication_path_4() { - let values = [[1; 32], [2; 32], [3; 32], [4; 32], [5; 32]]; - let tree = MerkleTree::new(&values); - assert!(tree.get_authentication_path_for(5).is_none()); - } - - #[test] - fn authentication_path_5() { - let values = [[1; 32], [2; 32], [3; 32], [4; 32], [5; 32]]; - let tree = MerkleTree::new(&values); - let index = 4; - let value = values[index]; - let path = tree.get_authentication_path_for(index).unwrap(); - assert!(verify_authentication_path( - &value, - index, - &path, - &tree.root() - )); - } - - #[test] - fn tree_with_63_insertions() { - let values = [ - hex!("cd00acab0f45736e6c6311f1953becc0b69a062e7c2a7310875d28bdf9ef9c5b"), - hex!("0df5a6afbcc7bf126caf7084acfc593593ab512e6ca433c61c1a922be40a04ea"), - hex!("23c1258620266c7bedb6d1ee32f6da9413e4010ace975239dccb34e727e07c40"), - hex!("f33ccc3a11476b0ef62326ca5ec292056759b05e6a28023d2d1ce66165611353"), - hex!("77f914ab016b8049f6bea7704000e413a393865918a3824f9285c3db0aacff23"), - hex!("910a1c23188e54d57fd167ddb0f8bf68c6b70ed9ec76ef56c4b7f2632f82ca7f"), - hex!("047ee85526197d1e7403a559cf6d2f22c1926c8ad59481a2e2f1b697af45e40b"), - hex!("9d355cf89fb382ae34bf80566b28489278d10f2cebb5b0ea42fab1bac5adae0c"), - hex!("604018b95232596b2685a9bc737b6cccb53b10e483d2d9a2f4a755410b02a188"), - hex!("a16708ef7b6bf1796063addaf57d6a566b6f87b0bbe42af43a4590d05f1684cb"), - hex!("820f2dfa271cd2fd41e1452406d5dad552c85c1223c45d45dbd7446759fdc6b8"), - hex!("680b6912d7e219f8805d4d28adb4428dd78fea0dc1b8cdb2412645c4b1962c88"), - hex!("14d5471ce6c45506753982b17cac5790ac7bc29e6f388f31052d7dfd62b294e5"), - hex!("8b364200172b777d4aa16d2098b5eb98ac3dd4a1b9597e5c2bf6f6930031f230"), - hex!("9bb45b910711874339dda8a21a9aad73822286f5e52d7d3de0ed78dfbba329a5"), - hex!("d6806d5df5cb25ce5d531042f09b3cb34fb9e47c61182b63cccd9d44392f6027"), - hex!("b8cfa90ebc8fd09c04682d93a08fddd3e8e57715174dcc92451edd191264a58b"), - hex!("3463c7f81d00f809b3dfa83195447c927fb4045b3913dac6f45bee6c4010d7ed"), - hex!("1d6ad7f7d677905feb506c58f4b404a79370ebc567296abea3a368b61d5a8239"), - hex!("a58085ecf00963cb22da23c901b9b3ddc56462bb96ff03c923d67708e10dd29c"), - hex!("c3319f4a65fb5bbb8447137b0972c03cbd84ebf7d9da194e0fcbd68c2d4d5bdb"), - hex!("4aa31e90e0090faf3648d05e5d5499df2c78ebed4d6e6c23d8147de5d67dae73"), - hex!("9f33b1d2c8bc7bd265336de1033ede6344bc41260313bdcb43f1108b83b9be92"), - hex!("6500d4ad93d41c16ec81eaa5e70f173194aabe5c1072ac263b5727296f5b7cac"), - hex!("3584f5d260003669fad98786e13171376b0f19410cb232ce65606cbff79e6768"), - hex!("c8410946ebf56f13141c894a34ced85a5230088af70dcea581e44f52847830ac"), - hex!("71dd90281cdebb70422f2d04ae446d5d2d5ea64b803c16128d37e3fcd5d1a4cc"), - hex!("c05acf8d77ab4d659a538bd35af590864a7ad9c055ff5d6cda9d5aecfccecba3"), - hex!("f1df98822ea084cce9021aa9cc81b1746cd1e84a75690da63e10fd877633ed77"), - hex!("2ca822bc8f67bceb0a71a0d06fea7349036ef3e5ec21795a851e4182bd35ce01"), - hex!("7fd2179abc3bcf89b4d8092988ba8c23952b3bbd3d7caea6b5ea0c13cf19f68b"), - hex!("91b6ad516e017f6aa5a2e95776538bd3a3e933c1b1d32bb5e0f00a9db63c9c24"), - hex!("cd31a8b5eef5ca0be5ef1cb261d0bf0a74d774a3152bb99739cfd296a1d0b85e"), - hex!("3fb16f48b2bf93f3815979e6638f975d7f935088ec37db0be0f07965fbc78339"), - hex!("c60c61b99bf486af5f4bf780a69860dafcd35c1474306a8575666fb5449bcec0"), - hex!("8048d0d7e14091251f3f6c6b10bf6b5880a014b513f9f8c2395501dbffa6192a"), - hex!("778b5af10b9dbe80b60a8e4f0bb91caf4476bcb812801099760754ae623fbd84"), - hex!("d3ac25467920a4e08998b7a3226b8b54bfe66ac58cfedc71f15b2402fee0054a"), - hex!("029aa94598fae2961a0d43937b8a9a3138bcfeae99a7cb15f77fac7c506f8432"), - hex!("2eee5ef52fe669cb6882a68c893abdc1262dcf4424e4ba7a479da7cf1c10171d"), - hex!("de3fb3d070e3a90f0eed8b5e65088a8dc0e4e3c342b9c0bf33bab714eae5dfec"), - hex!("14d40177e833ab45bbfdc5f2b11fba7efaebb3f69facc554f24b549a2efe8538"), - hex!("5734355069702448774fb2df95f1d562e1b9fe1514aeb6b922554ee9d2d01068"), - hex!("8a273d49ac110343cec2cf3359d16eb2906b446bd9ec9833e2a640cebc8d5155"), - hex!("e3fa984dd3cbeb9a7e827ed32d3d4e6a6ba643a55d82be97d9ddb06ee809fa3e"), - hex!("90b1d5a364e17c8b7965396b06ec6e13749b5fc16500731518ad8fc30ae33e77"), - hex!("7517376541b2e8ec83cbab04522b54a26610908a9872feb663451385aea58eb1"), - hex!("5cba2e4cf7448e526d161133c4b2ea7c919ac4813a7308612595f46f11dea6cd"), - hex!("c721911b300bec0691c8a2dfaabfef1d66b7b6258918914d3c3ad690729f05b7"), - hex!("d0d0a70d8ae0d27806fa0b711c507290c260a89cbca0436d339d1dccdd087d62"), - hex!("2a625c28ea763c5e82dd0a93ecfca7ec371ccbb363cd42be359c2c875f58009d"), - hex!("174ef0119932ed890397d9f3837dd85f9100558b6fc9085d4af947ae8cf74bbc"), - hex!("b497bc267151e8efa3c6daa461e6804b01a3f05f44f1f4d5b41d5f0d3f5219b1"), - hex!("e987e91f5734630ddd7e6b58733b4fcdbc316ee9e8cac0e94c36c91cf58e59cc"), - hex!("55019ad8bbe656c51eb042190c1c8da53f42baf43fd2350ebea38fc7cca2fae3"), - hex!("c45a638edd18a6d9f5ad20b870c81b8626459bcb22dae7d58add7a6b6c6a84a8"), - hex!("d42d3a5fb2ad50b2027fe5a36d59dd71e49a63e4b1b299073c96bbf7ba5d68a1"), - hex!("9599e561054bcd3f647eb018ab0b069d3176497d42be9c4466551cbb959be47c"), - hex!("42f33b23775327ff71aea6569548255f3cc9929da73373cc9bb1743d417f7cda"), - hex!("ab24294f44fc6fdbeb96e0f6e93c4f6d97d035b73b9a337c353e18c6d0603bdd"), - hex!("33954ec63520334f99b640a2982ac966b68c363fed383d621a1ab573934f1d33"), - hex!("5e2a1f7df963d1fd8f50a285387cfbb5df581426619b325563e20bf7886c62b7"), - hex!("13ffde471d4e27c473254e766fd1328ad80c42cab4d4955cffeae43d866f86e5"), - ]; - - let expected_root = - hex!("1cf9b214217d7823f9de51b8f6cb34d0a99436a3a1bb762f90b815672a6afcc0"); - - let mut tree_less_capacity = MerkleTree::with_capacity(1); - let mut tree_exact_capacity = MerkleTree::with_capacity(64); - let mut tree_more_capacity = MerkleTree::with_capacity(128); - - for value in &values { - tree_less_capacity.insert(*value); - tree_exact_capacity.insert(*value); - tree_more_capacity.insert(*value); - } - - assert_eq!(tree_more_capacity.root(), expected_root); - assert_eq!(tree_less_capacity.root(), expected_root); - assert_eq!(tree_exact_capacity.root(), expected_root); - } -} - -// +mod tests; diff --git a/lee/state_machine/src/merkle_tree/tests.rs b/lee/state_machine/src/merkle_tree/tests.rs new file mode 100644 index 00000000..756fd45f --- /dev/null +++ b/lee/state_machine/src/merkle_tree/tests.rs @@ -0,0 +1,380 @@ +use hex_literal::hex; + +use super::*; + +impl MerkleTree { + pub fn new(values: &[Value]) -> Self { + let mut this = Self::with_capacity(values.len()); + for value in values.iter().copied() { + this.insert(value); + } + this + } +} + +#[test] +fn empty_merkle_tree() { + let tree = MerkleTree::with_capacity(4); + let expected_root = hex!("0000000000000000000000000000000000000000000000000000000000000000"); + assert_eq!(tree.root(), expected_root); + assert_eq!(tree.capacity, 4); + assert_eq!(tree.length, 0); +} + +#[test] +fn merkle_tree_0() { + let values = [[0; 32]]; + let tree = MerkleTree::new(&values); + assert_eq!(tree.root(), hash_value(&[0; 32])); + assert_eq!(tree.capacity, 1); + assert_eq!(tree.length, 1); +} + +#[test] +fn merkle_tree_1() { + let values = [[1; 32], [2; 32], [3; 32], [4; 32]]; + let tree = MerkleTree::new(&values); + let expected_root = hex!("48c73f7821a58a8d2a703e5b39c571c0aa20cf14abcd0af8f2b955bc202998de"); + assert_eq!(tree.root(), expected_root); + assert_eq!(tree.capacity, 4); + assert_eq!(tree.length, 4); +} + +#[test] +fn merkle_tree_2() { + let values = [[1; 32], [2; 32], [3; 32], [0; 32]]; + let tree = MerkleTree::new(&values); + let expected_root = hex!("c9bbb83096df85157a146e7d770455a98412dee0633187ee86fee6c8a45b831a"); + assert_eq!(tree.root(), expected_root); + assert_eq!(tree.capacity, 4); + assert_eq!(tree.length, 4); +} + +#[test] +fn merkle_tree_3() { + let values = [[1; 32], [2; 32], [3; 32]]; + let tree = MerkleTree::new(&values); + let expected_root = hex!("c8d3d8d2b13f27ceeccdc699119871f9f32ea7ed86ff45d0ad11f77b28cd7568"); + assert_eq!(tree.root(), expected_root); + assert_eq!(tree.capacity, 4); + assert_eq!(tree.length, 3); +} + +#[test] +fn merkle_tree_4() { + let values = [[11; 32], [12; 32], [13; 32], [14; 32], [15; 32]]; + let tree = MerkleTree::new(&values); + let expected_root = hex!("ef418aed5aa20702d4d94c92da79a4012f2e36f1008bfdb3cd1e38749dca2499"); + + assert_eq!(tree.root(), expected_root); + assert_eq!(tree.capacity, 8); + assert_eq!(tree.length, 5); +} + +#[test] +fn merkle_tree_5() { + let values = [ + [11; 32], [12; 32], [12; 32], [13; 32], [14; 32], [15; 32], [15; 32], [13; 32], [13; 32], + [15; 32], [11; 32], + ]; + let tree = MerkleTree::new(&values); + let expected_root = hex!("3f72d2ff55921a86c48e5988ec3e19ee9d0d5aa3e23197842970a903508ed767"); + assert_eq!(tree.root(), expected_root); + assert_eq!(tree.capacity, 16); + assert_eq!(tree.length, 11); +} + +#[test] +fn merkle_tree_6() { + let values = [[1; 32], [2; 32], [3; 32], [4; 32], [5; 32]]; + let tree = MerkleTree::new(&values); + let expected_root = hex!("069cb8259a06fe6edb3fa7ff7933a6dd7dca6fca299314379794a688926c3792"); + assert_eq!(tree.root(), expected_root); +} + +#[test] +fn with_capacity_4() { + let tree = MerkleTree::with_capacity(4); + + assert_eq!(tree.length, 0); + assert_eq!(tree.nodes.len(), 7); + for i in 3..7 { + assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[0], "{i}"); + } + for i in 1..3 { + assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[1], "{i}"); + } + assert_eq!(*tree.get_node(0), default_values::DEFAULT_VALUES[2]); +} + +#[test] +fn with_capacity_5() { + let tree = MerkleTree::with_capacity(5); + + assert_eq!(tree.length, 0); + assert_eq!(tree.nodes.len(), 15); + for i in 7..15 { + assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[0]); + } + for i in 3..7 { + assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[1]); + } + for i in 1..3 { + assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[2]); + } + assert_eq!(*tree.get_node(0), default_values::DEFAULT_VALUES[3]); +} + +#[test] +fn with_capacity_6() { + let mut tree = MerkleTree::with_capacity(100); + + let values = [[1; 32], [2; 32], [3; 32], [4; 32]]; + + let expected_root = hex!("48c73f7821a58a8d2a703e5b39c571c0aa20cf14abcd0af8f2b955bc202998de"); + + assert_eq!(0, tree.insert(values[0])); + assert_eq!(1, tree.insert(values[1])); + assert_eq!(2, tree.insert(values[2])); + assert_eq!(3, tree.insert(values[3])); + + assert_eq!(tree.root(), expected_root); +} + +#[test] +fn with_capacity_7() { + let mut tree = MerkleTree::with_capacity(599); + + let values = [[1; 32], [2; 32], [3; 32]]; + + let expected_root = hex!("c8d3d8d2b13f27ceeccdc699119871f9f32ea7ed86ff45d0ad11f77b28cd7568"); + + assert_eq!(0, tree.insert(values[0])); + assert_eq!(1, tree.insert(values[1])); + assert_eq!(2, tree.insert(values[2])); + + assert_eq!(tree.root(), expected_root); +} + +#[test] +fn with_capacity_8() { + let mut tree = MerkleTree::with_capacity(1); + + let values = [[1; 32], [2; 32], [3; 32]]; + + let expected_root = hex!("c8d3d8d2b13f27ceeccdc699119871f9f32ea7ed86ff45d0ad11f77b28cd7568"); + + assert_eq!(0, tree.insert(values[0])); + assert_eq!(1, tree.insert(values[1])); + assert_eq!(2, tree.insert(values[2])); + + assert_eq!(tree.root(), expected_root); +} + +#[test] +fn insert_value_1() { + let mut tree = MerkleTree::with_capacity(1); + + let values = [[1; 32], [2; 32], [3; 32]]; + let expected_tree = MerkleTree::new(&values); + + assert_eq!(0, tree.insert(values[0])); + assert_eq!(1, tree.insert(values[1])); + assert_eq!(2, tree.insert(values[2])); + + assert_eq!(expected_tree, tree); +} + +#[test] +fn insert_value_2() { + let mut tree = MerkleTree::with_capacity(1); + + let values = [[1; 32], [2; 32], [3; 32], [4; 32]]; + let expected_tree = MerkleTree::new(&values); + + assert_eq!(0, tree.insert(values[0])); + assert_eq!(1, tree.insert(values[1])); + assert_eq!(2, tree.insert(values[2])); + assert_eq!(3, tree.insert(values[3])); + + assert_eq!(expected_tree, tree); +} + +#[test] +fn insert_value_3() { + let mut tree = MerkleTree::with_capacity(1); + + let values = [[11; 32], [12; 32], [13; 32], [14; 32], [15; 32]]; + let expected_tree = MerkleTree::new(&values); + + tree.insert(values[0]); + tree.insert(values[1]); + tree.insert(values[2]); + tree.insert(values[3]); + tree.insert(values[4]); + + assert_eq!(expected_tree, tree); +} + +// Reference implementation +fn verify_authentication_path(value: &Value, index: usize, path: &[Node], root: &Node) -> bool { + let mut result = hash_value(value); + let mut level_index = index; + for node in path { + let is_left_child = level_index & 1 == 0; + if is_left_child { + result = hash_two(&result, node); + } else { + result = hash_two(node, &result); + } + level_index >>= 1; + } + &result == root +} + +#[test] +fn authentication_path_1() { + let values = [[1; 32], [2; 32], [3; 32], [4; 32]]; + let tree = MerkleTree::new(&values); + let expected_authentication_path = vec![ + hex!("9f4fb68f3e1dac82202f9aa581ce0bbf1f765df0e9ac3c8c57e20f685abab8ed"), + hex!("50a27d4746f357cb700cbe9d4883b77fb64f0128828a3489dc6a6f21ddbf2414"), + ]; + + let authentication_path = tree.get_authentication_path_for(2).unwrap(); + assert_eq!(authentication_path, expected_authentication_path); +} + +#[test] +fn authentication_path_2() { + let values = [[1; 32], [2; 32], [3; 32]]; + let tree = MerkleTree::new(&values); + let expected_authentication_path = vec![ + hex!("75877bb41d393b5fb8455ce60ecd8dda001d06316496b14dfa7f895656eeca4a"), + hex!("a41b855d2db4de9052cd7be5ec67d6586629cb9f6e3246a4afa5ba313f07a9c5"), + ]; + + let authentication_path = tree.get_authentication_path_for(0).unwrap(); + assert_eq!(authentication_path, expected_authentication_path); +} + +#[test] +fn authentication_path_3() { + let values = [[1; 32], [2; 32], [3; 32], [4; 32], [5; 32]]; + let tree = MerkleTree::new(&values); + let expected_authentication_path = vec![ + hex!("0000000000000000000000000000000000000000000000000000000000000000"), + hex!("f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b"), + hex!("48c73f7821a58a8d2a703e5b39c571c0aa20cf14abcd0af8f2b955bc202998de"), + ]; + + let authentication_path = tree.get_authentication_path_for(4).unwrap(); + assert_eq!(authentication_path, expected_authentication_path); +} + +#[test] +fn authentication_path_4() { + let values = [[1; 32], [2; 32], [3; 32], [4; 32], [5; 32]]; + let tree = MerkleTree::new(&values); + assert!(tree.get_authentication_path_for(5).is_none()); +} + +#[test] +fn authentication_path_5() { + let values = [[1; 32], [2; 32], [3; 32], [4; 32], [5; 32]]; + let tree = MerkleTree::new(&values); + let index = 4; + let value = values[index]; + let path = tree.get_authentication_path_for(index).unwrap(); + assert!(verify_authentication_path( + &value, + index, + &path, + &tree.root() + )); +} + +#[test] +fn tree_with_63_insertions() { + let values = [ + hex!("cd00acab0f45736e6c6311f1953becc0b69a062e7c2a7310875d28bdf9ef9c5b"), + hex!("0df5a6afbcc7bf126caf7084acfc593593ab512e6ca433c61c1a922be40a04ea"), + hex!("23c1258620266c7bedb6d1ee32f6da9413e4010ace975239dccb34e727e07c40"), + hex!("f33ccc3a11476b0ef62326ca5ec292056759b05e6a28023d2d1ce66165611353"), + hex!("77f914ab016b8049f6bea7704000e413a393865918a3824f9285c3db0aacff23"), + hex!("910a1c23188e54d57fd167ddb0f8bf68c6b70ed9ec76ef56c4b7f2632f82ca7f"), + hex!("047ee85526197d1e7403a559cf6d2f22c1926c8ad59481a2e2f1b697af45e40b"), + hex!("9d355cf89fb382ae34bf80566b28489278d10f2cebb5b0ea42fab1bac5adae0c"), + hex!("604018b95232596b2685a9bc737b6cccb53b10e483d2d9a2f4a755410b02a188"), + hex!("a16708ef7b6bf1796063addaf57d6a566b6f87b0bbe42af43a4590d05f1684cb"), + hex!("820f2dfa271cd2fd41e1452406d5dad552c85c1223c45d45dbd7446759fdc6b8"), + hex!("680b6912d7e219f8805d4d28adb4428dd78fea0dc1b8cdb2412645c4b1962c88"), + hex!("14d5471ce6c45506753982b17cac5790ac7bc29e6f388f31052d7dfd62b294e5"), + hex!("8b364200172b777d4aa16d2098b5eb98ac3dd4a1b9597e5c2bf6f6930031f230"), + hex!("9bb45b910711874339dda8a21a9aad73822286f5e52d7d3de0ed78dfbba329a5"), + hex!("d6806d5df5cb25ce5d531042f09b3cb34fb9e47c61182b63cccd9d44392f6027"), + hex!("b8cfa90ebc8fd09c04682d93a08fddd3e8e57715174dcc92451edd191264a58b"), + hex!("3463c7f81d00f809b3dfa83195447c927fb4045b3913dac6f45bee6c4010d7ed"), + hex!("1d6ad7f7d677905feb506c58f4b404a79370ebc567296abea3a368b61d5a8239"), + hex!("a58085ecf00963cb22da23c901b9b3ddc56462bb96ff03c923d67708e10dd29c"), + hex!("c3319f4a65fb5bbb8447137b0972c03cbd84ebf7d9da194e0fcbd68c2d4d5bdb"), + hex!("4aa31e90e0090faf3648d05e5d5499df2c78ebed4d6e6c23d8147de5d67dae73"), + hex!("9f33b1d2c8bc7bd265336de1033ede6344bc41260313bdcb43f1108b83b9be92"), + hex!("6500d4ad93d41c16ec81eaa5e70f173194aabe5c1072ac263b5727296f5b7cac"), + hex!("3584f5d260003669fad98786e13171376b0f19410cb232ce65606cbff79e6768"), + hex!("c8410946ebf56f13141c894a34ced85a5230088af70dcea581e44f52847830ac"), + hex!("71dd90281cdebb70422f2d04ae446d5d2d5ea64b803c16128d37e3fcd5d1a4cc"), + hex!("c05acf8d77ab4d659a538bd35af590864a7ad9c055ff5d6cda9d5aecfccecba3"), + hex!("f1df98822ea084cce9021aa9cc81b1746cd1e84a75690da63e10fd877633ed77"), + hex!("2ca822bc8f67bceb0a71a0d06fea7349036ef3e5ec21795a851e4182bd35ce01"), + hex!("7fd2179abc3bcf89b4d8092988ba8c23952b3bbd3d7caea6b5ea0c13cf19f68b"), + hex!("91b6ad516e017f6aa5a2e95776538bd3a3e933c1b1d32bb5e0f00a9db63c9c24"), + hex!("cd31a8b5eef5ca0be5ef1cb261d0bf0a74d774a3152bb99739cfd296a1d0b85e"), + hex!("3fb16f48b2bf93f3815979e6638f975d7f935088ec37db0be0f07965fbc78339"), + hex!("c60c61b99bf486af5f4bf780a69860dafcd35c1474306a8575666fb5449bcec0"), + hex!("8048d0d7e14091251f3f6c6b10bf6b5880a014b513f9f8c2395501dbffa6192a"), + hex!("778b5af10b9dbe80b60a8e4f0bb91caf4476bcb812801099760754ae623fbd84"), + hex!("d3ac25467920a4e08998b7a3226b8b54bfe66ac58cfedc71f15b2402fee0054a"), + hex!("029aa94598fae2961a0d43937b8a9a3138bcfeae99a7cb15f77fac7c506f8432"), + hex!("2eee5ef52fe669cb6882a68c893abdc1262dcf4424e4ba7a479da7cf1c10171d"), + hex!("de3fb3d070e3a90f0eed8b5e65088a8dc0e4e3c342b9c0bf33bab714eae5dfec"), + hex!("14d40177e833ab45bbfdc5f2b11fba7efaebb3f69facc554f24b549a2efe8538"), + hex!("5734355069702448774fb2df95f1d562e1b9fe1514aeb6b922554ee9d2d01068"), + hex!("8a273d49ac110343cec2cf3359d16eb2906b446bd9ec9833e2a640cebc8d5155"), + hex!("e3fa984dd3cbeb9a7e827ed32d3d4e6a6ba643a55d82be97d9ddb06ee809fa3e"), + hex!("90b1d5a364e17c8b7965396b06ec6e13749b5fc16500731518ad8fc30ae33e77"), + hex!("7517376541b2e8ec83cbab04522b54a26610908a9872feb663451385aea58eb1"), + hex!("5cba2e4cf7448e526d161133c4b2ea7c919ac4813a7308612595f46f11dea6cd"), + hex!("c721911b300bec0691c8a2dfaabfef1d66b7b6258918914d3c3ad690729f05b7"), + hex!("d0d0a70d8ae0d27806fa0b711c507290c260a89cbca0436d339d1dccdd087d62"), + hex!("2a625c28ea763c5e82dd0a93ecfca7ec371ccbb363cd42be359c2c875f58009d"), + hex!("174ef0119932ed890397d9f3837dd85f9100558b6fc9085d4af947ae8cf74bbc"), + hex!("b497bc267151e8efa3c6daa461e6804b01a3f05f44f1f4d5b41d5f0d3f5219b1"), + hex!("e987e91f5734630ddd7e6b58733b4fcdbc316ee9e8cac0e94c36c91cf58e59cc"), + hex!("55019ad8bbe656c51eb042190c1c8da53f42baf43fd2350ebea38fc7cca2fae3"), + hex!("c45a638edd18a6d9f5ad20b870c81b8626459bcb22dae7d58add7a6b6c6a84a8"), + hex!("d42d3a5fb2ad50b2027fe5a36d59dd71e49a63e4b1b299073c96bbf7ba5d68a1"), + hex!("9599e561054bcd3f647eb018ab0b069d3176497d42be9c4466551cbb959be47c"), + hex!("42f33b23775327ff71aea6569548255f3cc9929da73373cc9bb1743d417f7cda"), + hex!("ab24294f44fc6fdbeb96e0f6e93c4f6d97d035b73b9a337c353e18c6d0603bdd"), + hex!("33954ec63520334f99b640a2982ac966b68c363fed383d621a1ab573934f1d33"), + hex!("5e2a1f7df963d1fd8f50a285387cfbb5df581426619b325563e20bf7886c62b7"), + hex!("13ffde471d4e27c473254e766fd1328ad80c42cab4d4955cffeae43d866f86e5"), + ]; + + let expected_root = hex!("1cf9b214217d7823f9de51b8f6cb34d0a99436a3a1bb762f90b815672a6afcc0"); + + let mut tree_less_capacity = MerkleTree::with_capacity(1); + let mut tree_exact_capacity = MerkleTree::with_capacity(64); + let mut tree_more_capacity = MerkleTree::with_capacity(128); + + for value in &values { + tree_less_capacity.insert(*value); + tree_exact_capacity.insert(*value); + tree_more_capacity.insert(*value); + } + + assert_eq!(tree_more_capacity.root(), expected_root); + assert_eq!(tree_less_capacity.root(), expected_root); + assert_eq!(tree_exact_capacity.root(), expected_root); +} diff --git a/lee/state_machine/src/privacy_preserving_transaction/circuit.rs b/lee/state_machine/src/privacy_preserving_transaction/circuit.rs deleted file mode 100644 index 489ee373..00000000 --- a/lee/state_machine/src/privacy_preserving_transaction/circuit.rs +++ /dev/null @@ -1,906 +0,0 @@ -use std::collections::{HashMap, VecDeque}; - -use borsh::{BorshDeserialize, BorshSerialize}; -use lee_core::{ - InputAccountIdentity, PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput, - account::AccountWithMetadata, - program::{ChainedCall, InstructionData, ProgramId, ProgramOutput}, -}; -use risc0_zkvm::{ExecutorEnv, InnerReceipt, ProverOpts, Receipt, default_prover}; - -use crate::{ - PRIVACY_PRESERVING_CIRCUIT_ELF, PRIVACY_PRESERVING_CIRCUIT_ID, - error::{InvalidProgramBehaviorError, LeeError}, - program::Program, - state::MAX_NUMBER_CHAINED_CALLS, -}; - -/// Proof of the privacy preserving execution circuit. -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] -pub struct Proof(pub(crate) Vec); - -impl Proof { - #[must_use] - pub fn into_inner(self) -> Vec { - self.0 - } - - #[must_use] - pub const fn from_inner(inner: Vec) -> Self { - Self(inner) - } - - pub(crate) fn is_valid_for(&self, circuit_output: &PrivacyPreservingCircuitOutput) -> bool { - let Ok(inner) = borsh::from_slice::(&self.0) else { - return false; - }; - let receipt = Receipt::new(inner, circuit_output.to_bytes()); - receipt.verify(PRIVACY_PRESERVING_CIRCUIT_ID).is_ok() - } -} - -#[derive(Clone)] -pub struct ProgramWithDependencies { - pub program: Program, - // TODO: avoid having a copy of the bytecode of each dependency. - pub dependencies: HashMap, -} - -impl ProgramWithDependencies { - #[must_use] - pub const fn new(program: Program, dependencies: HashMap) -> Self { - Self { - program, - dependencies, - } - } -} - -impl From for ProgramWithDependencies { - fn from(program: Program) -> Self { - Self::new(program, HashMap::new()) - } -} - -/// Generates a proof of the execution of a LEE program inside the privacy preserving execution -/// circuit. -pub fn execute_and_prove( - pre_states: Vec, - instruction_data: InstructionData, - account_identities: Vec, - program_with_dependencies: &ProgramWithDependencies, -) -> Result<(PrivacyPreservingCircuitOutput, Proof), LeeError> { - let ProgramWithDependencies { - program: initial_program, - dependencies, - } = program_with_dependencies; - let mut env_builder = ExecutorEnv::builder(); - let mut program_outputs = Vec::new(); - - let initial_call = ChainedCall { - program_id: initial_program.id(), - instruction_data, - pre_states, - pda_seeds: vec![], - }; - - let mut chained_calls = VecDeque::from_iter([(initial_call, initial_program, None)]); - let mut chain_calls_counter = 0; - while let Some((chained_call, program, caller_program_id)) = chained_calls.pop_front() { - if chain_calls_counter >= MAX_NUMBER_CHAINED_CALLS { - return Err(LeeError::MaxChainedCallsDepthExceeded); - } - - let inner_receipt = execute_and_prove_program( - program, - caller_program_id, - &chained_call.pre_states, - &chained_call.instruction_data, - )?; - - let program_output: ProgramOutput = inner_receipt - .journal - .decode() - .map_err(|e| LeeError::ProgramOutputDeserializationError(e.to_string()))?; - - // TODO: remove clone - program_outputs.push(program_output.clone()); - - // Prove circuit. - env_builder.add_assumption(inner_receipt); - - for new_call in program_output.chained_calls.into_iter().rev() { - let next_program = dependencies.get(&new_call.program_id).ok_or( - InvalidProgramBehaviorError::UndeclaredProgramDependency { - program_id: new_call.program_id, - }, - )?; - chained_calls.push_front((new_call, next_program, Some(chained_call.program_id))); - } - - chain_calls_counter = chain_calls_counter - .checked_add(1) - .expect("we check the max depth at the beginning of the loop"); - } - - let circuit_input = PrivacyPreservingCircuitInput { - program_outputs, - account_identities, - program_id: program_with_dependencies.program.id(), - }; - - env_builder.write(&circuit_input).unwrap(); - let env = env_builder.build().unwrap(); - let prover = default_prover(); - let opts = ProverOpts::succinct(); - let prove_info = prover - .prove_with_opts(env, PRIVACY_PRESERVING_CIRCUIT_ELF, &opts) - .map_err(|e| LeeError::CircuitProvingError(e.to_string()))?; - - let proof = Proof(borsh::to_vec(&prove_info.receipt.inner)?); - - let circuit_output: PrivacyPreservingCircuitOutput = prove_info - .receipt - .journal - .decode() - .map_err(|e| LeeError::CircuitOutputDeserializationError(e.to_string()))?; - - Ok((circuit_output, proof)) -} - -fn execute_and_prove_program( - program: &Program, - caller_program_id: Option, - pre_states: &[AccountWithMetadata], - instruction_data: &InstructionData, -) -> Result { - // Write inputs to the program - let mut env_builder = ExecutorEnv::builder(); - Program::write_inputs( - program.id(), - caller_program_id, - pre_states, - instruction_data, - &mut env_builder, - )?; - let env = env_builder.build().unwrap(); - - // Prove the program - let prover = default_prover(); - Ok(prover - .prove(env, program.elf()) - .map_err(|e| LeeError::ProgramProveFailed(e.to_string()))? - .receipt) -} - -#[cfg(test)] -mod tests { - #![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")] - - use lee_core::{ - Commitment, DUMMY_COMMITMENT_HASH, EncryptedAccountData, EncryptionScheme, - EphemeralPublicKey, Nullifier, PrivacyPreservingCircuitOutput, SharedSecretKey, - account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data}, - program::{PdaSeed, PrivateAccountKind}, - }; - - use super::*; - use crate::{ - error::LeeError, - privacy_preserving_transaction::circuit::execute_and_prove, - program::Program, - state::{ - CommitmentSet, - tests::{test_private_account_keys_1, test_private_account_keys_2}, - }, - }; - - fn decrypt_kind( - output: &PrivacyPreservingCircuitOutput, - ssk: &SharedSecretKey, - idx: usize, - ) -> PrivateAccountKind { - let (kind, _) = EncryptionScheme::decrypt( - &output.encrypted_private_post_states[idx].ciphertext, - ssk, - &output.new_commitments[idx], - u32::try_from(idx).expect("idx fits in u32"), - ) - .unwrap(); - kind - } - - #[test] - fn proof_inner_roundtrip() { - // `Proof::from_inner(b).into_inner()` must return exactly `b`. Catches - // mutations of `into_inner` returning `vec![]`, `vec![0]`, or `vec![1]`, - // and of `from_inner` discarding its argument. - let bytes = vec![0xDE_u8, 0xAD, 0xBE, 0xEF]; - assert_eq!(Proof::from_inner(bytes.clone()).into_inner(), bytes); - assert!(Proof::from_inner(vec![]).into_inner().is_empty()); - assert_eq!(Proof::from_inner(vec![0xFF]).into_inner(), vec![0xFF_u8]); - } - - #[test] - fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts() { - let recipient_keys = test_private_account_keys_1(); - let program = crate::test_methods::simple_balance_transfer(); - let sender = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 100, - ..Account::default() - }, - true, - AccountId::new([0; 32]), - ); - - let recipient_account_id = AccountId::for_regular_private_account(&recipient_keys.npk(), 0); - let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id); - - let balance_to_move: u128 = 37; - - let expected_sender_post = Account { - program_owner: program.id(), - balance: 100 - balance_to_move, - nonce: Nonce::default(), - data: Data::default(), - }; - - let expected_recipient_post = Account { - program_owner: program.id(), - balance: balance_to_move, - nonce: Nonce::private_account_nonce_init(&recipient_account_id), - data: Data::default(), - }; - - let expected_sender_pre = sender.clone(); - - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &[0_u8; 32], 0).0; - - let (output, proof) = execute_and_prove( - vec![sender, recipient], - Program::serialize_instruction(balance_to_move).unwrap(), - vec![ - InputAccountIdentity::Public, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &recipient_keys.npk(), - &recipient_keys.vpk(), - ), - npk: recipient_keys.npk(), - ssk: shared_secret, - identifier: 0, - }, - ], - &crate::test_methods::simple_balance_transfer().into(), - ) - .unwrap(); - - assert!(proof.is_valid_for(&output)); - - let [sender_pre] = output.public_pre_states.try_into().unwrap(); - let [sender_post] = output.public_post_states.try_into().unwrap(); - assert_eq!(sender_pre, expected_sender_pre); - assert_eq!(sender_post, expected_sender_post); - assert_eq!(output.new_commitments.len(), 1); - assert_eq!(output.new_nullifiers.len(), 1); - assert_eq!(output.encrypted_private_post_states.len(), 1); - - let (_identifier, recipient_post) = EncryptionScheme::decrypt( - &output.encrypted_private_post_states[0].ciphertext, - &shared_secret, - &output.new_commitments[0], - 0, - ) - .unwrap(); - assert_eq!(recipient_post, expected_recipient_post); - } - - #[test] - fn prove_privacy_preserving_execution_circuit_fully_private() { - let program = crate::test_methods::simple_balance_transfer(); - let sender_keys = test_private_account_keys_1(); - let recipient_keys = test_private_account_keys_2(); - - let sender_nonce = Nonce(0xdead_beef); - let sender_pre = AccountWithMetadata::new( - Account { - balance: 100, - nonce: sender_nonce, - program_owner: program.id(), - data: Data::default(), - }, - true, - AccountId::for_regular_private_account(&sender_keys.npk(), 0), - ); - let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0); - let commitment_sender = Commitment::new(&sender_account_id, &sender_pre.account); - - let recipient_account_id = AccountId::for_regular_private_account(&recipient_keys.npk(), 0); - let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id); - let balance_to_move: u128 = 37; - - let mut commitment_set = CommitmentSet::with_capacity(2); - commitment_set.extend(std::slice::from_ref(&commitment_sender)); - let expected_new_nullifiers = vec![ - ( - Nullifier::for_account_update(&commitment_sender, &sender_keys.nsk), - commitment_set.digest(), - ), - ( - Nullifier::for_account_initialization(&recipient_account_id), - DUMMY_COMMITMENT_HASH, - ), - ]; - - let program = crate::test_methods::simple_balance_transfer(); - - let expected_private_account_1 = Account { - program_owner: program.id(), - balance: 100 - balance_to_move, - nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk), - ..Default::default() - }; - let expected_private_account_2 = Account { - program_owner: program.id(), - balance: balance_to_move, - nonce: Nonce::private_account_nonce_init(&recipient_account_id), - ..Default::default() - }; - let expected_new_commitments = vec![ - Commitment::new(&sender_account_id, &expected_private_account_1), - Commitment::new(&recipient_account_id, &expected_private_account_2), - ]; - - let shared_secret_1 = - SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0).0; - - let shared_secret_2 = - SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &[0_u8; 32], 1).0; - - let (output, proof) = execute_and_prove( - vec![sender_pre, recipient], - Program::serialize_instruction(balance_to_move).unwrap(), - vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &sender_keys.npk(), - &sender_keys.vpk(), - ), - ssk: shared_secret_1, - nsk: sender_keys.nsk, - membership_proof: commitment_set - .get_proof_for(&commitment_sender) - .expect("sender's commitment must be in the set"), - identifier: 0, - }, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &recipient_keys.npk(), - &recipient_keys.vpk(), - ), - npk: recipient_keys.npk(), - ssk: shared_secret_2, - identifier: 0, - }, - ], - &program.into(), - ) - .unwrap(); - - assert!(proof.is_valid_for(&output)); - assert!(output.public_pre_states.is_empty()); - assert!(output.public_post_states.is_empty()); - assert_eq!(output.new_commitments, expected_new_commitments); - assert_eq!(output.new_nullifiers, expected_new_nullifiers); - assert_eq!(output.encrypted_private_post_states.len(), 2); - - let (_identifier, sender_post) = EncryptionScheme::decrypt( - &output.encrypted_private_post_states[0].ciphertext, - &shared_secret_1, - &expected_new_commitments[0], - 0, - ) - .unwrap(); - assert_eq!(sender_post, expected_private_account_1); - - let (_identifier, recipient_post) = EncryptionScheme::decrypt( - &output.encrypted_private_post_states[1].ciphertext, - &shared_secret_2, - &expected_new_commitments[1], - 1, - ) - .unwrap(); - assert_eq!(recipient_post, expected_private_account_2); - } - - #[test] - fn circuit_fails_when_chained_validity_windows_have_empty_intersection() { - let account_keys = test_private_account_keys_1(); - let pre = AccountWithMetadata::new( - Account::default(), - false, - AccountId::for_regular_private_account(&account_keys.npk(), 0), - ); - - let validity_window_chain_caller = crate::test_methods::validity_window_chain_caller(); - let validity_window = crate::test_methods::validity_window(); - - let instruction = Program::serialize_instruction(( - Some(1_u64), - Some(4_u64), - validity_window.id(), - Some(4_u64), - Some(7_u64), - )) - .unwrap(); - - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&account_keys.vpk(), &[0_u8; 32], 0).0; - - let program_with_deps = ProgramWithDependencies::new( - validity_window_chain_caller, - [(validity_window.id(), validity_window)].into(), - ); - - let result = execute_and_prove( - vec![pre], - instruction, - vec![InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &account_keys.npk(), - &account_keys.vpk(), - ), - npk: account_keys.npk(), - ssk: shared_secret, - identifier: 0, - }], - &program_with_deps, - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - /// A private PDA claimed with a non-default identifier produces a ciphertext that decrypts - /// to `PrivateAccountKind::Pda` carrying the correct `(program_id, seed, identifier)`. - #[test] - fn private_pda_claim_with_custom_identifier_encrypts_correct_kind() { - let program = crate::test_methods::pda_claimer(); - let keys = test_private_account_keys_1(); - let npk = keys.npk(); - let seed = PdaSeed::new([42; 32]); - let identifier: u128 = 99; - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0; - - let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, identifier); - let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); - - let (output, _proof) = execute_and_prove( - vec![pre_state], - Program::serialize_instruction(seed).unwrap(), - vec![InputAccountIdentity::PrivatePdaInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&npk, &keys.vpk()), - npk, - ssk: shared_secret, - identifier, - seed: None, - }], - &program.clone().into(), - ) - .unwrap(); - - assert_eq!( - decrypt_kind(&output, &shared_secret, 0), - PrivateAccountKind::Pda { - program_id: program.id(), - seed, - identifier - }, - ); - } - - /// PDA init: initializes a new PDA under `simple_balance_transfer`'s ownership. - /// The `simple_transfer_proxy` program chains to `simple_balance_transfer` with `pda_seeds` - /// to establish authorization and the private PDA binding. - #[test] - fn private_pda_init() { - let program = crate::test_methods::simple_transfer_proxy(); - let simple_transfer = crate::test_methods::simple_balance_transfer(); - let keys = test_private_account_keys_1(); - let npk = keys.npk(); - let seed = PdaSeed::new([42; 32]); - let shared_secret_pda = - SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0; - - // PDA (new, private PDA) - let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 0); - let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); - - let auth_id = simple_transfer.id(); - let program_with_deps = - ProgramWithDependencies::new(program, [(auth_id, simple_transfer)].into()); - - // is_withdraw=false triggers init path (1 pre-state) - let instruction = Program::serialize_instruction((seed, auth_id, 0_u128, false)).unwrap(); - - let result = execute_and_prove( - vec![pda_pre], - instruction, - vec![InputAccountIdentity::PrivatePdaInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&npk, &keys.vpk()), - npk, - ssk: shared_secret_pda, - identifier: 0, - seed: None, - }], - &program_with_deps, - ); - - let (output, _proof) = result.expect("PDA init should succeed"); - assert_eq!(output.new_commitments.len(), 1); - } - - /// PDA withdraw: chains to `simple_balance_transfer` to move balance from PDA to recipient. - /// Uses a default PDA (amount=0) because testing with a pre-funded PDA requires a - /// two-tx sequence with membership proofs. - #[test] - fn private_pda_withdraw() { - let program = crate::test_methods::simple_transfer_proxy(); - let simple_transfer = crate::test_methods::simple_balance_transfer(); - let keys = test_private_account_keys_1(); - let npk = keys.npk(); - let seed = PdaSeed::new([42; 32]); - let shared_secret_pda = - SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0; - - // PDA (new, private PDA) - let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 0); - let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); - - // Recipient (public) - let recipient_id = AccountId::new([88; 32]); - let recipient_pre = AccountWithMetadata::new( - Account { - program_owner: simple_transfer.id(), - balance: 10000, - ..Account::default() - }, - true, - recipient_id, - ); - - let auth_id = simple_transfer.id(); - let program_with_deps = - ProgramWithDependencies::new(program, [(auth_id, simple_transfer)].into()); - - // is_withdraw=true, amount=0 (PDA has no balance yet) - let instruction = Program::serialize_instruction((seed, auth_id, 0_u128, true)).unwrap(); - - let result = execute_and_prove( - vec![pda_pre, recipient_pre], - instruction, - vec![ - InputAccountIdentity::PrivatePdaInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&npk, &keys.vpk()), - npk, - ssk: shared_secret_pda, - identifier: 0, - seed: None, - }, - InputAccountIdentity::Public, - ], - &program_with_deps, - ); - - let (output, _proof) = result.expect("PDA withdraw should succeed"); - assert_eq!(output.new_commitments.len(), 1); - } - - /// Shared regular private account: receives funds via `authenticated_transfer` directly, - /// no custom program needed. This demonstrates the non-PDA shared account flow where - /// keys are derived from GMS via `derive_keys_for_shared_account`. The shared account - /// uses the standard unauthorized private account path and works with auth-transfer's - /// transfer path like any other private account. - #[test] - fn shared_account_receives_via_simple_transfer() { - let program = crate::test_methods::simple_balance_transfer(); - let shared_keys = test_private_account_keys_1(); - let shared_npk = shared_keys.npk(); - let shared_identifier: u128 = 42; - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&shared_keys.vpk(), &[0_u8; 32], 0).0; - - // Sender: public account with balance, owned by auth-transfer - let sender_id = AccountId::new([99; 32]); - let sender = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 1000, - ..Account::default() - }, - true, - sender_id, - ); - - // Recipient: shared private account (new, unauthorized) - let shared_account_id = AccountId::from((&shared_npk, shared_identifier)); - let recipient = AccountWithMetadata::new(Account::default(), false, shared_account_id); - - let balance_to_move: u128 = 100; - let instruction = Program::serialize_instruction(balance_to_move).unwrap(); - - let result = execute_and_prove( - vec![sender, recipient], - instruction, - vec![ - InputAccountIdentity::Public, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &shared_npk, - &shared_keys.vpk(), - ), - npk: shared_npk, - ssk: shared_secret, - identifier: shared_identifier, - }, - ], - &program.into(), - ); - - let (output, _proof) = result.expect("shared account receive should succeed"); - // Sender is public (no commitment), recipient is private (1 commitment) - assert_eq!(output.new_commitments.len(), 1); - } - - /// `PrivateAuthorizedInit` with a non-default identifier produces a ciphertext that decrypts - /// to `PrivateAccountKind::Regular` carrying the correct identifier. - #[test] - fn private_authorized_init_encrypts_regular_kind_with_identifier() { - let program = crate::test_methods::claimer(); - let keys = test_private_account_keys_1(); - let identifier: u128 = 99; - let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0; - let account_id = AccountId::for_regular_private_account(&keys.npk(), identifier); - let pre = AccountWithMetadata::new(Account::default(), true, account_id); - - let (output, _) = execute_and_prove( - vec![pre], - Program::serialize_instruction(()).unwrap(), - vec![InputAccountIdentity::PrivateAuthorizedInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()), - ssk, - nsk: keys.nsk, - identifier, - }], - &program.into(), - ) - .unwrap(); - - assert_eq!( - decrypt_kind(&output, &ssk, 0), - PrivateAccountKind::Regular(identifier) - ); - } - - /// `PrivateUnauthorized` with a non-default identifier produces a ciphertext that decrypts - /// to `PrivateAccountKind::Regular` carrying the correct identifier. - #[test] - fn private_unauthorized_init_encrypts_regular_kind_with_identifier() { - let program = crate::test_methods::claimer(); - let keys = test_private_account_keys_1(); - let identifier: u128 = 99; - let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0; - let recipient_id = AccountId::for_regular_private_account(&keys.npk(), identifier); - let recipient = AccountWithMetadata::new(Account::default(), false, recipient_id); - - let (output, _) = execute_and_prove( - vec![recipient], - Program::serialize_instruction(()).unwrap(), - vec![InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()), - npk: keys.npk(), - ssk, - identifier, - }], - &program.into(), - ) - .unwrap(); - - assert_eq!( - decrypt_kind(&output, &ssk, 0), - PrivateAccountKind::Regular(identifier) - ); - } - - /// `PrivateAuthorizedUpdate` with a non-default identifier produces a ciphertext that decrypts - /// to `PrivateAccountKind::Regular` carrying the correct identifier. - #[test] - fn private_authorized_update_encrypts_regular_kind_with_identifier() { - let program = crate::test_methods::noop(); - let keys = test_private_account_keys_1(); - let identifier: u128 = 99; - let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0; - let account_id = AccountId::for_regular_private_account(&keys.npk(), identifier); - let account = Account { - program_owner: program.id(), - balance: 1, - ..Account::default() - }; - let commitment = Commitment::new(&account_id, &account); - let mut commitment_set = CommitmentSet::with_capacity(1); - commitment_set.extend(std::slice::from_ref(&commitment)); - - let sender = AccountWithMetadata::new(account, true, account_id); - - let (output, _) = execute_and_prove( - vec![sender], - Program::serialize_instruction(()).unwrap(), - vec![InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()), - ssk, - nsk: keys.nsk, - membership_proof: commitment_set.get_proof_for(&commitment).unwrap(), - identifier, - }], - &program.into(), - ) - .unwrap(); - - assert_eq!( - decrypt_kind(&output, &ssk, 0), - PrivateAccountKind::Regular(identifier) - ); - } - - /// `PrivatePdaUpdate` with a non-default identifier produces a ciphertext that decrypts - /// to `PrivateAccountKind::Pda` carrying the correct `(program_id, seed, identifier)`. - #[test] - fn private_pda_update_encrypts_pda_kind_with_identifier() { - let program = crate::test_methods::pda_spend_proxy(); - let simple_transfer = crate::test_methods::simple_balance_transfer(); - let keys = test_private_account_keys_1(); - let npk = keys.npk(); - let seed = PdaSeed::new([42; 32]); - let identifier: u128 = 99; - let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0; - - let simple_transfer_id = simple_transfer.id(); - let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, identifier); - let pda_account = Account { - program_owner: simple_transfer_id, - balance: 1, - ..Account::default() - }; - let pda_commitment = Commitment::new(&pda_id, &pda_account); - let mut commitment_set = CommitmentSet::with_capacity(1); - commitment_set.extend(std::slice::from_ref(&pda_commitment)); - - let pda_pre = AccountWithMetadata::new(pda_account, true, pda_id); - let recipient_pre = - AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32])); - - let program_with_deps = ProgramWithDependencies::new( - program.clone(), - [(simple_transfer_id, simple_transfer)].into(), - ); - - let (output, _) = execute_and_prove( - vec![pda_pre, recipient_pre], - Program::serialize_instruction((seed, 1_u128, simple_transfer_id, false)).unwrap(), - vec![ - InputAccountIdentity::PrivatePdaUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&npk, &keys.vpk()), - ssk, - nsk: keys.nsk, - membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(), - identifier, - seed: None, - }, - InputAccountIdentity::Public, - ], - &program_with_deps, - ) - .unwrap(); - - assert_eq!( - decrypt_kind(&output, &ssk, 0), - PrivateAccountKind::Pda { - program_id: program.id(), - seed, - identifier - }, - ); - } - - #[test] - fn private_pda_init_identifier_mismatch_fails() { - let program = crate::test_methods::pda_claimer(); - let keys = test_private_account_keys_1(); - let npk = keys.npk(); - let seed = PdaSeed::new([42; 32]); - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0; - - let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 5); - let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); - - let result = execute_and_prove( - vec![pre_state], - Program::serialize_instruction(seed).unwrap(), - vec![InputAccountIdentity::PrivatePdaInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&npk, &keys.vpk()), - npk, - ssk: shared_secret, - identifier: 99, - seed: None, - }], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn private_pda_update_identifier_mismatch_fails() { - let program = crate::test_methods::pda_spend_proxy(); - let simple_transfer = crate::test_methods::simple_balance_transfer(); - let keys = test_private_account_keys_1(); - let npk = keys.npk(); - let seed = PdaSeed::new([42; 32]); - let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0; - - let simple_transfer_id = simple_transfer.id(); - let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 5); - let pda_account = Account { - program_owner: simple_transfer_id, - balance: 1, - ..Account::default() - }; - let pda_commitment = Commitment::new(&pda_id, &pda_account); - let mut commitment_set = CommitmentSet::with_capacity(1); - commitment_set.extend(std::slice::from_ref(&pda_commitment)); - - let pda_pre = AccountWithMetadata::new(pda_account, true, pda_id); - let recipient_pre = - AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32])); - - let program_with_deps = - ProgramWithDependencies::new(program, [(simple_transfer_id, simple_transfer)].into()); - - let result = execute_and_prove( - vec![pda_pre, recipient_pre], - Program::serialize_instruction((seed, 1_u128, simple_transfer_id, false)).unwrap(), - vec![ - InputAccountIdentity::PrivatePdaUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&npk, &keys.vpk()), - ssk, - nsk: keys.nsk, - membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(), - identifier: 99, - seed: None, - }, - InputAccountIdentity::Public, - ], - &program_with_deps, - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } -} diff --git a/lee/state_machine/src/privacy_preserving_transaction/circuit/mod.rs b/lee/state_machine/src/privacy_preserving_transaction/circuit/mod.rs new file mode 100644 index 00000000..d907a1e8 --- /dev/null +++ b/lee/state_machine/src/privacy_preserving_transaction/circuit/mod.rs @@ -0,0 +1,195 @@ +use std::collections::{HashMap, VecDeque}; + +use borsh::{BorshDeserialize, BorshSerialize}; +use lee_core::{ + DummyInput, InputAccountIdentity, PrivacyPreservingCircuitInput, + PrivacyPreservingCircuitOutput, + account::AccountWithMetadata, + program::{ChainedCall, InstructionData, ProgramId, ProgramOutput}, +}; +use risc0_zkvm::{ExecutorEnv, InnerReceipt, ProverOpts, Receipt, default_prover}; + +use crate::{ + PRIVACY_PRESERVING_CIRCUIT_ELF, PRIVACY_PRESERVING_CIRCUIT_ID, + error::{InvalidProgramBehaviorError, LeeError}, + program::Program, + state::MAX_NUMBER_CHAINED_CALLS, +}; + +/// Proof of the privacy preserving execution circuit. +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct Proof(pub(crate) Vec); + +impl Proof { + #[must_use] + pub fn into_inner(self) -> Vec { + self.0 + } + + #[must_use] + pub const fn from_inner(inner: Vec) -> Self { + Self(inner) + } + + pub(crate) fn is_valid_for(&self, circuit_output: &PrivacyPreservingCircuitOutput) -> bool { + let Ok(inner) = borsh::from_slice::(&self.0) else { + return false; + }; + let receipt = Receipt::new(inner, circuit_output.to_bytes()); + receipt.verify(PRIVACY_PRESERVING_CIRCUIT_ID).is_ok() + } +} + +#[derive(Clone)] +pub struct ProgramWithDependencies { + pub program: Program, + // TODO: avoid having a copy of the bytecode of each dependency. + pub dependencies: HashMap, +} + +impl ProgramWithDependencies { + #[must_use] + pub const fn new(program: Program, dependencies: HashMap) -> Self { + Self { + program, + dependencies, + } + } +} + +impl From for ProgramWithDependencies { + fn from(program: Program) -> Self { + Self::new(program, HashMap::new()) + } +} + +/// Generates a proof of the execution of a LEE program inside the privacy preserving execution +/// circuit. +pub fn execute_and_prove( + pre_states: Vec, + instruction_data: InstructionData, + account_identities: Vec, + program_with_dependencies: &ProgramWithDependencies, +) -> Result<(PrivacyPreservingCircuitOutput, Proof), LeeError> { + execute_and_prove_with_padded_inputs( + pre_states, + instruction_data, + account_identities, + vec![], + program_with_dependencies, + ) +} + +pub fn execute_and_prove_with_padded_inputs( + pre_states: Vec, + instruction_data: InstructionData, + account_identities: Vec, + dummy_inputs: Vec, + program_with_dependencies: &ProgramWithDependencies, +) -> Result<(PrivacyPreservingCircuitOutput, Proof), LeeError> { + let ProgramWithDependencies { + program: initial_program, + dependencies, + } = program_with_dependencies; + let mut env_builder = ExecutorEnv::builder(); + let mut program_outputs = Vec::new(); + + let initial_call = ChainedCall { + program_id: initial_program.id(), + instruction_data, + pre_states, + pda_seeds: vec![], + }; + + let mut chained_calls = VecDeque::from_iter([(initial_call, initial_program, None)]); + let mut chain_calls_counter = 0; + while let Some((chained_call, program, caller_program_id)) = chained_calls.pop_front() { + if chain_calls_counter >= MAX_NUMBER_CHAINED_CALLS { + return Err(LeeError::MaxChainedCallsDepthExceeded); + } + + let inner_receipt = execute_and_prove_program( + program, + caller_program_id, + &chained_call.pre_states, + &chained_call.instruction_data, + )?; + + let program_output: ProgramOutput = inner_receipt + .journal + .decode() + .map_err(|e| LeeError::ProgramOutputDeserializationError(e.to_string()))?; + + // TODO: remove clone + program_outputs.push(program_output.clone()); + + // Prove circuit. + env_builder.add_assumption(inner_receipt); + + for new_call in program_output.chained_calls.into_iter().rev() { + let next_program = dependencies.get(&new_call.program_id).ok_or( + InvalidProgramBehaviorError::UndeclaredProgramDependency { + program_id: new_call.program_id, + }, + )?; + chained_calls.push_front((new_call, next_program, Some(chained_call.program_id))); + } + + chain_calls_counter = chain_calls_counter + .checked_add(1) + .expect("we check the max depth at the beginning of the loop"); + } + + let circuit_input = PrivacyPreservingCircuitInput { + program_outputs, + account_identities, + program_id: program_with_dependencies.program.id(), + dummy_inputs, + }; + + env_builder.write(&circuit_input).unwrap(); + let env = env_builder.build().unwrap(); + let prover = default_prover(); + let opts = ProverOpts::succinct(); + let prove_info = prover + .prove_with_opts(env, PRIVACY_PRESERVING_CIRCUIT_ELF, &opts) + .map_err(|e| LeeError::CircuitProvingError(e.to_string()))?; + + let proof = Proof(borsh::to_vec(&prove_info.receipt.inner)?); + + let circuit_output: PrivacyPreservingCircuitOutput = prove_info + .receipt + .journal + .decode() + .map_err(|e| LeeError::CircuitOutputDeserializationError(e.to_string()))?; + + Ok((circuit_output, proof)) +} + +fn execute_and_prove_program( + program: &Program, + caller_program_id: Option, + pre_states: &[AccountWithMetadata], + instruction_data: &InstructionData, +) -> Result { + // Write inputs to the program + let mut env_builder = ExecutorEnv::builder(); + Program::write_inputs( + program.id(), + caller_program_id, + pre_states, + instruction_data, + &mut env_builder, + )?; + let env = env_builder.build().unwrap(); + + // Prove the program + let prover = default_prover(); + Ok(prover + .prove(env, program.elf()) + .map_err(|e| LeeError::ProgramProveFailed(e.to_string()))? + .receipt) +} + +#[cfg(test)] +mod tests; diff --git a/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs new file mode 100644 index 00000000..5a74727a --- /dev/null +++ b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs @@ -0,0 +1,811 @@ +#![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")] + +use lee_core::{ + Commitment, DUMMY_COMMITMENT_HASH, EncryptedAccountData, EncryptionScheme, EphemeralSecretKey, + Nullifier, PrivacyPreservingCircuitOutput, SharedSecretKey, + account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data}, + program::{PdaSeed, PrivateAccountKind}, +}; + +use super::*; +use crate::{ + error::LeeError, + privacy_preserving_transaction::circuit::execute_and_prove, + program::Program, + state::{ + CommitmentSet, + tests::{test_private_account_keys_1, test_private_account_keys_2}, + }, +}; + +fn decrypt_kind( + output: &PrivacyPreservingCircuitOutput, + ssk: &SharedSecretKey, + idx: usize, +) -> PrivateAccountKind { + let (kind, _) = EncryptionScheme::decrypt( + &output.encrypted_private_post_states[idx].ciphertext, + ssk, + &output.new_nullifiers[idx].0, + ) + .unwrap(); + kind +} + +#[test] +fn proof_inner_roundtrip() { + // `Proof::from_inner(b).into_inner()` must return exactly `b`. Catches + // mutations of `into_inner` returning `vec![]`, `vec![0]`, or `vec![1]`, + // and of `from_inner` discarding its argument. + let bytes = vec![0xDE_u8, 0xAD, 0xBE, 0xEF]; + assert_eq!(Proof::from_inner(bytes.clone()).into_inner(), bytes); + assert!(Proof::from_inner(vec![]).into_inner().is_empty()); + assert_eq!(Proof::from_inner(vec![0xFF]).into_inner(), vec![0xFF_u8]); +} + +#[test] +fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts() { + let recipient_keys = test_private_account_keys_1(); + let program = crate::test_methods::simple_balance_transfer(); + let sender = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 100, + ..Account::default() + }, + true, + AccountId::new([0; 32]), + ); + + let recipient_account_id = + AccountId::for_regular_private_account(&recipient_keys.npk(), &recipient_keys.vpk(), 0); + let recipient = AccountWithMetadata::new(Account::default(), true, recipient_account_id); + + let balance_to_move: u128 = 37; + + let expected_sender_post = Account { + program_owner: program.id(), + balance: 100 - balance_to_move, + nonce: Nonce::default(), + data: Data::default(), + }; + + let expected_recipient_post = Account { + program_owner: program.id(), + balance: balance_to_move, + nonce: Nonce::private_account_nonce_init(&recipient_account_id), + data: Data::default(), + }; + + let expected_sender_pre = sender.clone(); + + let init_nonce = Nonce::private_account_nonce_init(&recipient_account_id); + let esk = EphemeralSecretKey::new(&recipient_account_id, &[0; 32], &init_nonce); + let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &esk).0; + + let (output, proof) = execute_and_prove( + vec![sender, recipient], + Program::serialize_instruction(balance_to_move).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateForeignInit { + vpk: recipient_keys.vpk(), + random_seed: [0; 32], + npk: recipient_keys.npk(), + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + }, + ], + &crate::test_methods::simple_balance_transfer().into(), + ) + .unwrap(); + + assert!(proof.is_valid_for(&output)); + + let [sender_pre] = output.public_pre_states.try_into().unwrap(); + let [sender_post] = output.public_post_states.try_into().unwrap(); + assert_eq!(sender_pre, expected_sender_pre); + assert_eq!(sender_post, expected_sender_post); + assert_eq!(output.new_commitments.len(), 1); + assert_eq!(output.new_nullifiers.len(), 1); + assert_eq!(output.encrypted_private_post_states.len(), 1); + + let (_identifier, recipient_post) = EncryptionScheme::decrypt( + &output.encrypted_private_post_states[0].ciphertext, + &shared_secret, + &output.new_nullifiers[0].0, + ) + .unwrap(); + assert_eq!(recipient_post, expected_recipient_post); +} + +#[test] +fn prove_privacy_preserving_execution_circuit_fully_private() { + let program = crate::test_methods::simple_balance_transfer(); + let sender_keys = test_private_account_keys_1(); + let recipient_keys = test_private_account_keys_2(); + + let sender_nonce = Nonce(0xdead_beef); + let sender_pre = AccountWithMetadata::new( + Account { + balance: 100, + nonce: sender_nonce, + program_owner: program.id(), + data: Data::default(), + }, + true, + AccountId::for_regular_private_account(&sender_keys.npk(), &sender_keys.vpk(), 0), + ); + let sender_account_id = + AccountId::for_regular_private_account(&sender_keys.npk(), &sender_keys.vpk(), 0); + let commitment_sender = Commitment::new(&sender_account_id, &sender_pre.account); + + let recipient_account_id = + AccountId::for_regular_private_account(&recipient_keys.npk(), &recipient_keys.vpk(), 0); + let recipient = AccountWithMetadata::new(Account::default(), true, recipient_account_id); + let balance_to_move: u128 = 37; + + let mut commitment_set = CommitmentSet::with_capacity(2); + commitment_set.extend(std::slice::from_ref(&commitment_sender)); + let expected_new_nullifiers = vec![ + ( + Nullifier::for_account_update(&commitment_sender, &sender_keys.nsk), + commitment_set.digest(), + ), + ( + Nullifier::for_account_initialization(&recipient_account_id), + DUMMY_COMMITMENT_HASH, + ), + ]; + + let program = crate::test_methods::simple_balance_transfer(); + + let expected_private_account_1 = Account { + program_owner: program.id(), + balance: 100 - balance_to_move, + nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk), + ..Default::default() + }; + let expected_private_account_2 = Account { + program_owner: program.id(), + balance: balance_to_move, + nonce: Nonce::private_account_nonce_init(&recipient_account_id), + ..Default::default() + }; + let expected_new_commitments = vec![ + Commitment::new(&sender_account_id, &expected_private_account_1), + Commitment::new(&recipient_account_id, &expected_private_account_2), + ]; + + let esk_1 = EphemeralSecretKey::new( + &sender_account_id, + &[0; 32], + &sender_nonce.private_account_nonce_increment(&sender_keys.nsk), + ); + let shared_secret_1 = SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &esk_1).0; + + let init_nonce_2 = Nonce::private_account_nonce_init(&recipient_account_id); + let esk_2 = EphemeralSecretKey::new(&recipient_account_id, &[0; 32], &init_nonce_2); + let shared_secret_2 = + SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &esk_2).0; + + let (output, proof) = execute_and_prove( + vec![sender_pre, recipient], + Program::serialize_instruction(balance_to_move).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: sender_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: sender_keys.nsk, + membership_proof: commitment_set + .get_proof_for(&commitment_sender) + .expect("sender's commitment must be in the set"), + identifier: 0, + }, + InputAccountIdentity::PrivateForeignInit { + vpk: recipient_keys.vpk(), + random_seed: [0; 32], + npk: recipient_keys.npk(), + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + }, + ], + &program.into(), + ) + .unwrap(); + + assert!(proof.is_valid_for(&output)); + assert!(output.public_pre_states.is_empty()); + assert!(output.public_post_states.is_empty()); + let sender_nullifier = expected_new_nullifiers[0].0; + let recipient_nullifier = expected_new_nullifiers[1].0; + + let mut expected_new_commitments = expected_new_commitments; + expected_new_commitments.sort_unstable_by_key(Commitment::to_byte_array); + assert_eq!(output.new_commitments, expected_new_commitments); + + let mut expected_new_nullifiers = expected_new_nullifiers; + expected_new_nullifiers.sort_unstable_by_key(|(nullifier, _)| nullifier.to_byte_array()); + assert_eq!(output.new_nullifiers, expected_new_nullifiers); + + assert_eq!(output.encrypted_private_post_states.len(), 2); + + let sender_slot = output + .new_nullifiers + .iter() + .position(|(nullifier, _)| *nullifier == sender_nullifier) + .unwrap(); + let (_identifier, sender_post) = EncryptionScheme::decrypt( + &output.encrypted_private_post_states[sender_slot].ciphertext, + &shared_secret_1, + &output.new_nullifiers[sender_slot].0, + ) + .unwrap(); + assert_eq!(sender_post, expected_private_account_1); + + let recipient_slot = output + .new_nullifiers + .iter() + .position(|(nullifier, _)| *nullifier == recipient_nullifier) + .unwrap(); + let (_identifier, recipient_post) = EncryptionScheme::decrypt( + &output.encrypted_private_post_states[recipient_slot].ciphertext, + &shared_secret_2, + &output.new_nullifiers[recipient_slot].0, + ) + .unwrap(); + assert_eq!(recipient_post, expected_private_account_2); +} + +#[test] +fn init_note_view_tag_is_derived_from_account_keys() { + let program = crate::test_methods::noop(); + let keys = test_private_account_keys_1(); + let identifier: u128 = 0; + let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier); + let account = AccountWithMetadata::new(Account::default(), true, account_id); + + let (output, proof) = execute_and_prove( + vec![account], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::PrivateForeignInit { + vpk: keys.vpk(), + random_seed: [0; 32], + npk: keys.npk(), + identifier, + commitment_root: DUMMY_COMMITMENT_HASH, + }], + &program.into(), + ) + .unwrap(); + + assert!(proof.is_valid_for(&output)); + assert_eq!(output.encrypted_private_post_states.len(), 1); + assert_eq!( + output.encrypted_private_post_states[0].view_tag, + EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()), + ); +} + +#[test] +fn update_note_view_tag_is_the_supplied_value() { + let program = crate::test_methods::noop(); + let keys = test_private_account_keys_1(); + let identifier: u128 = 99; + let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier); + let account = Account { + program_owner: program.id(), + balance: 1, + ..Account::default() + }; + let commitment = Commitment::new(&account_id, &account); + let mut commitment_set = CommitmentSet::with_capacity(1); + commitment_set.extend(std::slice::from_ref(&commitment)); + let sender = AccountWithMetadata::new(account, true, account_id); + + // A tag deliberately different from the address-derived one, so a passthrough is + // distinguishable from re-derivation. + let fed_tag = EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()).wrapping_add(1); + + let (output, proof) = execute_and_prove( + vec![sender], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: keys.vpk(), + random_seed: [0; 32], + view_tag: fed_tag, + nsk: keys.nsk, + membership_proof: commitment_set.get_proof_for(&commitment).unwrap(), + identifier, + }], + &program.into(), + ) + .unwrap(); + + assert!(proof.is_valid_for(&output)); + assert_eq!(output.encrypted_private_post_states.len(), 1); + assert_eq!(output.encrypted_private_post_states[0].view_tag, fed_tag); +} + +#[test] +fn circuit_fails_when_chained_validity_windows_have_empty_intersection() { + let account_keys = test_private_account_keys_1(); + let pre = AccountWithMetadata::new( + Account::default(), + true, + AccountId::for_regular_private_account(&account_keys.npk(), &account_keys.vpk(), 0), + ); + + let validity_window_chain_caller = crate::test_methods::validity_window_chain_caller(); + let validity_window = crate::test_methods::validity_window(); + + let instruction = Program::serialize_instruction(( + Some(1_u64), + Some(4_u64), + validity_window.id(), + Some(4_u64), + Some(7_u64), + )) + .unwrap(); + + let program_with_deps = ProgramWithDependencies::new( + validity_window_chain_caller, + [(validity_window.id(), validity_window)].into(), + ); + + let result = execute_and_prove( + vec![pre], + instruction, + vec![InputAccountIdentity::PrivateForeignInit { + vpk: account_keys.vpk(), + random_seed: [0; 32], + npk: account_keys.npk(), + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + }], + &program_with_deps, + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +/// A private PDA claimed with a non-default identifier produces a ciphertext that decrypts +/// to `PrivateAccountKind::Pda` carrying the correct `(program_id, seed, identifier)`. +#[test] +fn private_pda_claim_with_custom_identifier_encrypts_correct_kind() { + let program = crate::test_methods::pda_claimer(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + let identifier: u128 = 99; + let account_id = + AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), identifier); + let init_nonce = Nonce::private_account_nonce_init(&account_id); + let esk = EphemeralSecretKey::new(&account_id, &[0; 32], &init_nonce); + let shared_secret = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0; + + let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + + let (output, _proof) = execute_and_prove( + vec![pre_state], + Program::serialize_instruction(seed).unwrap(), + vec![InputAccountIdentity::PrivatePdaInit { + vpk: keys.vpk(), + random_seed: [0; 32], + npk, + identifier, + commitment_root: DUMMY_COMMITMENT_HASH, + seed: None, + }], + &program.clone().into(), + ) + .unwrap(); + + assert_eq!( + decrypt_kind(&output, &shared_secret, 0), + PrivateAccountKind::Pda { + program_id: program.id(), + seed, + identifier + }, + ); +} + +/// PDA init: initializes a new PDA under `simple_balance_transfer`'s ownership. +/// The `simple_transfer_proxy` program chains to `simple_balance_transfer` with `pda_seeds` +/// to establish authorization and the private PDA binding. +#[test] +fn private_pda_init() { + let program = crate::test_methods::simple_transfer_proxy(); + let simple_transfer = crate::test_methods::simple_balance_transfer(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + // PDA (new, private PDA) + let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), 0); + let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); + + let auth_id = simple_transfer.id(); + let program_with_deps = + ProgramWithDependencies::new(program, [(auth_id, simple_transfer)].into()); + + // is_withdraw=false triggers init path (1 pre-state) + let instruction = Program::serialize_instruction((seed, auth_id, 0_u128, false)).unwrap(); + + let result = execute_and_prove( + vec![pda_pre], + instruction, + vec![InputAccountIdentity::PrivatePdaInit { + vpk: keys.vpk(), + random_seed: [0; 32], + npk, + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + seed: None, + }], + &program_with_deps, + ); + + let (output, _proof) = result.expect("PDA init should succeed"); + assert_eq!(output.new_commitments.len(), 1); +} + +/// PDA withdraw: chains to `simple_balance_transfer` to move balance from PDA to recipient. +/// Uses a default PDA (amount=0) because testing with a pre-funded PDA requires a +/// two-tx sequence with membership proofs. +#[test] +fn private_pda_withdraw() { + let program = crate::test_methods::simple_transfer_proxy(); + let simple_transfer = crate::test_methods::simple_balance_transfer(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + // PDA (new, private PDA) + let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), 0); + let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); + + // Recipient (public) + let recipient_id = AccountId::new([88; 32]); + let recipient_pre = AccountWithMetadata::new( + Account { + program_owner: simple_transfer.id(), + balance: 10000, + ..Account::default() + }, + true, + recipient_id, + ); + + let auth_id = simple_transfer.id(); + let program_with_deps = + ProgramWithDependencies::new(program, [(auth_id, simple_transfer)].into()); + + // is_withdraw=true, amount=0 (PDA has no balance yet) + let instruction = Program::serialize_instruction((seed, auth_id, 0_u128, true)).unwrap(); + + let result = execute_and_prove( + vec![pda_pre, recipient_pre], + instruction, + vec![ + InputAccountIdentity::PrivatePdaInit { + vpk: keys.vpk(), + random_seed: [0; 32], + npk, + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + seed: None, + }, + InputAccountIdentity::Public, + ], + &program_with_deps, + ); + + let (output, _proof) = result.expect("PDA withdraw should succeed"); + assert_eq!(output.new_commitments.len(), 1); +} + +/// Shared regular private account: receives funds via `authenticated_transfer` directly, +/// no custom program needed. This demonstrates the non-PDA shared account flow where +/// keys are derived from GMS via `derive_keys_for_shared_account`. The shared account +/// uses the standard foreign private account path and works with auth-transfer's +/// transfer path like any other private account. +#[test] +fn shared_account_receives_via_simple_transfer() { + let program = crate::test_methods::simple_balance_transfer(); + let shared_keys = test_private_account_keys_1(); + let shared_npk = shared_keys.npk(); + let shared_identifier: u128 = 42; + + // Sender: public account with balance, owned by auth-transfer + let sender_id = AccountId::new([99; 32]); + let sender = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 1000, + ..Account::default() + }, + true, + sender_id, + ); + + // Recipient: shared private account (new, foreign) + let shared_account_id = AccountId::from((&shared_npk, &shared_keys.vpk(), shared_identifier)); + let recipient = AccountWithMetadata::new(Account::default(), true, shared_account_id); + + let balance_to_move: u128 = 100; + let instruction = Program::serialize_instruction(balance_to_move).unwrap(); + + let result = execute_and_prove( + vec![sender, recipient], + instruction, + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateForeignInit { + vpk: shared_keys.vpk(), + random_seed: [0; 32], + npk: shared_npk, + identifier: shared_identifier, + commitment_root: DUMMY_COMMITMENT_HASH, + }, + ], + &program.into(), + ); + + let (output, _proof) = result.expect("shared account receive should succeed"); + // Sender is public (no commitment), recipient is private (1 commitment) + assert_eq!(output.new_commitments.len(), 1); +} + +/// `PrivateAuthorizedInit` with a non-default identifier produces a ciphertext that decrypts +/// to `PrivateAccountKind::Regular` carrying the correct identifier. +#[test] +fn private_authorized_init_encrypts_regular_kind_with_identifier() { + let program = crate::test_methods::claimer(); + let keys = test_private_account_keys_1(); + let identifier: u128 = 99; + let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier); + let esk = EphemeralSecretKey::new( + &account_id, + &[0; 32], + &Nonce::private_account_nonce_init(&account_id), + ); + let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0; + let pre = AccountWithMetadata::new(Account::default(), true, account_id); + + let (output, _) = execute_and_prove( + vec![pre], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::PrivateAuthorizedInit { + vpk: keys.vpk(), + random_seed: [0; 32], + nsk: keys.nsk, + identifier, + commitment_root: DUMMY_COMMITMENT_HASH, + }], + &program.into(), + ) + .unwrap(); + + assert_eq!( + decrypt_kind(&output, &ssk, 0), + PrivateAccountKind::Regular(identifier) + ); +} + +/// `PrivateForeignInit` with a non-default identifier produces a ciphertext that decrypts +/// to `PrivateAccountKind::Regular` carrying the correct identifier. +#[test] +fn private_foreign_init_encrypts_regular_kind_with_identifier() { + let program = crate::test_methods::claimer(); + let keys = test_private_account_keys_1(); + let identifier: u128 = 99; + let recipient_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier); + let esk = EphemeralSecretKey::new( + &recipient_id, + &[0; 32], + &Nonce::private_account_nonce_init(&recipient_id), + ); + let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0; + let recipient = AccountWithMetadata::new(Account::default(), true, recipient_id); + + let (output, _) = execute_and_prove( + vec![recipient], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::PrivateForeignInit { + vpk: keys.vpk(), + random_seed: [0; 32], + npk: keys.npk(), + identifier, + commitment_root: DUMMY_COMMITMENT_HASH, + }], + &program.into(), + ) + .unwrap(); + + assert_eq!( + decrypt_kind(&output, &ssk, 0), + PrivateAccountKind::Regular(identifier) + ); +} + +/// `PrivateAuthorizedUpdate` with a non-default identifier produces a ciphertext that decrypts +/// to `PrivateAccountKind::Regular` carrying the correct identifier. +#[test] +fn private_authorized_update_encrypts_regular_kind_with_identifier() { + let program = crate::test_methods::noop(); + let keys = test_private_account_keys_1(); + let identifier: u128 = 99; + let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier); + let esk = EphemeralSecretKey::new( + &account_id, + &[0; 32], + &Nonce::default().private_account_nonce_increment(&keys.nsk), + ); + let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0; + let account = Account { + program_owner: program.id(), + balance: 1, + ..Account::default() + }; + let commitment = Commitment::new(&account_id, &account); + let mut commitment_set = CommitmentSet::with_capacity(1); + commitment_set.extend(std::slice::from_ref(&commitment)); + + let sender = AccountWithMetadata::new(account, true, account_id); + + let (output, _) = execute_and_prove( + vec![sender], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: keys.nsk, + membership_proof: commitment_set.get_proof_for(&commitment).unwrap(), + identifier, + }], + &program.into(), + ) + .unwrap(); + + assert_eq!( + decrypt_kind(&output, &ssk, 0), + PrivateAccountKind::Regular(identifier) + ); +} + +/// `PrivatePdaUpdate` with a non-default identifier produces a ciphertext that decrypts +/// to `PrivateAccountKind::Pda` carrying the correct `(program_id, seed, identifier)`. +#[test] +fn private_pda_update_encrypts_pda_kind_with_identifier() { + let program = crate::test_methods::pda_spend_proxy(); + let simple_transfer = crate::test_methods::simple_balance_transfer(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + let identifier: u128 = 99; + let simple_transfer_id = simple_transfer.id(); + let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), identifier); + let esk = EphemeralSecretKey::new( + &pda_id, + &[0; 32], + &Nonce::default().private_account_nonce_increment(&keys.nsk), + ); + let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0; + let pda_account = Account { + program_owner: simple_transfer_id, + balance: 1, + ..Account::default() + }; + let pda_commitment = Commitment::new(&pda_id, &pda_account); + let mut commitment_set = CommitmentSet::with_capacity(1); + commitment_set.extend(std::slice::from_ref(&pda_commitment)); + + let pda_pre = AccountWithMetadata::new(pda_account, true, pda_id); + let recipient_pre = AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32])); + + let program_with_deps = ProgramWithDependencies::new( + program.clone(), + [(simple_transfer_id, simple_transfer)].into(), + ); + + let (output, _) = execute_and_prove( + vec![pda_pre, recipient_pre], + Program::serialize_instruction((seed, 1_u128, simple_transfer_id, false)).unwrap(), + vec![ + InputAccountIdentity::PrivatePdaUpdate { + vpk: keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: keys.nsk, + membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(), + identifier, + seed: None, + }, + InputAccountIdentity::Public, + ], + &program_with_deps, + ) + .unwrap(); + + assert_eq!( + decrypt_kind(&output, &ssk, 0), + PrivateAccountKind::Pda { + program_id: program.id(), + seed, + identifier + }, + ); +} + +#[test] +fn private_pda_init_identifier_mismatch_fails() { + let program = crate::test_methods::pda_claimer(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), 5); + let pre_state = AccountWithMetadata::new(Account::default(), true, account_id); + + let result = execute_and_prove( + vec![pre_state], + Program::serialize_instruction(seed).unwrap(), + vec![InputAccountIdentity::PrivatePdaInit { + vpk: keys.vpk(), + random_seed: [0; 32], + npk, + identifier: 99, + commitment_root: DUMMY_COMMITMENT_HASH, + seed: None, + }], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn private_pda_update_identifier_mismatch_fails() { + let program = crate::test_methods::pda_spend_proxy(); + let simple_transfer = crate::test_methods::simple_balance_transfer(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + let simple_transfer_id = simple_transfer.id(); + let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), 5); + let pda_account = Account { + program_owner: simple_transfer_id, + balance: 1, + ..Account::default() + }; + let pda_commitment = Commitment::new(&pda_id, &pda_account); + let mut commitment_set = CommitmentSet::with_capacity(1); + commitment_set.extend(std::slice::from_ref(&pda_commitment)); + + let pda_pre = AccountWithMetadata::new(pda_account, true, pda_id); + let recipient_pre = AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32])); + + let program_with_deps = + ProgramWithDependencies::new(program, [(simple_transfer_id, simple_transfer)].into()); + + let result = execute_and_prove( + vec![pda_pre, recipient_pre], + Program::serialize_instruction((seed, 1_u128, simple_transfer_id, false)).unwrap(), + vec![ + InputAccountIdentity::PrivatePdaUpdate { + vpk: keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: keys.nsk, + membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(), + identifier: 99, + seed: None, + }, + InputAccountIdentity::Public, + ], + &program_with_deps, + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} diff --git a/lee/state_machine/src/privacy_preserving_transaction/message.rs b/lee/state_machine/src/privacy_preserving_transaction/message.rs index b2594912..3b6704ff 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/message.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/message.rs @@ -11,7 +11,7 @@ use crate::{AccountId, error::LeeError}; const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Privacy/\x00\x00\x00\x00\x00\x00"; -#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[derive(Clone, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct Message { pub public_account_ids: Vec, pub nonces: Vec, @@ -84,13 +84,27 @@ impl Message { Sha256::digest(bytes).into() } + + /// Ensure that the commitments, nullifiers, and ciphertexts agree. + pub fn validate_note_lengths(&self) -> Result { + let count = self.new_nullifiers.len(); + if self.new_commitments.len() != count || self.encrypted_private_post_states.len() != count + { + return Err(LeeError::InvalidInput(format!( + "Note vectors disagree in length with {count} nullifiers, {} commitments, and {} ciphertexts", + self.new_commitments.len(), + self.encrypted_private_post_states.len(), + ))); + } + Ok(count) + } } #[cfg(test)] pub mod tests { use lee_core::{ - Commitment, EncryptionScheme, Nullifier, NullifierPublicKey, PrivateAccountKind, - SharedSecretKey, + Commitment, EncryptionScheme, EphemeralSecretKey, Nullifier, NullifierPublicKey, + PrivateAccountKind, SharedSecretKey, account::{Account, AccountId, Nonce}, encryption::ViewingPublicKey, program::{BlockValidityWindow, TimestampValidityWindow}, @@ -109,6 +123,7 @@ pub mod tests { let npk1 = NullifierPublicKey::from(&nsk1); let npk2 = NullifierPublicKey::from(&nsk2); + let vpk = ViewingPublicKey::from_seed(&[7; 32], &[8; 32]); let public_account_ids = vec![AccountId::new([1; 32])]; @@ -118,10 +133,10 @@ pub mod tests { let encrypted_private_post_states = Vec::new(); - let account_id2 = lee_core::account::AccountId::for_regular_private_account(&npk2, 0); + let account_id2 = lee_core::account::AccountId::for_regular_private_account(&npk2, &vpk, 0); let new_commitments = vec![Commitment::new(&account_id2, &account2)]; - let account_id1 = lee_core::account::AccountId::for_regular_private_account(&npk1, 0); + let account_id1 = lee_core::account::AccountId::for_regular_private_account(&npk1, &vpk, 0); let old_commitment = Commitment::new(&account_id1, &account1); let new_nullifiers = vec![( Nullifier::for_account_update(&old_commitment, &nsk1), @@ -140,6 +155,20 @@ pub mod tests { } } + #[test] + fn validate_note_lengths_accepts_matching_and_rejects_mismatched() { + assert_eq!(Message::default().validate_note_lengths().unwrap(), 0); + + let mismatched = Message { + new_commitments: vec![Commitment::new( + &AccountId::new([0; 32]), + &Account::default(), + )], + ..Default::default() + }; + assert!(mismatched.validate_note_lengths().is_err()); + } + #[test] fn hash_privacy_pinned() { let msg = Message { @@ -197,15 +226,15 @@ pub mod tests { let npk = NullifierPublicKey::from(&[1; 32]); let vpk = ViewingPublicKey::from_seed(&[2_u8; 32], &[3_u8; 32]); let account = Account::default(); - let account_id = lee_core::account::AccountId::for_regular_private_account(&npk, 0); - let commitment = Commitment::new(&account_id, &account); - let (shared_secret, epk) = SharedSecretKey::encapsulate_deterministic(&vpk, &[0_u8; 32], 0); + let account_id = lee_core::account::AccountId::for_regular_private_account(&npk, &vpk, 0); + let nullifier = Nullifier::for_account_initialization(&account_id); + let (shared_secret, epk) = + SharedSecretKey::encapsulate_deterministic(&vpk, &EphemeralSecretKey([0_u8; 32])); let ciphertext = EncryptionScheme::encrypt( &account, &PrivateAccountKind::Regular(0), &shared_secret, - &commitment, - 2, + &nullifier, ); let encrypted_account_data = EncryptedAccountData::new(ciphertext.clone(), &npk, &vpk, epk.clone()); diff --git a/lee/state_machine/src/program.rs b/lee/state_machine/src/program/mod.rs similarity index 72% rename from lee/state_machine/src/program.rs rename to lee/state_machine/src/program/mod.rs index 65d60a42..d481c1fa 100644 --- a/lee/state_machine/src/program.rs +++ b/lee/state_machine/src/program/mod.rs @@ -111,42 +111,4 @@ impl Program { } #[cfg(test)] -mod tests { - use lee_core::account::{Account, AccountId, AccountWithMetadata}; - - use crate::program::Program; - - #[test] - fn program_execution() { - let program = crate::test_methods::simple_balance_transfer(); - let balance_to_move: u128 = 11_223_344_556_677; - let instruction_data = Program::serialize_instruction(balance_to_move).unwrap(); - let sender = AccountWithMetadata::new( - Account { - balance: 77_665_544_332_211, - ..Account::default() - }, - true, - AccountId::new([0; 32]), - ); - let recipient = - AccountWithMetadata::new(Account::default(), false, AccountId::new([1; 32])); - - let expected_sender_post = Account { - balance: 77_665_544_332_211 - balance_to_move, - ..Account::default() - }; - let expected_recipient_post = Account { - balance: balance_to_move, - ..Account::default() - }; - let program_output = program - .execute(None, &[sender, recipient], &instruction_data) - .unwrap(); - - let [sender_post, recipient_post] = program_output.post_states.try_into().unwrap(); - - assert_eq!(sender_post.account(), &expected_sender_post); - assert_eq!(recipient_post.account(), &expected_recipient_post); - } -} +mod tests; diff --git a/lee/state_machine/src/program/tests.rs b/lee/state_machine/src/program/tests.rs new file mode 100644 index 00000000..330bd0d6 --- /dev/null +++ b/lee/state_machine/src/program/tests.rs @@ -0,0 +1,36 @@ +use lee_core::account::{Account, AccountId, AccountWithMetadata}; + +use crate::program::Program; + +#[test] +fn program_execution() { + let program = crate::test_methods::simple_balance_transfer(); + let balance_to_move: u128 = 11_223_344_556_677; + let instruction_data = Program::serialize_instruction(balance_to_move).unwrap(); + let sender = AccountWithMetadata::new( + Account { + balance: 77_665_544_332_211, + ..Account::default() + }, + true, + AccountId::new([0; 32]), + ); + let recipient = AccountWithMetadata::new(Account::default(), false, AccountId::new([1; 32])); + + let expected_sender_post = Account { + balance: 77_665_544_332_211 - balance_to_move, + ..Account::default() + }; + let expected_recipient_post = Account { + balance: balance_to_move, + ..Account::default() + }; + let program_output = program + .execute(None, &[sender, recipient], &instruction_data) + .unwrap(); + + let [sender_post, recipient_post] = program_output.post_states.try_into().unwrap(); + + assert_eq!(sender_post.account(), &expected_sender_post); + assert_eq!(recipient_post.account(), &expected_recipient_post); +} diff --git a/lee/state_machine/src/program_deployment_transaction/message.rs b/lee/state_machine/src/program_deployment_transaction/message.rs index 866399e8..34c6a806 100644 --- a/lee/state_machine/src/program_deployment_transaction/message.rs +++ b/lee/state_machine/src/program_deployment_transaction/message.rs @@ -1,10 +1,18 @@ use borsh::{BorshDeserialize, BorshSerialize}; -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct Message { pub(crate) bytecode: Vec, } +impl std::fmt::Debug for Message { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Message") + .field("bytecode", &format_args!("<{} bytes>", self.bytecode.len())) + .finish() + } +} + impl Message { #[must_use] pub const fn new(bytecode: Vec) -> Self { diff --git a/lee/state_machine/src/state.rs b/lee/state_machine/src/state.rs deleted file mode 100644 index c399cea1..00000000 --- a/lee/state_machine/src/state.rs +++ /dev/null @@ -1,4414 +0,0 @@ -use std::collections::{BTreeSet, HashMap, HashSet}; - -use borsh::{BorshDeserialize, BorshSerialize}; -use lee_core::{ - BlockId, Commitment, CommitmentSetDigest, DUMMY_COMMITMENT, MembershipProof, Nullifier, - Timestamp, - account::{Account, AccountId}, - program::ProgramId, -}; - -use crate::{ - error::LeeError, - merkle_tree::MerkleTree, - privacy_preserving_transaction::PrivacyPreservingTransaction, - program::Program, - program_deployment_transaction::ProgramDeploymentTransaction, - public_transaction::PublicTransaction, - validated_state_diff::{StateDiff, ValidatedStateDiff}, -}; - -pub const MAX_NUMBER_CHAINED_CALLS: usize = 10; - -#[derive(Clone, BorshSerialize, BorshDeserialize)] -#[cfg_attr(test, derive(Debug, PartialEq, Eq))] -pub struct CommitmentSet { - merkle_tree: MerkleTree, - commitments: HashMap, - root_history: HashSet, -} - -impl CommitmentSet { - pub(crate) fn digest(&self) -> CommitmentSetDigest { - self.merkle_tree.root() - } - - /// Queries the `CommitmentSet` for a membership proof of commitment. - pub fn get_proof_for(&self, commitment: &Commitment) -> Option { - let index = *self.commitments.get(commitment)?; - - self.merkle_tree - .get_authentication_path_for(index) - .map(|path| (index, path)) - } - - /// Inserts a list of commitments to the `CommitmentSet`. - pub(crate) fn extend(&mut self, commitments: &[Commitment]) { - for commitment in commitments.iter().cloned() { - let index = self.merkle_tree.insert(commitment.to_byte_array()); - self.commitments.insert(commitment, index); - } - self.root_history.insert(self.digest()); - } - - fn contains(&self, commitment: &Commitment) -> bool { - self.commitments.contains_key(commitment) - } - - /// Initializes an empty `CommitmentSet` with a given capacity. - /// If the capacity is not a `power_of_two`, then capacity is taken - /// to be the next `power_of_two`. - pub(crate) fn with_capacity(capacity: usize) -> Self { - Self { - merkle_tree: MerkleTree::with_capacity(capacity), - commitments: HashMap::new(), - root_history: HashSet::new(), - } - } -} - -#[cfg_attr(test, derive(Debug, PartialEq, Eq))] -#[derive(Clone)] -struct NullifierSet(BTreeSet); - -impl NullifierSet { - const fn new() -> Self { - Self(BTreeSet::new()) - } - - fn extend(&mut self, new_nullifiers: &[Nullifier]) { - self.0.extend(new_nullifiers); - } - - fn contains(&self, nullifier: &Nullifier) -> bool { - self.0.contains(nullifier) - } -} - -impl BorshSerialize for NullifierSet { - fn serialize(&self, writer: &mut W) -> std::io::Result<()> { - self.0.iter().collect::>().serialize(writer) - } -} - -impl BorshDeserialize for NullifierSet { - fn deserialize_reader(reader: &mut R) -> std::io::Result { - let vec = Vec::::deserialize_reader(reader)?; - - let mut set = BTreeSet::new(); - for n in vec { - if !set.insert(n) { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "duplicate nullifier in NullifierSet", - )); - } - } - - Ok(Self(set)) - } -} - -#[derive(Clone, BorshSerialize, BorshDeserialize)] -#[cfg_attr(test, derive(Debug, PartialEq, Eq))] -pub struct V03State { - public_state: HashMap, - private_state: (CommitmentSet, NullifierSet), - programs: HashMap, -} - -impl Default for V03State { - fn default() -> Self { - let mut commitment_set = CommitmentSet::with_capacity(32); - commitment_set.extend(&[DUMMY_COMMITMENT]); - let nullifier_set = NullifierSet::new(); - let private_state = (commitment_set, nullifier_set); - - Self { - public_state: HashMap::default(), - private_state, - programs: HashMap::default(), - } - } -} - -impl V03State { - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Initializes state with given public account balances leaving other account fields at their - /// default values. - #[must_use] - pub fn with_public_account_balances( - mut self, - balances: impl IntoIterator, - ) -> Self { - let public_accounts = balances.into_iter().map(|(account_id, balance)| { - ( - account_id, - Account { - balance, - ..Account::default() - }, - ) - }); - self.public_state.extend(public_accounts); - self - } - - /// Initializes state with given public accounts. - #[must_use] - pub fn with_public_accounts( - mut self, - public_accounts: impl IntoIterator, - ) -> Self { - self.public_state.extend(public_accounts); - self - } - - /// Initializes state with given private accounts. - #[must_use] - pub fn with_private_accounts( - mut self, - private_accounts: impl IntoIterator, - ) -> Self { - let (commitments, nullifiers): (Vec, Vec) = - private_accounts.into_iter().unzip(); - self.private_state.0.extend(&commitments); - self.private_state.1.extend(&nullifiers); - self - } - - /// Initializes state with given builtin programs. - #[must_use] - pub fn with_programs(mut self, programs: impl IntoIterator) -> Self { - for program in programs { - self.insert_program(program); - } - self - } - - pub(crate) fn insert_program(&mut self, program: Program) { - self.programs.insert(program.id(), program); - } - - pub fn apply_state_diff(&mut self, diff: ValidatedStateDiff) { - let StateDiff { - signer_account_ids, - public_diff, - new_commitments, - new_nullifiers, - program, - } = diff.into_state_diff(); - #[expect( - clippy::iter_over_hash_type, - reason = "Iteration order doesn't matter here" - )] - for (account_id, account) in public_diff { - *self.get_account_by_id_mut(account_id) = account; - } - for account_id in signer_account_ids { - self.get_account_by_id_mut(account_id) - .nonce - .public_account_nonce_increment(); - } - self.private_state.0.extend(&new_commitments); - self.private_state.1.extend(&new_nullifiers); - if let Some(program) = program { - self.insert_program(program); - } - } - - pub fn transition_from_public_transaction( - &mut self, - tx: &PublicTransaction, - block_id: BlockId, - timestamp: Timestamp, - ) -> Result<(), LeeError> { - let diff = ValidatedStateDiff::from_public_transaction(tx, self, block_id, timestamp)?; - self.apply_state_diff(diff); - Ok(()) - } - - pub fn transition_from_privacy_preserving_transaction( - &mut self, - tx: &PrivacyPreservingTransaction, - block_id: BlockId, - timestamp: Timestamp, - ) -> Result<(), LeeError> { - let diff = - ValidatedStateDiff::from_privacy_preserving_transaction(tx, self, block_id, timestamp)?; - self.apply_state_diff(diff); - Ok(()) - } - - pub fn transition_from_program_deployment_transaction( - &mut self, - tx: &ProgramDeploymentTransaction, - ) -> Result<(), LeeError> { - let diff = ValidatedStateDiff::from_program_deployment_transaction(tx, self)?; - self.apply_state_diff(diff); - Ok(()) - } - - fn get_account_by_id_mut(&mut self, account_id: AccountId) -> &mut Account { - self.public_state.entry(account_id).or_default() - } - - #[must_use] - pub fn get_account_by_id(&self, account_id: AccountId) -> Account { - self.public_state - .get(&account_id) - .cloned() - .unwrap_or_else(Account::default) - } - - #[must_use] - pub fn get_proof_for_commitment(&self, commitment: &Commitment) -> Option { - self.private_state.0.get_proof_for(commitment) - } - - pub(crate) const fn programs(&self) -> &HashMap { - &self.programs - } - - #[must_use] - pub fn commitment_set_digest(&self) -> CommitmentSetDigest { - self.private_state.0.digest() - } - - pub(crate) fn check_commitments_are_new( - &self, - new_commitments: &[Commitment], - ) -> Result<(), LeeError> { - for commitment in new_commitments { - if self.private_state.0.contains(commitment) { - return Err(LeeError::InvalidInput("Commitment already seen".to_owned())); - } - } - Ok(()) - } - - pub(crate) fn check_nullifiers_are_valid( - &self, - new_nullifiers: &[(Nullifier, CommitmentSetDigest)], - ) -> Result<(), LeeError> { - for (nullifier, digest) in new_nullifiers { - if self.private_state.1.contains(nullifier) { - return Err(LeeError::InvalidInput("Nullifier already seen".to_owned())); - } - if !self.private_state.0.root_history.contains(digest) { - return Err(LeeError::InvalidInput( - "Unrecognized commitment set digest".to_owned(), - )); - } - } - Ok(()) - } -} - -#[cfg(any(test, feature = "test-utils"))] -impl V03State { - pub fn force_insert_account(&mut self, account_id: AccountId, account: Account) { - self.public_state.insert(account_id, account); - } -} - -#[cfg(test)] -pub mod tests { - #![expect( - clippy::arithmetic_side_effects, - clippy::shadow_unrelated, - reason = "We don't care about it in tests" - )] - - use std::collections::HashMap; - - use lee_core::{ - BlockId, Commitment, EncryptedAccountData, InputAccountIdentity, Nullifier, - NullifierPublicKey, NullifierSecretKey, SharedSecretKey, Timestamp, - account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data}, - encryption::{EphemeralPublicKey, ViewingPublicKey}, - program::{ - BlockValidityWindow, ExecutionValidationError, MAX_NUMBER_CHAINED_CALLS, PdaSeed, - ProgramId, TimestampValidityWindow, WrappedBalanceSum, - }, - }; - - use crate::{ - PublicKey, PublicTransaction, V03State, - error::{InvalidProgramBehaviorError, LeeError}, - execute_and_prove, - privacy_preserving_transaction::{ - PrivacyPreservingTransaction, - circuit::{self, ProgramWithDependencies}, - message::Message, - witness_set::WitnessSet, - }, - program::Program, - public_transaction, - signature::PrivateKey, - }; - - impl V03State { - /// Include test programs in the builtin programs map. - #[must_use] - pub fn with_test_programs(mut self) -> Self { - self.insert_program(crate::test_methods::simple_balance_transfer()); - self.insert_program(crate::test_methods::nonce_changer()); - self.insert_program(crate::test_methods::extra_output()); - self.insert_program(crate::test_methods::missing_output()); - self.insert_program(crate::test_methods::program_owner_changer()); - self.insert_program(crate::test_methods::data_changer()); - self.insert_program(crate::test_methods::minter()); - self.insert_program(crate::test_methods::burner()); - self.insert_program(crate::test_methods::auth_asserting_noop()); - self.insert_program(crate::test_methods::private_pda_delegator()); - self.insert_program(crate::test_methods::pda_claimer()); - self.insert_program(crate::test_methods::two_pda_claimer()); - self.insert_program(crate::test_methods::noop()); - self.insert_program(crate::test_methods::chain_caller()); - self.insert_program(crate::test_methods::modified_transfer_program()); - self.insert_program(crate::test_methods::malicious_authorization_changer()); - self.insert_program(crate::test_methods::validity_window()); - self.insert_program(crate::test_methods::flash_swap_initiator()); - self.insert_program(crate::test_methods::flash_swap_callback()); - self.insert_program(crate::test_methods::malicious_self_program_id()); - self.insert_program(crate::test_methods::malicious_caller_program_id()); - self.insert_program(crate::test_methods::pda_spend_proxy()); - self.insert_program(crate::test_methods::claimer()); - self.insert_program(crate::test_methods::changer_claimer()); - self.insert_program(crate::test_methods::validity_window_chain_caller()); - self.insert_program(crate::test_methods::simple_transfer_proxy()); - self.insert_program(crate::test_methods::malicious_injector()); - self.insert_program(crate::test_methods::malicious_launderer()); - self.insert_program(crate::test_methods::modified_transfer_program()); - self - } - - #[must_use] - pub fn with_non_default_accounts_but_default_program_owners(mut self) -> Self { - let account_with_default_values_except_balance = Account { - balance: 100, - ..Account::default() - }; - let account_with_default_values_except_nonce = Account { - nonce: Nonce(37), - ..Account::default() - }; - let account_with_default_values_except_data = Account { - data: vec![0xca, 0xfe].try_into().unwrap(), - ..Account::default() - }; - self.force_insert_account( - AccountId::new([255; 32]), - account_with_default_values_except_balance, - ); - self.force_insert_account( - AccountId::new([254; 32]), - account_with_default_values_except_nonce, - ); - self.force_insert_account( - AccountId::new([253; 32]), - account_with_default_values_except_data, - ); - self - } - - #[must_use] - pub fn with_account_owned_by_burner_program(mut self) -> Self { - let account = Account { - program_owner: crate::test_methods::burner().id(), - balance: 100, - ..Default::default() - }; - self.force_insert_account(AccountId::new([252; 32]), account); - self - } - - #[must_use] - pub fn with_private_account(mut self, keys: &TestPrivateKeys, account: &Account) -> Self { - let account_id = AccountId::for_regular_private_account(&keys.npk(), 0); - let commitment = Commitment::new(&account_id, account); - self.private_state.0.extend(&[commitment]); - self - } - } - - pub struct TestPublicKeys { - pub signing_key: PrivateKey, - } - - impl TestPublicKeys { - pub fn account_id(&self) -> AccountId { - AccountId::from(&PublicKey::new_from_private_key(&self.signing_key)) - } - } - - pub struct TestPrivateKeys { - pub nsk: NullifierSecretKey, - pub d: [u8; 32], - pub z: [u8; 32], - } - - impl TestPrivateKeys { - pub fn npk(&self) -> NullifierPublicKey { - NullifierPublicKey::from(&self.nsk) - } - - pub fn vpk(&self) -> ViewingPublicKey { - ViewingPublicKey::from_seed(&self.d, &self.z) - } - } - - // ── Flash Swap types (mirrors of guest types for host-side serialisation) ── - - #[derive(serde::Serialize, serde::Deserialize)] - struct CallbackInstruction { - return_funds: bool, - token_program_id: ProgramId, - amount: u128, - } - - #[derive(serde::Serialize, serde::Deserialize)] - enum FlashSwapInstruction { - Initiate { - token_program_id: ProgramId, - callback_program_id: ProgramId, - amount_out: u128, - callback_instruction_data: Vec, - }, - InvariantCheck { - min_vault_balance: u128, - }, - } - - fn public_state_from_balances( - initial_data: &[(AccountId, u128)], - ) -> HashMap { - initial_data - .iter() - .copied() - .map(|(account_id, balance)| { - ( - account_id, - Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance, - ..Account::default() - }, - ) - }) - .collect() - } - - fn transfer_transaction( - from: AccountId, - from_key: &PrivateKey, - from_nonce: u128, - to: AccountId, - to_key: &PrivateKey, - to_nonce: u128, - balance: u128, - ) -> PublicTransaction { - let account_ids = vec![from, to]; - let nonces = vec![Nonce(from_nonce), Nonce(to_nonce)]; - let program_id = crate::test_methods::simple_balance_transfer().id(); - let message = - public_transaction::Message::try_new(program_id, account_ids, nonces, balance).unwrap(); - let witness_set = - public_transaction::WitnessSet::for_message(&message, &[from_key, to_key]); - PublicTransaction::new(message, witness_set) - } - - fn build_flash_swap_tx( - initiator: &Program, - vault_id: AccountId, - receiver_id: AccountId, - instruction: FlashSwapInstruction, - ) -> PublicTransaction { - let message = public_transaction::Message::try_new( - initiator.id(), - vec![vault_id, receiver_id], - vec![], // no signers — vault is PDA-authorised - instruction, - ) - .unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - PublicTransaction::new(message, witness_set) - } - - #[test] - fn new_works() { - let key1 = PrivateKey::try_new([1; 32]).unwrap(); - let key2 = PrivateKey::try_new([2; 32]).unwrap(); - let addr1 = AccountId::from(&PublicKey::new_from_private_key(&key1)); - let addr2 = AccountId::from(&PublicKey::new_from_private_key(&key2)); - let expected_public_state = { - let mut this = HashMap::new(); - this.insert( - addr1, - Account { - balance: 100, - ..Account::default() - }, - ); - this.insert( - addr2, - Account { - balance: 151, - ..Account::default() - }, - ); - this - }; - let expected_builtin_programs = HashMap::new(); - - let state = - V03State::new().with_public_account_balances([(addr1, 100_u128), (addr2, 151_u128)]); - - assert_eq!(state.public_state, expected_public_state); - assert_eq!(state.programs, expected_builtin_programs); - } - - #[test] - fn new_includes_nullifiers_for_private_accounts() { - let keys1 = test_private_account_keys_1(); - let keys2 = test_private_account_keys_2(); - - let account = Account { - balance: 100, - ..Account::default() - }; - - let account_id1 = AccountId::for_regular_private_account(&keys1.npk(), 0); - let account_id2 = AccountId::for_regular_private_account(&keys2.npk(), 0); - - let init_commitment1 = Commitment::new(&account_id1, &account); - let init_commitment2 = Commitment::new(&account_id2, &account); - let init_nullifier1 = Nullifier::for_account_initialization(&account_id1); - let init_nullifier2 = Nullifier::for_account_initialization(&account_id2); - - let initial_private_accounts = vec![ - (init_commitment1, init_nullifier1), - (init_commitment2, init_nullifier2), - ]; - - let state = V03State::new().with_private_accounts(initial_private_accounts); - - assert!(state.private_state.1.contains(&init_nullifier1)); - assert!(state.private_state.1.contains(&init_nullifier2)); - } - - #[test] - fn insert_program() { - let mut state = V03State::new(); - let program_to_insert = crate::test_methods::simple_balance_transfer(); - let program_id = program_to_insert.id(); - assert!(!state.programs.contains_key(&program_id)); - - state.insert_program(program_to_insert); - - assert!(state.programs.contains_key(&program_id)); - } - - #[test] - fn get_account_by_account_id_non_default_account() { - let key = PrivateKey::try_new([1; 32]).unwrap(); - let account_id = AccountId::from(&PublicKey::new_from_private_key(&key)); - let initial_data = [( - account_id, - Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance: 100, - ..Account::default() - }, - )]; - let state = V03State::new().with_public_accounts(initial_data); - let expected_account = &state.public_state[&account_id]; - - let account = state.get_account_by_id(account_id); - - assert_eq!(&account, expected_account); - } - - #[test] - fn get_account_by_account_id_default_account() { - let addr2 = AccountId::new([0; 32]); - let state = V03State::new(); - let expected_account = Account::default(); - - let account = state.get_account_by_id(addr2); - - assert_eq!(account, expected_account); - } - - #[test] - fn builtin_programs_getter() { - let state = V03State::new(); - - let builtin_programs = state.programs(); - - assert_eq!(builtin_programs, &state.programs); - } - - #[test] - fn transition_from_authenticated_transfer_program_invocation_default_account_destination() { - let key = PrivateKey::try_new([1; 32]).unwrap(); - let account_id = AccountId::from(&PublicKey::new_from_private_key(&key)); - let initial_data = [( - account_id, - Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance: 100, - ..Account::default() - }, - )]; - let mut state = V03State::new() - .with_public_accounts(initial_data) - .with_test_programs(); - let from = account_id; - let to_key = PrivateKey::try_new([2; 32]).unwrap(); - let to = AccountId::from(&PublicKey::new_from_private_key(&to_key)); - assert_eq!(state.get_account_by_id(to), Account::default()); - let balance_to_move = 5; - - let tx = transfer_transaction(from, &key, 0, to, &to_key, 0, balance_to_move); - state.transition_from_public_transaction(&tx, 1, 0).unwrap(); - - assert_eq!(state.get_account_by_id(from).balance, 95); - assert_eq!(state.get_account_by_id(to).balance, 5); - assert_eq!(state.get_account_by_id(from).nonce, Nonce(1)); - assert_eq!(state.get_account_by_id(to).nonce, Nonce(1)); - } - - #[test] - fn transition_from_authenticated_transfer_program_invocation_insuficient_balance() { - let key = PrivateKey::try_new([1; 32]).unwrap(); - let account_id = AccountId::from(&PublicKey::new_from_private_key(&key)); - let mut state = V03State::new() - .with_public_account_balances([(account_id, 100)]) - .with_test_programs(); - let from = account_id; - let from_key = key; - let to_key = PrivateKey::try_new([2; 32]).unwrap(); - let to = AccountId::from(&PublicKey::new_from_private_key(&to_key)); - let balance_to_move = 101; - assert!(state.get_account_by_id(from).balance < balance_to_move); - - let tx = transfer_transaction(from, &from_key, 0, to, &to_key, 0, balance_to_move); - let result = state.transition_from_public_transaction(&tx, 1, 0); - - assert!(matches!(result, Err(LeeError::ProgramExecutionFailed(_)))); - assert_eq!(state.get_account_by_id(from).balance, 100); - assert_eq!(state.get_account_by_id(to).balance, 0); - assert_eq!(state.get_account_by_id(from).nonce, Nonce(0)); - assert_eq!(state.get_account_by_id(to).nonce, Nonce(0)); - } - - #[test] - fn transition_from_authenticated_transfer_program_invocation_non_default_account_destination() { - let key1 = PrivateKey::try_new([1; 32]).unwrap(); - let key2 = PrivateKey::try_new([2; 32]).unwrap(); - let account_id1 = AccountId::from(&PublicKey::new_from_private_key(&key1)); - let account_id2 = AccountId::from(&PublicKey::new_from_private_key(&key2)); - let initial_data = [ - ( - account_id1, - Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance: 100, - ..Account::default() - }, - ), - ( - account_id2, - Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance: 200, - ..Account::default() - }, - ), - ]; - let mut state = V03State::new() - .with_public_accounts(initial_data) - .with_test_programs(); - let from = account_id2; - let from_key = key2; - let to = account_id1; - let to_key = key1; - assert_ne!(state.get_account_by_id(to), Account::default()); - let balance_to_move = 8; - - let tx = transfer_transaction(from, &from_key, 0, to, &to_key, 0, balance_to_move); - state.transition_from_public_transaction(&tx, 1, 0).unwrap(); - - assert_eq!(state.get_account_by_id(from).balance, 192); - assert_eq!(state.get_account_by_id(to).balance, 108); - assert_eq!(state.get_account_by_id(from).nonce, Nonce(1)); - assert_eq!(state.get_account_by_id(to).nonce, Nonce(1)); - } - - #[test] - fn transition_from_sequence_of_authenticated_transfer_program_invocations() { - let key1 = PrivateKey::try_new([8; 32]).unwrap(); - let account_id1 = AccountId::from(&PublicKey::new_from_private_key(&key1)); - let key2 = PrivateKey::try_new([2; 32]).unwrap(); - let account_id2 = AccountId::from(&PublicKey::new_from_private_key(&key2)); - let initial_data = [( - account_id1, - Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance: 100, - ..Account::default() - }, - )]; - let mut state = V03State::new() - .with_public_accounts(initial_data) - .with_test_programs(); - let key3 = PrivateKey::try_new([3; 32]).unwrap(); - let account_id3 = AccountId::from(&PublicKey::new_from_private_key(&key3)); - let balance_to_move = 5; - - let tx = transfer_transaction( - account_id1, - &key1, - 0, - account_id2, - &key2, - 0, - balance_to_move, - ); - state.transition_from_public_transaction(&tx, 1, 0).unwrap(); - let balance_to_move = 3; - let tx = transfer_transaction( - account_id2, - &key2, - 1, - account_id3, - &key3, - 0, - balance_to_move, - ); - state.transition_from_public_transaction(&tx, 1, 0).unwrap(); - - assert_eq!(state.get_account_by_id(account_id1).balance, 95); - assert_eq!(state.get_account_by_id(account_id2).balance, 2); - assert_eq!(state.get_account_by_id(account_id3).balance, 3); - assert_eq!(state.get_account_by_id(account_id1).nonce, Nonce(1)); - assert_eq!(state.get_account_by_id(account_id2).nonce, Nonce(2)); - assert_eq!(state.get_account_by_id(account_id3).nonce, Nonce(1)); - } - - #[test] - fn program_should_fail_if_modifies_nonces() { - let account_id = AccountId::new([1; 32]); - let mut state = V03State::new() - .with_public_account_balances([(account_id, 100)]) - .with_test_programs(); - let account_ids = vec![account_id]; - let program_id = crate::test_methods::nonce_changer().id(); - let message = - public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 1, 0); - - assert!(matches!( - result, - Err(LeeError::InvalidProgramBehavior( - InvalidProgramBehaviorError::ExecutionValidationFailed( - ExecutionValidationError::ModifiedNonce { account_id: err_account_id } - ) - )) if err_account_id == account_id - )); - } - - #[test] - fn program_should_fail_if_output_accounts_exceed_inputs() { - let mut state = V03State::new() - .with_public_account_balances([(AccountId::new([1; 32]), 0)]) - .with_test_programs(); - let account_ids = vec![AccountId::new([1; 32])]; - let program_id = crate::test_methods::extra_output().id(); - let message = - public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 1, 0); - - assert!(matches!( - result, - Err(LeeError::InvalidProgramBehavior( - InvalidProgramBehaviorError::ExecutionValidationFailed( - ExecutionValidationError::MismatchedPreStatePostStateLength { - pre_state_length, - post_state_length - } - ) - )) if pre_state_length == 1 && post_state_length == 2 - )); - } - - #[test] - fn program_should_fail_with_missing_output_accounts() { - let mut state = V03State::new() - .with_public_account_balances([(AccountId::new([1; 32]), 100)]) - .with_test_programs(); - let account_ids = vec![AccountId::new([1; 32]), AccountId::new([2; 32])]; - let program_id = crate::test_methods::missing_output().id(); - let message = - public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 1, 0); - - assert!(matches!( - result, - Err(LeeError::InvalidProgramBehavior( - InvalidProgramBehaviorError::ExecutionValidationFailed( - ExecutionValidationError::MismatchedPreStatePostStateLength { - pre_state_length, - post_state_length - } - ) - )) if pre_state_length == 2 && post_state_length == 1 - )); - } - - #[test] - fn program_should_fail_if_modifies_program_owner_with_only_non_default_program_owner() { - let initial_data = [( - AccountId::new([1; 32]), - Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - ..Account::default() - }, - )]; - let mut state = V03State::new() - .with_public_accounts(initial_data) - .with_test_programs(); - let account_id = AccountId::new([1; 32]); - let account = state.get_account_by_id(account_id); - // Assert the target account only differs from the default account in the program owner - // field - assert_ne!(account.program_owner, Account::default().program_owner); - assert_eq!(account.balance, Account::default().balance); - assert_eq!(account.nonce, Account::default().nonce); - assert_eq!(account.data, Account::default().data); - let program_id = crate::test_methods::program_owner_changer().id(); - let message = - public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 1, 0); - - assert!(matches!( - result, - Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( - ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id } - ))) if err_account_id == account_id - )); - } - - #[test] - fn program_should_fail_if_modifies_program_owner_with_only_non_default_balance() { - let initial_data = HashMap::new(); - let mut state = V03State::new() - .with_public_accounts(initial_data) - .with_test_programs() - .with_non_default_accounts_but_default_program_owners(); - let account_id = AccountId::new([255; 32]); - let account = state.get_account_by_id(account_id); - // Assert the target account only differs from the default account in balance field - assert_eq!(account.program_owner, Account::default().program_owner); - assert_ne!(account.balance, Account::default().balance); - assert_eq!(account.nonce, Account::default().nonce); - assert_eq!(account.data, Account::default().data); - let program_id = crate::test_methods::program_owner_changer().id(); - let message = - public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 1, 0); - - assert!(matches!( - result, - Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( - ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id } - ))) if err_account_id == account_id - )); - } - - #[test] - fn program_should_fail_if_modifies_program_owner_with_only_non_default_nonce() { - let initial_data = HashMap::new(); - let mut state = V03State::new() - .with_public_accounts(initial_data) - .with_test_programs() - .with_non_default_accounts_but_default_program_owners(); - let account_id = AccountId::new([254; 32]); - let account = state.get_account_by_id(account_id); - // Assert the target account only differs from the default account in nonce field - assert_eq!(account.program_owner, Account::default().program_owner); - assert_eq!(account.balance, Account::default().balance); - assert_ne!(account.nonce, Account::default().nonce); - assert_eq!(account.data, Account::default().data); - let program_id = crate::test_methods::program_owner_changer().id(); - let message = - public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 1, 0); - - assert!(matches!( - result, - Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( - ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id } - ))) if err_account_id == account_id - )); - } - - #[test] - fn program_should_fail_if_modifies_program_owner_with_only_non_default_data() { - let initial_data = HashMap::new(); - let mut state = V03State::new() - .with_public_accounts(initial_data) - .with_test_programs() - .with_non_default_accounts_but_default_program_owners(); - let account_id = AccountId::new([253; 32]); - let account = state.get_account_by_id(account_id); - // Assert the target account only differs from the default account in data field - assert_eq!(account.program_owner, Account::default().program_owner); - assert_eq!(account.balance, Account::default().balance); - assert_eq!(account.nonce, Account::default().nonce); - assert_ne!(account.data, Account::default().data); - let program_id = crate::test_methods::program_owner_changer().id(); - let message = - public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 1, 0); - - assert!(matches!( - result, - Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( - ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id } - ))) if err_account_id == account_id - )); - } - - #[test] - fn program_should_fail_if_transfers_balance_from_non_owned_account() { - let sender_account_id = AccountId::new([1; 32]); - let receiver_account_id = AccountId::new([2; 32]); - let mut state = V03State::new() - .with_public_account_balances([(sender_account_id, 100)]) - .with_test_programs(); - let balance_to_move: u128 = 1; - let program_id = crate::test_methods::simple_balance_transfer().id(); - assert_ne!( - state.get_account_by_id(sender_account_id).program_owner, - program_id - ); - let message = public_transaction::Message::try_new( - program_id, - vec![sender_account_id, receiver_account_id], - vec![], - balance_to_move, - ) - .unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 1, 0); - - assert!(matches!( - result, - Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( - ExecutionValidationError::UnauthorizedBalanceDecrease { account_id: err_account_id, owner_program_id, executing_program_id } - ))) if err_account_id == sender_account_id && owner_program_id != program_id && executing_program_id == program_id - )); - } - - #[test] - fn program_should_fail_if_modifies_data_of_non_owned_account() { - let initial_data = HashMap::new(); - let mut state = V03State::new() - .with_public_accounts(initial_data) - .with_test_programs() - .with_non_default_accounts_but_default_program_owners(); - let account_id = AccountId::new([255; 32]); - let program_id = crate::test_methods::data_changer().id(); - - assert_ne!(state.get_account_by_id(account_id), Account::default()); - assert_ne!( - state.get_account_by_id(account_id).program_owner, - program_id - ); - let message = - public_transaction::Message::try_new(program_id, vec![account_id], vec![], vec![0]) - .unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 1, 0); - - assert!(matches!( - result, - Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( - ExecutionValidationError::UnauthorizedDataModification { account_id: err_account_id, executing_program_id } - ))) if err_account_id == account_id && executing_program_id == program_id - )); - } - - #[test] - fn program_should_fail_if_does_not_preserve_total_balance_by_minting() { - let initial_data = HashMap::new(); - let mut state = V03State::new() - .with_public_accounts(initial_data) - .with_test_programs(); - let account_id = AccountId::new([1; 32]); - let program_id = crate::test_methods::minter().id(); - - let message = - public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 2, 0); - - assert!(matches!( - result, - Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( - ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states } - ))) if total_balance_pre_states == 0.into() && total_balance_post_states == 1.into() - )); - } - - #[test] - fn program_should_fail_if_does_not_preserve_total_balance_by_burning() { - let initial_data = HashMap::new(); - let mut state = V03State::new() - .with_public_accounts(initial_data) - .with_test_programs() - .with_account_owned_by_burner_program(); - let program_id = crate::test_methods::burner().id(); - let account_id = AccountId::new([252; 32]); - assert_eq!( - state.get_account_by_id(account_id).program_owner, - program_id - ); - let balance_to_burn: u128 = 1; - assert!(state.get_account_by_id(account_id).balance > balance_to_burn); - - let message = public_transaction::Message::try_new( - program_id, - vec![account_id], - vec![], - balance_to_burn, - ) - .unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - let result = state.transition_from_public_transaction(&tx, 2, 0); - - assert!(matches!( - result, - Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( - ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states } - ))) if total_balance_pre_states == 100.into() && total_balance_post_states == 99.into() - )); - } - - fn test_public_account_keys_1() -> TestPublicKeys { - TestPublicKeys { - signing_key: PrivateKey::try_new([37; 32]).unwrap(), - } - } - - fn test_public_account_keys_2() -> TestPublicKeys { - TestPublicKeys { - signing_key: PrivateKey::try_new([38; 32]).unwrap(), - } - } - - pub fn test_private_account_keys_1() -> TestPrivateKeys { - TestPrivateKeys { - nsk: [13; 32], - d: [31; 32], - z: [32; 32], - } - } - - pub fn test_private_account_keys_2() -> TestPrivateKeys { - TestPrivateKeys { - nsk: [38; 32], - d: [83; 32], - z: [84; 32], - } - } - - fn shielded_balance_transfer_for_tests( - sender_keys: &TestPublicKeys, - recipient_keys: &TestPrivateKeys, - balance_to_move: u128, - state: &V03State, - ) -> PrivacyPreservingTransaction { - let sender = AccountWithMetadata::new( - state.get_account_by_id(sender_keys.account_id()), - true, - sender_keys.account_id(), - ); - - let sender_nonce = sender.account.nonce; - - let recipient = - AccountWithMetadata::new(Account::default(), false, (&recipient_keys.npk(), 0)); - - let (shared_secret, epk) = - SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &[0_u8; 32], 0); - - let (output, proof) = circuit::execute_and_prove( - vec![sender, recipient], - Program::serialize_instruction(balance_to_move).unwrap(), - vec![ - InputAccountIdentity::Public, - InputAccountIdentity::PrivateUnauthorized { - epk, - view_tag: EncryptedAccountData::compute_view_tag( - &recipient_keys.npk(), - &recipient_keys.vpk(), - ), - npk: recipient_keys.npk(), - ssk: shared_secret, - identifier: 0, - }, - ], - &crate::test_methods::simple_balance_transfer().into(), - ) - .unwrap(); - - let message = Message::try_from_circuit_output( - vec![sender_keys.account_id()], - vec![sender_nonce], - output, - ) - .unwrap(); - - let witness_set = WitnessSet::for_message(&message, proof, &[&sender_keys.signing_key]); - PrivacyPreservingTransaction::new(message, witness_set) - } - - fn private_balance_transfer_for_tests( - sender_keys: &TestPrivateKeys, - sender_private_account: &Account, - recipient_keys: &TestPrivateKeys, - balance_to_move: u128, - state: &V03State, - ) -> PrivacyPreservingTransaction { - let program = crate::test_methods::simple_balance_transfer(); - let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0); - let sender_commitment = Commitment::new(&sender_account_id, sender_private_account); - let sender_pre = AccountWithMetadata::new( - sender_private_account.clone(), - true, - (&sender_keys.npk(), 0), - ); - let recipient_pre = - AccountWithMetadata::new(Account::default(), false, (&recipient_keys.npk(), 0)); - - let (shared_secret_1, epk_1) = - SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0); - - let (shared_secret_2, epk_2) = - SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &[0_u8; 32], 1); - - let (output, proof) = circuit::execute_and_prove( - vec![sender_pre, recipient_pre], - Program::serialize_instruction(balance_to_move).unwrap(), - vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: epk_1, - view_tag: EncryptedAccountData::compute_view_tag( - &sender_keys.npk(), - &sender_keys.vpk(), - ), - ssk: shared_secret_1, - nsk: sender_keys.nsk, - membership_proof: state - .get_proof_for_commitment(&sender_commitment) - .expect("sender's commitment must be in state"), - identifier: 0, - }, - InputAccountIdentity::PrivateUnauthorized { - epk: epk_2, - view_tag: EncryptedAccountData::compute_view_tag( - &recipient_keys.npk(), - &recipient_keys.vpk(), - ), - npk: recipient_keys.npk(), - ssk: shared_secret_2, - identifier: 0, - }, - ], - &program.into(), - ) - .unwrap(); - - let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); - - let witness_set = WitnessSet::for_message(&message, proof, &[]); - - PrivacyPreservingTransaction::new(message, witness_set) - } - - fn deshielded_balance_transfer_for_tests( - sender_keys: &TestPrivateKeys, - sender_private_account: &Account, - recipient_account_id: &AccountId, - balance_to_move: u128, - state: &V03State, - ) -> PrivacyPreservingTransaction { - let program = crate::test_methods::simple_balance_transfer(); - let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0); - let sender_commitment = Commitment::new(&sender_account_id, sender_private_account); - let sender_pre = AccountWithMetadata::new( - sender_private_account.clone(), - true, - (&sender_keys.npk(), 0), - ); - let recipient_pre = AccountWithMetadata::new( - state.get_account_by_id(*recipient_account_id), - false, - *recipient_account_id, - ); - - let (shared_secret, epk) = - SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0); - - let (output, proof) = circuit::execute_and_prove( - vec![sender_pre, recipient_pre], - Program::serialize_instruction(balance_to_move).unwrap(), - vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk, - view_tag: EncryptedAccountData::compute_view_tag( - &sender_keys.npk(), - &sender_keys.vpk(), - ), - ssk: shared_secret, - nsk: sender_keys.nsk, - membership_proof: state - .get_proof_for_commitment(&sender_commitment) - .expect("sender's commitment must be in state"), - identifier: 0, - }, - InputAccountIdentity::Public, - ], - &program.into(), - ) - .unwrap(); - - let message = - Message::try_from_circuit_output(vec![*recipient_account_id], vec![], output).unwrap(); - - let witness_set = WitnessSet::for_message(&message, proof, &[]); - - PrivacyPreservingTransaction::new(message, witness_set) - } - - #[test] - fn transition_from_privacy_preserving_transaction_shielded() { - let sender_keys = test_public_account_keys_1(); - let recipient_keys = test_private_account_keys_1(); - - let mut state = V03State::new().with_public_accounts([( - sender_keys.account_id(), - Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance: 200, - ..Account::default() - }, - )]); - - let balance_to_move = 37; - - let tx = shielded_balance_transfer_for_tests( - &sender_keys, - &recipient_keys, - balance_to_move, - &state, - ); - - let expected_sender_post = { - let mut this = state.get_account_by_id(sender_keys.account_id()); - this.balance -= balance_to_move; - this.nonce.public_account_nonce_increment(); - this - }; - - let [expected_new_commitment] = tx.message().new_commitments.clone().try_into().unwrap(); - assert!(!state.private_state.0.contains(&expected_new_commitment)); - - state - .transition_from_privacy_preserving_transaction(&tx, 1, 0) - .unwrap(); - - let sender_post = state.get_account_by_id(sender_keys.account_id()); - assert_eq!(sender_post, expected_sender_post); - assert!(state.private_state.0.contains(&expected_new_commitment)); - - assert_eq!( - state.get_account_by_id(sender_keys.account_id()).balance, - 200 - balance_to_move - ); - } - - #[test] - fn transition_from_privacy_preserving_transaction_private() { - let sender_keys = test_private_account_keys_1(); - let sender_nonce = Nonce(0xdead_beef); - - let sender_private_account = Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance: 100, - nonce: sender_nonce, - data: Data::default(), - }; - let recipient_keys = test_private_account_keys_2(); - - let mut state = V03State::new().with_private_account(&sender_keys, &sender_private_account); - - let balance_to_move = 37; - - let tx = private_balance_transfer_for_tests( - &sender_keys, - &sender_private_account, - &recipient_keys, - balance_to_move, - &state, - ); - - let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0); - let recipient_account_id = AccountId::for_regular_private_account(&recipient_keys.npk(), 0); - let expected_new_commitment_1 = Commitment::new( - &sender_account_id, - &Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk), - balance: sender_private_account.balance - balance_to_move, - data: Data::default(), - }, - ); - - let sender_pre_commitment = Commitment::new(&sender_account_id, &sender_private_account); - let expected_new_nullifier = - Nullifier::for_account_update(&sender_pre_commitment, &sender_keys.nsk); - - let expected_new_commitment_2 = Commitment::new( - &recipient_account_id, - &Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - nonce: Nonce::private_account_nonce_init(&recipient_account_id), - balance: balance_to_move, - ..Account::default() - }, - ); - - let previous_public_state = state.public_state.clone(); - assert!(state.private_state.0.contains(&sender_pre_commitment)); - assert!(!state.private_state.0.contains(&expected_new_commitment_1)); - assert!(!state.private_state.0.contains(&expected_new_commitment_2)); - assert!(!state.private_state.1.contains(&expected_new_nullifier)); - - state - .transition_from_privacy_preserving_transaction(&tx, 1, 0) - .unwrap(); - - assert_eq!(state.public_state, previous_public_state); - assert!(state.private_state.0.contains(&sender_pre_commitment)); - assert!(state.private_state.0.contains(&expected_new_commitment_1)); - assert!(state.private_state.0.contains(&expected_new_commitment_2)); - assert!(state.private_state.1.contains(&expected_new_nullifier)); - } - - fn valid_private_transfer_tx_and_state() -> (V03State, PrivacyPreservingTransaction) { - let sender_keys = test_private_account_keys_1(); - let sender_private_account = Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance: 100, - nonce: Nonce(0xdead_beef), - ..Account::default() - }; - let recipient_keys = test_private_account_keys_2(); - let state = V03State::new().with_private_account(&sender_keys, &sender_private_account); - let tx = private_balance_transfer_for_tests( - &sender_keys, - &sender_private_account, - &recipient_keys, - 37, - &state, - ); - (state, tx) - } - - /// After a valid fully-private tx is proven, tampering with a note's epk should - /// make the shielding proof invalid. - #[test] - fn privacy_tampered_epk_is_rejected() { - use crate::validated_state_diff::ValidatedStateDiff; - - let (state, mut tx) = valid_private_transfer_tx_and_state(); - - // Baseline: the untampered tx verifies - assert!( - ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0).is_ok(), - "the unmodified private transfer must verify" - ); - - // Flip a byte of the first note's epk - tx.message.encrypted_private_post_states[0].epk.0[0] ^= 0xFF; - - assert!( - matches!( - ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0), - Err(LeeError::InvalidPrivacyPreservingProof) - ), - "a tampered epk must be rejected by proof verification" - ); - } - - /// After a valid fully-private tx is proven, tampering with a note's view tag should - /// make the shielding proof invalid. - #[test] - fn privacy_tampered_view_tag_is_rejected() { - use crate::validated_state_diff::ValidatedStateDiff; - - let (state, mut tx) = valid_private_transfer_tx_and_state(); - - // Baseline: the untampered tx verifies. - assert!( - ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0).is_ok(), - "the unmodified private transfer must verify" - ); - - // Flip the first note's view_tag - tx.message.encrypted_private_post_states[0].view_tag ^= 0xFF; - - assert!( - matches!( - ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0), - Err(LeeError::InvalidPrivacyPreservingProof) - ), - "a tampered view_tag must be rejected by proof verification" - ); - } - - #[test] - fn transition_from_privacy_preserving_transaction_deshielded() { - let sender_keys = test_private_account_keys_1(); - let sender_nonce = Nonce(0xdead_beef); - - let sender_private_account = Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance: 100, - nonce: sender_nonce, - data: Data::default(), - }; - let recipient_keys = test_public_account_keys_1(); - let recipient_initial_balance = 400; - let mut state = V03State::new() - .with_public_accounts([( - recipient_keys.account_id(), - Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance: recipient_initial_balance, - ..Account::default() - }, - )]) - .with_private_account(&sender_keys, &sender_private_account); - - let balance_to_move = 37; - - let expected_recipient_post = { - let mut this = state.get_account_by_id(recipient_keys.account_id()); - this.balance += balance_to_move; - this - }; - - let tx = deshielded_balance_transfer_for_tests( - &sender_keys, - &sender_private_account, - &recipient_keys.account_id(), - balance_to_move, - &state, - ); - - let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0); - let expected_new_commitment = Commitment::new( - &sender_account_id, - &Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk), - balance: sender_private_account.balance - balance_to_move, - data: Data::default(), - }, - ); - - let sender_pre_commitment = Commitment::new(&sender_account_id, &sender_private_account); - let expected_new_nullifier = - Nullifier::for_account_update(&sender_pre_commitment, &sender_keys.nsk); - - assert!(state.private_state.0.contains(&sender_pre_commitment)); - assert!(!state.private_state.0.contains(&expected_new_commitment)); - assert!(!state.private_state.1.contains(&expected_new_nullifier)); - - state - .transition_from_privacy_preserving_transaction(&tx, 1, 0) - .unwrap(); - - let recipient_post = state.get_account_by_id(recipient_keys.account_id()); - assert_eq!(recipient_post, expected_recipient_post); - assert!(state.private_state.0.contains(&sender_pre_commitment)); - assert!(state.private_state.0.contains(&expected_new_commitment)); - assert!(state.private_state.1.contains(&expected_new_nullifier)); - assert_eq!( - state.get_account_by_id(recipient_keys.account_id()).balance, - recipient_initial_balance + balance_to_move - ); - } - - #[test] - fn burner_program_should_fail_in_privacy_preserving_circuit() { - let program = crate::test_methods::burner(); - let public_account = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 100, - ..Account::default() - }, - true, - AccountId::new([0; 32]), - ); - - let result = execute_and_prove( - vec![public_account], - Program::serialize_instruction(10_u128).unwrap(), - vec![InputAccountIdentity::Public], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn minter_program_should_fail_in_privacy_preserving_circuit() { - let program = crate::test_methods::minter(); - let public_account = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 0, - ..Account::default() - }, - true, - AccountId::new([0; 32]), - ); - - let result = execute_and_prove( - vec![public_account], - Program::serialize_instruction(10_u128).unwrap(), - vec![InputAccountIdentity::Public], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn nonce_changer_program_should_fail_in_privacy_preserving_circuit() { - let program = crate::test_methods::nonce_changer(); - let public_account = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 0, - ..Account::default() - }, - true, - AccountId::new([0; 32]), - ); - - let result = execute_and_prove( - vec![public_account], - Program::serialize_instruction(()).unwrap(), - vec![InputAccountIdentity::Public], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn data_changer_program_should_fail_for_non_owned_account_in_privacy_preserving_circuit() { - let program = crate::test_methods::data_changer(); - let public_account = AccountWithMetadata::new( - Account { - program_owner: [0, 1, 2, 3, 4, 5, 6, 7], - balance: 0, - ..Account::default() - }, - true, - AccountId::new([0; 32]), - ); - - let result = execute_and_prove( - vec![public_account], - Program::serialize_instruction(vec![0]).unwrap(), - vec![InputAccountIdentity::Public], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn data_changer_program_should_fail_for_too_large_data_in_privacy_preserving_circuit() { - let program = crate::test_methods::data_changer(); - let public_account = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 0, - ..Account::default() - }, - true, - AccountId::new([0; 32]), - ); - - let large_data: Vec = - vec![ - 0; - usize::try_from(lee_core::account::data::DATA_MAX_LENGTH.as_u64()) - .expect("DATA_MAX_LENGTH fits in usize") - + 1 - ]; - - let result = execute_and_prove( - vec![public_account], - Program::serialize_instruction(large_data).unwrap(), - vec![InputAccountIdentity::Public], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::ProgramProveFailed(_)))); - } - - #[test] - fn extra_output_program_should_fail_in_privacy_preserving_circuit() { - let program = crate::test_methods::extra_output(); - let public_account = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 0, - ..Account::default() - }, - true, - AccountId::new([0; 32]), - ); - - let result = execute_and_prove( - vec![public_account], - Program::serialize_instruction(()).unwrap(), - vec![InputAccountIdentity::Public], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn missing_output_program_should_fail_in_privacy_preserving_circuit() { - let program = crate::test_methods::missing_output(); - let public_account_1 = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 0, - ..Account::default() - }, - true, - AccountId::new([0; 32]), - ); - let public_account_2 = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 0, - ..Account::default() - }, - true, - AccountId::new([1; 32]), - ); - - let result = execute_and_prove( - vec![public_account_1, public_account_2], - Program::serialize_instruction(()).unwrap(), - vec![InputAccountIdentity::Public, InputAccountIdentity::Public], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn program_owner_changer_should_fail_in_privacy_preserving_circuit() { - let program = crate::test_methods::program_owner_changer(); - let public_account = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 0, - ..Account::default() - }, - true, - AccountId::new([0; 32]), - ); - - let result = execute_and_prove( - vec![public_account], - Program::serialize_instruction(()).unwrap(), - vec![InputAccountIdentity::Public], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn transfer_from_non_owned_account_should_fail_in_privacy_preserving_circuit() { - let program = crate::test_methods::simple_balance_transfer(); - let public_account_1 = AccountWithMetadata::new( - Account { - program_owner: [0, 1, 2, 3, 4, 5, 6, 7], - balance: 100, - ..Account::default() - }, - true, - AccountId::new([0; 32]), - ); - let public_account_2 = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 0, - ..Account::default() - }, - true, - AccountId::new([1; 32]), - ); - - let result = execute_and_prove( - vec![public_account_1, public_account_2], - Program::serialize_instruction(10_u128).unwrap(), - vec![InputAccountIdentity::Public, InputAccountIdentity::Public], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn circuit_fails_if_visibility_masks_have_incorrect_lenght() { - let program = crate::test_methods::simple_balance_transfer(); - let public_account_1 = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 100, - ..Account::default() - }, - true, - AccountId::new([0; 32]), - ); - let public_account_2 = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 0, - ..Account::default() - }, - true, - AccountId::new([1; 32]), - ); - - // Single account_identity entry for a circuit execution with two pre_state accounts. - let result = execute_and_prove( - vec![public_account_1, public_account_2], - Program::serialize_instruction(10_u128).unwrap(), - vec![InputAccountIdentity::Public], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn circuit_fails_if_invalid_auth_keys_are_provided() { - let program = crate::test_methods::simple_balance_transfer(); - let sender_keys = test_private_account_keys_1(); - let recipient_keys = test_private_account_keys_2(); - let private_account_1 = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 100, - ..Account::default() - }, - true, - (&sender_keys.npk(), 0), - ); - let private_account_2 = - AccountWithMetadata::new(Account::default(), false, (&recipient_keys.npk(), 0)); - - // Setting the recipient nsk to authorize the sender. - // This should be set to the sender private account in a normal circumstance. - // `PrivateAuthorizedUpdate` derives npk from nsk and asserts equality with - // `pre_state.account_id`, so a mismatched nsk fails that check. - let result = execute_and_prove( - vec![private_account_1, private_account_2], - Program::serialize_instruction(10_u128).unwrap(), - vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &sender_keys.npk(), - &sender_keys.vpk(), - ), - ssk: SharedSecretKey::encapsulate_deterministic( - &sender_keys.vpk(), - &[0_u8; 32], - 0, - ) - .0, - nsk: recipient_keys.nsk, - membership_proof: (0, vec![]), - identifier: 0, - }, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &recipient_keys.npk(), - &recipient_keys.vpk(), - ), - npk: recipient_keys.npk(), - ssk: SharedSecretKey::encapsulate_deterministic( - &recipient_keys.vpk(), - &[0_u8; 32], - 0, - ) - .0, - identifier: 0, - }, - ], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provided() { - let program = crate::test_methods::simple_balance_transfer(); - let sender_keys = test_private_account_keys_1(); - let recipient_keys = test_private_account_keys_2(); - let private_account_1 = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 100, - ..Account::default() - }, - true, - (&sender_keys.npk(), 0), - ); - let private_account_2 = AccountWithMetadata::new( - Account { - // Non default balance - balance: 1, - ..Account::default() - }, - false, - (&recipient_keys.npk(), 0), - ); - - let result = execute_and_prove( - vec![private_account_1, private_account_2], - Program::serialize_instruction(10_u128).unwrap(), - vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &sender_keys.npk(), - &sender_keys.vpk(), - ), - ssk: SharedSecretKey::encapsulate_deterministic( - &sender_keys.vpk(), - &[0_u8; 32], - 0, - ) - .0, - nsk: sender_keys.nsk, - membership_proof: (0, vec![]), - identifier: 0, - }, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &recipient_keys.npk(), - &recipient_keys.vpk(), - ), - npk: recipient_keys.npk(), - ssk: SharedSecretKey::encapsulate_deterministic( - &recipient_keys.vpk(), - &[0_u8; 32], - 0, - ) - .0, - identifier: 0, - }, - ], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_provided() { - let program = crate::test_methods::simple_balance_transfer(); - let sender_keys = test_private_account_keys_1(); - let recipient_keys = test_private_account_keys_2(); - let private_account_1 = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 100, - ..Account::default() - }, - true, - (&sender_keys.npk(), 0), - ); - let private_account_2 = AccountWithMetadata::new( - Account { - // Non default program_owner - program_owner: [0, 1, 2, 3, 4, 5, 6, 7], - ..Account::default() - }, - false, - (&recipient_keys.npk(), 0), - ); - - let result = execute_and_prove( - vec![private_account_1, private_account_2], - Program::serialize_instruction(10_u128).unwrap(), - vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &sender_keys.npk(), - &sender_keys.vpk(), - ), - ssk: SharedSecretKey::encapsulate_deterministic( - &sender_keys.vpk(), - &[0_u8; 32], - 0, - ) - .0, - nsk: sender_keys.nsk, - membership_proof: (0, vec![]), - identifier: 0, - }, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &recipient_keys.npk(), - &recipient_keys.vpk(), - ), - npk: recipient_keys.npk(), - ssk: SharedSecretKey::encapsulate_deterministic( - &recipient_keys.vpk(), - &[0_u8; 32], - 0, - ) - .0, - identifier: 0, - }, - ], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided() { - let program = crate::test_methods::simple_balance_transfer(); - let sender_keys = test_private_account_keys_1(); - let recipient_keys = test_private_account_keys_2(); - let private_account_1 = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 100, - ..Account::default() - }, - true, - (&sender_keys.npk(), 0), - ); - let private_account_2 = AccountWithMetadata::new( - Account { - // Non default data - data: b"hola mundo".to_vec().try_into().unwrap(), - ..Account::default() - }, - false, - (&recipient_keys.npk(), 0), - ); - - let result = execute_and_prove( - vec![private_account_1, private_account_2], - Program::serialize_instruction(10_u128).unwrap(), - vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &sender_keys.npk(), - &sender_keys.vpk(), - ), - ssk: SharedSecretKey::encapsulate_deterministic( - &sender_keys.vpk(), - &[0_u8; 32], - 0, - ) - .0, - nsk: sender_keys.nsk, - membership_proof: (0, vec![]), - identifier: 0, - }, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &recipient_keys.npk(), - &recipient_keys.vpk(), - ), - npk: recipient_keys.npk(), - ssk: SharedSecretKey::encapsulate_deterministic( - &recipient_keys.vpk(), - &[0_u8; 32], - 0, - ) - .0, - identifier: 0, - }, - ], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided() { - let program = crate::test_methods::simple_balance_transfer(); - let sender_keys = test_private_account_keys_1(); - let recipient_keys = test_private_account_keys_2(); - let private_account_1 = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 100, - ..Account::default() - }, - true, - (&sender_keys.npk(), 0), - ); - let private_account_2 = AccountWithMetadata::new( - Account { - // Non default nonce - nonce: Nonce(0xdead_beef), - ..Account::default() - }, - false, - (&recipient_keys.npk(), 0), - ); - - let result = execute_and_prove( - vec![private_account_1, private_account_2], - Program::serialize_instruction(10_u128).unwrap(), - vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &sender_keys.npk(), - &sender_keys.vpk(), - ), - ssk: SharedSecretKey::encapsulate_deterministic( - &sender_keys.vpk(), - &[0_u8; 32], - 0, - ) - .0, - nsk: sender_keys.nsk, - membership_proof: (0, vec![]), - identifier: 0, - }, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &recipient_keys.npk(), - &recipient_keys.vpk(), - ), - npk: recipient_keys.npk(), - ssk: SharedSecretKey::encapsulate_deterministic( - &recipient_keys.vpk(), - &[0_u8; 32], - 0, - ) - .0, - identifier: 0, - }, - ], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_but_marked_as_authorized() - { - let program = crate::test_methods::simple_balance_transfer(); - let sender_keys = test_private_account_keys_1(); - let recipient_keys = test_private_account_keys_2(); - let private_account_1 = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 100, - ..Account::default() - }, - true, - (&sender_keys.npk(), 0), - ); - let private_account_2 = AccountWithMetadata::new( - Account::default(), - // This should be set to false in normal circumstances - true, - (&recipient_keys.npk(), 0), - ); - - let result = execute_and_prove( - vec![private_account_1, private_account_2], - Program::serialize_instruction(10_u128).unwrap(), - vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &sender_keys.npk(), - &sender_keys.vpk(), - ), - ssk: SharedSecretKey::encapsulate_deterministic( - &sender_keys.vpk(), - &[0_u8; 32], - 0, - ) - .0, - nsk: sender_keys.nsk, - membership_proof: (0, vec![]), - identifier: 0, - }, - InputAccountIdentity::PrivateUnauthorized { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &recipient_keys.npk(), - &recipient_keys.vpk(), - ), - npk: recipient_keys.npk(), - ssk: SharedSecretKey::encapsulate_deterministic( - &recipient_keys.vpk(), - &[0_u8; 32], - 0, - ) - .0, - identifier: 0, - }, - ], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - /// A private PDA account that no program claims via `Claim::Pda` and no caller authorizes via - /// `ChainedCall.pda_seeds` has no binding between its supplied npk and its `account_id`, - /// so the circuit must reject. Here `simple_balance_transfer` emits no claim for the - /// second account, leaving position 1 unbound. - #[test] - fn private_pda_without_binding_fails() { - let program = crate::test_methods::simple_balance_transfer(); - let keys = test_private_account_keys_1(); - let npk = keys.npk(); - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0; - let public_account_1 = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 100, - ..Account::default() - }, - true, - AccountId::new([0; 32]), - ); - let private_pda_account = - AccountWithMetadata::new(Account::default(), false, AccountId::new([1; 32])); - - let result = execute_and_prove( - vec![public_account_1, private_pda_account], - Program::serialize_instruction(10_u128).unwrap(), - vec![ - InputAccountIdentity::Public, - InputAccountIdentity::PrivatePdaInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&npk, &keys.vpk()), - npk, - ssk: shared_secret, - identifier: u128::MAX, - seed: None, - }, - ], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - /// Happy path: a program claims a new private PDA via `Claim::Pda(seed)`. The circuit - /// reads the npk for that `pre_state` from `private_account_keys` at the `pre_state`'s - /// position, derives `AccountId` via `AccountId::for_private_pda(program_id, seed, npk)`, and - /// asserts it equals the `pre_state`'s `account_id`. The equality both validates the claim - /// and binds the supplied npk to the `account_id`. - #[test] - fn private_pda_claim_succeeds() { - let program = crate::test_methods::pda_claimer(); - let keys = test_private_account_keys_1(); - let npk = keys.npk(); - let seed = PdaSeed::new([42; 32]); - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0; - - let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, u128::MAX); - let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); - - let result = execute_and_prove( - vec![pre_state], - Program::serialize_instruction(seed).unwrap(), - vec![InputAccountIdentity::PrivatePdaInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&npk, &keys.vpk()), - npk, - ssk: shared_secret, - identifier: u128::MAX, - seed: None, - }], - &program.into(), - ); - - let (output, _proof) = result.expect("private PDA claim should succeed"); - assert_eq!(output.new_nullifiers.len(), 1); - assert_eq!(output.new_commitments.len(), 1); - assert_eq!(output.encrypted_private_post_states.len(), 1); - assert!(output.public_pre_states.is_empty()); - assert!(output.public_post_states.is_empty()); - } - - /// An npk is supplied that does not match the `pre_state`'s `account_id` under - /// `AccountId::for_private_pda(program, claim_seed, npk)`. The claim equality check rejects. - #[test] - fn private_pda_npk_mismatch_fails() { - // `keys_a` produces the `pre_state`'s `account_id` (the registered pair), `keys_b` is - // the mismatched pair supplied in `private_account_keys` for that pre_state. - let program = crate::test_methods::pda_claimer(); - let keys_a = test_private_account_keys_1(); - let keys_b = test_private_account_keys_2(); - let npk_a = keys_a.npk(); - let npk_b = keys_b.npk(); - let seed = PdaSeed::new([42; 32]); - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&keys_b.vpk(), &[0_u8; 32], 0).0; - - // `account_id` is derived from `npk_a`, but `npk_b` is supplied for this pre_state. - // `AccountId::for_private_pda(program, seed, npk_b) != account_id`, so the claim check in - // the circuit must reject. - let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk_a, u128::MAX); - let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); - - let result = execute_and_prove( - vec![pre_state], - Program::serialize_instruction(seed).unwrap(), - vec![InputAccountIdentity::PrivatePdaInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&npk_b, &keys_b.vpk()), - npk: npk_b, - ssk: shared_secret, - identifier: u128::MAX, - seed: None, - }], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - /// Happy path for the caller-seeds authorization of a private PDA. The delegator claims a - /// private PDA via `Claim::Pda(seed)`, then chains to a callee (`noop`) delegating the same - /// seed via `ChainedCall.pda_seeds`. In the callee's step, the `pre_state`'s authorization - /// is established via the private derivation - /// `AccountId::for_private_pda(delegator, seed, npk) == pre.account_id`. - #[test] - fn caller_pda_seeds_authorize_private_pda_for_callee() { - let delegator = crate::test_methods::private_pda_delegator(); - let callee = crate::test_methods::auth_asserting_noop(); - let keys = test_private_account_keys_1(); - let npk = keys.npk(); - let seed = PdaSeed::new([77; 32]); - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0; - - let account_id = AccountId::for_private_pda(&delegator.id(), &seed, &npk, u128::MAX); - let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); - - let callee_id = callee.id(); - let program_with_deps = - ProgramWithDependencies::new(delegator, [(callee_id, callee)].into()); - - let result = execute_and_prove( - vec![pre_state], - Program::serialize_instruction((seed, seed, callee_id)).unwrap(), - vec![InputAccountIdentity::PrivatePdaInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&npk, &keys.vpk()), - npk, - ssk: shared_secret, - identifier: u128::MAX, - seed: None, - }], - &program_with_deps, - ); - - let (output, _proof) = - result.expect("caller-seeds authorization of private PDA should succeed"); - assert_eq!(output.new_commitments.len(), 1); - assert_eq!(output.new_nullifiers.len(), 1); - } - - /// The delegator chains with a different seed than the one it claimed with. In the callee - /// step, neither public nor private caller-seeds authorization matches; `pre.is_authorized` - /// was set to `true` by the delegator but no proven source supports it, so the consistency - /// assertion rejects. - #[test] - fn caller_pda_seeds_with_wrong_seed_rejects_private_pda_for_callee() { - let delegator = crate::test_methods::private_pda_delegator(); - let callee = crate::test_methods::auth_asserting_noop(); - let keys = test_private_account_keys_1(); - let npk = keys.npk(); - let claim_seed = PdaSeed::new([77; 32]); - let wrong_delegated_seed = PdaSeed::new([88; 32]); - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0; - - let account_id = AccountId::for_private_pda(&delegator.id(), &claim_seed, &npk, u128::MAX); - let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); - - let callee_id = callee.id(); - let program_with_deps = - ProgramWithDependencies::new(delegator, [(callee_id, callee)].into()); - - let result = execute_and_prove( - vec![pre_state], - Program::serialize_instruction((claim_seed, wrong_delegated_seed, callee_id)).unwrap(), - vec![InputAccountIdentity::PrivatePdaInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&npk, &keys.vpk()), - npk, - ssk: shared_secret, - identifier: u128::MAX, - seed: None, - }], - &program_with_deps, - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - /// Exploit-scenario pin. A single `(program_id, seed)` pair can derive a family of - /// `AccountId`s, one public PDA and one private PDA per distinct npk. Without the tx-wide - /// family-binding check, a program could claim `PDA_alice` (`alice_npk`) and - /// `PDA_bob` (`bob_npk`) under the same seed in one transaction, and once reuse - /// is supported a later chained call could delegate both to a callee via - /// `pda_seeds: [S]` and mix balances across them. The binding check rejects the setup - /// here: after the first claim records `(program, seed) → PDA_alice`, the second claim - /// tries to record `(program, seed) → PDA_bob` and panics. - #[test] - fn two_private_pda_claims_under_same_seed_are_rejected() { - let program = crate::test_methods::two_pda_claimer(); - let keys_a = test_private_account_keys_1(); - let keys_b = test_private_account_keys_2(); - let seed = PdaSeed::new([55; 32]); - let shared_a = SharedSecretKey::encapsulate_deterministic(&keys_a.vpk(), &[0_u8; 32], 0).0; - let shared_b = SharedSecretKey::encapsulate_deterministic(&keys_b.vpk(), &[0_u8; 32], 0).0; - - let account_a = AccountId::for_private_pda(&program.id(), &seed, &keys_a.npk(), u128::MAX); - let account_b = AccountId::for_private_pda(&program.id(), &seed, &keys_b.npk(), u128::MAX); - - let pre_a = AccountWithMetadata::new(Account::default(), false, account_a); - let pre_b = AccountWithMetadata::new(Account::default(), false, account_b); - - let result = execute_and_prove( - vec![pre_a, pre_b], - Program::serialize_instruction(seed).unwrap(), - vec![ - InputAccountIdentity::PrivatePdaInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&keys_a.npk(), &keys_a.vpk()), - npk: keys_a.npk(), - ssk: shared_a, - identifier: u128::MAX, - seed: None, - }, - InputAccountIdentity::PrivatePdaInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&keys_b.npk(), &keys_b.vpk()), - npk: keys_b.npk(), - ssk: shared_b, - identifier: u128::MAX, - seed: None, - }, - ], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - /// A private PDA that is reused at top level without an external seed in the identity still - /// fails binding. The noop program emits no `Claim::Pda` and there is no caller - /// `ChainedCall.pda_seeds`, so position 0 is never bound and the assertion fires. - /// Supplying `seed: Some((seed, owner_program_id))` in the `PrivatePdaUpdate` identity is - /// the correct path for top-level reuse; this test pins the failure when no seed is provided. - #[test] - fn private_pda_top_level_reuse_rejected_by_binding_check() { - let program = crate::test_methods::noop(); - let keys = test_private_account_keys_1(); - let npk = keys.npk(); - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0; - let seed = PdaSeed::new([99; 32]); - - // Simulate a previously-claimed private PDA: program_owner != DEFAULT, is_authorized = - // true, account_id derived via the private formula. - let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, u128::MAX); - let owned_pre_state = AccountWithMetadata::new( - Account { - program_owner: program.id(), - ..Account::default() - }, - true, - account_id, - ); - - let result = execute_and_prove( - vec![owned_pre_state], - Program::serialize_instruction(()).unwrap(), - vec![InputAccountIdentity::PrivatePdaInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag(&npk, &keys.vpk()), - npk, - ssk: shared_secret, - identifier: u128::MAX, - seed: None, - }], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn private_accounts_can_only_be_initialized_once() { - let sender_keys = test_private_account_keys_1(); - let sender_nonce = Nonce(0xdead_beef); - - let sender_private_account = Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance: 100, - nonce: sender_nonce, - data: Data::default(), - }; - let recipient_keys = test_private_account_keys_2(); - - let mut state = V03State::new().with_private_account(&sender_keys, &sender_private_account); - - let balance_to_move = 37; - let balance_to_move_2 = 30; - - let tx = private_balance_transfer_for_tests( - &sender_keys, - &sender_private_account, - &recipient_keys, - balance_to_move, - &state, - ); - - state - .transition_from_privacy_preserving_transaction(&tx, 1, 0) - .unwrap(); - - let sender_private_account = Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance: 100, - nonce: sender_nonce, - data: Data::default(), - }; - - let tx = private_balance_transfer_for_tests( - &sender_keys, - &sender_private_account, - &recipient_keys, - balance_to_move_2, - &state, - ); - - let result = state.transition_from_privacy_preserving_transaction(&tx, 1, 0); - - assert!(matches!(result, Err(LeeError::InvalidInput(_)))); - let LeeError::InvalidInput(error_message) = result.err().unwrap() else { - panic!("Incorrect message error"); - }; - let expected_error_message = "Nullifier already seen".to_owned(); - assert_eq!(error_message, expected_error_message); - } - - #[test] - fn circuit_should_fail_if_there_are_repeated_ids() { - let program = crate::test_methods::simple_balance_transfer(); - let sender_keys = test_private_account_keys_1(); - let private_account_1 = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 100, - ..Account::default() - }, - true, - (&sender_keys.npk(), 0), - ); - - let shared_secret = - SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0).0; - let result = execute_and_prove( - vec![private_account_1.clone(), private_account_1], - Program::serialize_instruction(100_u128).unwrap(), - vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &sender_keys.npk(), - &sender_keys.vpk(), - ), - ssk: shared_secret, - nsk: sender_keys.nsk, - membership_proof: (1, vec![]), - identifier: 0, - }, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &sender_keys.npk(), - &sender_keys.vpk(), - ), - ssk: shared_secret, - nsk: sender_keys.nsk, - membership_proof: (1, vec![]), - identifier: 0, - }, - ], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn claiming_mechanism() { - let program = crate::test_methods::simple_balance_transfer(); - let from_key = PrivateKey::try_new([1; 32]).unwrap(); - let from = AccountId::from(&PublicKey::new_from_private_key(&from_key)); - let initial_balance = 100; - let initial_data = [(from, initial_balance)]; - let mut state = V03State::new() - .with_public_accounts(public_state_from_balances(&initial_data)) - .with_test_programs(); - let to_key = PrivateKey::try_new([2; 32]).unwrap(); - let to = AccountId::from(&PublicKey::new_from_private_key(&to_key)); - let amount: u128 = 37; - - // Check the recipient is an uninitialized account - assert_eq!(state.get_account_by_id(to), Account::default()); - - let expected_recipient_post = Account { - program_owner: program.id(), - balance: amount, - nonce: Nonce(1), - ..Account::default() - }; - - let message = public_transaction::Message::try_new( - program.id(), - vec![from, to], - vec![Nonce(0), Nonce(0)], - amount, - ) - .unwrap(); - let witness_set = - public_transaction::WitnessSet::for_message(&message, &[&from_key, &to_key]); - let tx = PublicTransaction::new(message, witness_set); - - state.transition_from_public_transaction(&tx, 1, 0).unwrap(); - - let recipient_post = state.get_account_by_id(to); - - assert_eq!(recipient_post, expected_recipient_post); - } - - #[test] - fn unauthorized_public_account_claiming_fails() { - let program = crate::test_methods::simple_balance_transfer(); - let account_key = PrivateKey::try_new([9; 32]).unwrap(); - let account_id = AccountId::from(&PublicKey::new_from_private_key(&account_key)); - let mut state = V03State::new().with_test_programs(); - - assert_eq!(state.get_account_by_id(account_id), Account::default()); - - let message = - public_transaction::Message::try_new(program.id(), vec![account_id], vec![], 0_u128) - .unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 2, 0); - - assert!(matches!(result, Err(LeeError::InvalidProgramBehavior(_)))); - assert_eq!(state.get_account_by_id(account_id), Account::default()); - } - - #[test] - fn authorized_public_account_claiming_succeeds() { - let program = crate::test_methods::simple_balance_transfer(); - let account_key = PrivateKey::try_new([10; 32]).unwrap(); - let account_id = AccountId::from(&PublicKey::new_from_private_key(&account_key)); - let mut state = V03State::new().with_test_programs(); - - assert_eq!(state.get_account_by_id(account_id), Account::default()); - - let message = public_transaction::Message::try_new( - program.id(), - vec![account_id], - vec![Nonce(0)], - 0_u128, - ) - .unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[&account_key]); - let tx = PublicTransaction::new(message, witness_set); - - state.transition_from_public_transaction(&tx, 1, 0).unwrap(); - - assert_eq!( - state.get_account_by_id(account_id), - Account { - program_owner: program.id(), - nonce: Nonce(1), - ..Account::default() - } - ); - } - - #[test] - fn public_chained_call() { - let program = crate::test_methods::chain_caller(); - let key = PrivateKey::try_new([1; 32]).unwrap(); - let from = AccountId::from(&PublicKey::new_from_private_key(&key)); - let to = AccountId::new([2; 32]); - let initial_balance = 1000; - let initial_data = [(from, initial_balance), (to, 0)]; - let mut state = V03State::new() - .with_public_accounts(public_state_from_balances(&initial_data)) - .with_test_programs(); - let from_key = key; - let amount: u128 = 37; - let instruction: (u128, ProgramId, u32, Option) = ( - amount, - crate::test_methods::simple_balance_transfer().id(), - 2, - None, - ); - - let expected_to_post = Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance: amount * 2, // The `chain_caller` chains the program twice - ..Account::default() - }; - - let message = public_transaction::Message::try_new( - program.id(), - vec![to, from], // The chain_caller program permutes the account order in the chain - // call - vec![Nonce(0)], - instruction, - ) - .unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[&from_key]); - let tx = PublicTransaction::new(message, witness_set); - - state.transition_from_public_transaction(&tx, 1, 0).unwrap(); - - let from_post = state.get_account_by_id(from); - let to_post = state.get_account_by_id(to); - // The `chain_caller` program calls the program twice - assert_eq!(from_post.balance, initial_balance - 2 * amount); - assert_eq!(to_post, expected_to_post); - } - - #[test] - fn execution_fails_if_chained_calls_exceeds_depth() { - let program = crate::test_methods::chain_caller(); - let key = PrivateKey::try_new([1; 32]).unwrap(); - let from = AccountId::from(&PublicKey::new_from_private_key(&key)); - let to = AccountId::new([2; 32]); - let initial_balance = 100; - let initial_data = [(from, initial_balance), (to, 0)]; - let mut state = V03State::new() - .with_public_accounts(public_state_from_balances(&initial_data)) - .with_test_programs(); - let from_key = key; - let amount: u128 = 0; - let instruction: (u128, ProgramId, u32, Option) = ( - amount, - crate::test_methods::simple_balance_transfer().id(), - u32::try_from(MAX_NUMBER_CHAINED_CALLS).expect("MAX_NUMBER_CHAINED_CALLS fits in u32") - + 1, - None, - ); - - let message = public_transaction::Message::try_new( - program.id(), - vec![to, from], // The chain_caller program permutes the account order in the chain - // call - vec![Nonce(0)], - instruction, - ) - .unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[&from_key]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!( - result, - Err(LeeError::MaxChainedCallsDepthExceeded) - )); - } - - #[test] - fn execution_that_requires_authentication_of_a_program_derived_account_id_succeeds() { - let chain_caller = crate::test_methods::chain_caller(); - let pda_seed = PdaSeed::new([37; 32]); - let from = AccountId::for_public_pda(&chain_caller.id(), &pda_seed); - let to = AccountId::new([2; 32]); - let initial_balance = 1000; - let initial_data = [(from, initial_balance), (to, 0)]; - let mut state = V03State::new() - .with_public_accounts(public_state_from_balances(&initial_data)) - .with_test_programs(); - let amount: u128 = 58; - let instruction: (u128, ProgramId, u32, Option) = ( - amount, - crate::test_methods::simple_balance_transfer().id(), - 1, - Some(pda_seed), - ); - - let expected_to_post = Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance: amount, // The `chain_caller` chains the program twice - ..Account::default() - }; - let message = public_transaction::Message::try_new( - chain_caller.id(), - vec![to, from], // The chain_caller program permutes the account order in the chain - // call - vec![], - instruction, - ) - .unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - state.transition_from_public_transaction(&tx, 1, 0).unwrap(); - - let from_post = state.get_account_by_id(from); - let to_post = state.get_account_by_id(to); - assert_eq!(from_post.balance, initial_balance - amount); - assert_eq!(to_post, expected_to_post); - } - - #[test] - fn claiming_mechanism_within_chain_call() { - // This test calls the authenticated transfer program through the chain_caller program. - // The transfer is made from an initialized sender to an uninitialized recipient. And - // it is expected that the recipient account is claimed by the authenticated transfer - // program and not the chained_caller program. - let chain_caller = crate::test_methods::chain_caller(); - let simple_transfer = crate::test_methods::simple_balance_transfer(); - let from_key = PrivateKey::try_new([1; 32]).unwrap(); - let from = AccountId::from(&PublicKey::new_from_private_key(&from_key)); - let initial_balance = 100; - let initial_data = [(from, initial_balance)]; - let mut state = V03State::new() - .with_public_accounts(public_state_from_balances(&initial_data)) - .with_test_programs(); - let to_key = PrivateKey::try_new([2; 32]).unwrap(); - let to = AccountId::from(&PublicKey::new_from_private_key(&to_key)); - let amount: u128 = 37; - - // Check the recipient is an uninitialized account - assert_eq!(state.get_account_by_id(to), Account::default()); - - let expected_to_post = Account { - // The expected program owner is the authenticated transfer program - program_owner: simple_transfer.id(), - balance: amount, - nonce: Nonce(1), - ..Account::default() - }; - - // The transaction executes the chain_caller program, which internally calls the - // authenticated_transfer program - let instruction: (u128, ProgramId, u32, Option) = ( - amount, - crate::test_methods::simple_balance_transfer().id(), - 1, - None, - ); - let message = public_transaction::Message::try_new( - chain_caller.id(), - vec![to, from], // The chain_caller program permutes the account order in the chain - // call - vec![Nonce(0), Nonce(0)], - instruction, - ) - .unwrap(); - let witness_set = - public_transaction::WitnessSet::for_message(&message, &[&from_key, &to_key]); - let tx = PublicTransaction::new(message, witness_set); - - state.transition_from_public_transaction(&tx, 1, 0).unwrap(); - - let from_post = state.get_account_by_id(from); - let to_post = state.get_account_by_id(to); - assert_eq!(from_post.balance, initial_balance - amount); - assert_eq!(to_post, expected_to_post); - } - - #[test] - fn unauthorized_public_account_claiming_fails_when_executed_privately() { - let program = crate::test_methods::simple_balance_transfer(); - let account_id = AccountId::new([11; 32]); - let public_account = AccountWithMetadata::new(Account::default(), false, account_id); - - let result = execute_and_prove( - vec![public_account], - Program::serialize_instruction(0_u128).unwrap(), - vec![InputAccountIdentity::Public], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn authorized_public_account_claiming_succeeds_when_executed_privately() { - let program = crate::test_methods::simple_balance_transfer(); - let program_id = program.id(); - let sender_keys = test_private_account_keys_1(); - let sender_private_account = Account { - program_owner: program_id, - balance: 100, - ..Account::default() - }; - let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0); - let sender_commitment = Commitment::new(&sender_account_id, &sender_private_account); - let sender_init_nullifier = Nullifier::for_account_initialization(&sender_account_id); - let mut state = V03State::new() - .with_private_accounts([(sender_commitment.clone(), sender_init_nullifier)]); - let sender_pre = - AccountWithMetadata::new(sender_private_account, true, (&sender_keys.npk(), 0)); - let recipient_private_key = PrivateKey::try_new([2; 32]).unwrap(); - let recipient_account_id = - AccountId::from(&PublicKey::new_from_private_key(&recipient_private_key)); - let recipient_pre = - AccountWithMetadata::new(Account::default(), true, recipient_account_id); - let (shared_secret, epk) = - SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0); - - let balance = 37; - - let (output, proof) = execute_and_prove( - vec![sender_pre, recipient_pre], - Program::serialize_instruction(balance).unwrap(), - vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk, - view_tag: EncryptedAccountData::compute_view_tag( - &sender_keys.npk(), - &sender_keys.vpk(), - ), - ssk: shared_secret, - nsk: sender_keys.nsk, - membership_proof: state - .get_proof_for_commitment(&sender_commitment) - .expect("sender's commitment must be in state"), - identifier: 0, - }, - InputAccountIdentity::Public, - ], - &program.into(), - ) - .unwrap(); - - let message = - Message::try_from_circuit_output(vec![recipient_account_id], vec![Nonce(0)], output) - .unwrap(); - - let witness_set = WitnessSet::for_message(&message, proof, &[&recipient_private_key]); - let tx = PrivacyPreservingTransaction::new(message, witness_set); - - state - .transition_from_privacy_preserving_transaction(&tx, 1, 0) - .unwrap(); - - let nullifier = Nullifier::for_account_update(&sender_commitment, &sender_keys.nsk); - assert!(state.private_state.1.contains(&nullifier)); - - assert_eq!( - state.get_account_by_id(recipient_account_id), - Account { - program_owner: program_id, - balance, - nonce: Nonce(1), - ..Account::default() - } - ); - } - - #[test_case::test_case(1; "single call")] - #[test_case::test_case(2; "two calls")] - fn private_chained_call(number_of_calls: u32) { - // Arrange - let chain_caller = crate::test_methods::chain_caller(); - let simple_transfers = crate::test_methods::simple_balance_transfer(); - let from_keys = test_private_account_keys_1(); - let to_keys = test_private_account_keys_2(); - let initial_balance = 100; - let from_account = AccountWithMetadata::new( - Account { - program_owner: simple_transfers.id(), - balance: initial_balance, - ..Account::default() - }, - true, - (&from_keys.npk(), 0), - ); - let to_account = AccountWithMetadata::new( - Account { - program_owner: simple_transfers.id(), - ..Account::default() - }, - true, - (&to_keys.npk(), 0), - ); - - let from_account_id = AccountId::for_regular_private_account(&from_keys.npk(), 0); - let to_account_id = AccountId::for_regular_private_account(&to_keys.npk(), 0); - let from_commitment = Commitment::new(&from_account_id, &from_account.account); - let to_commitment = Commitment::new(&to_account_id, &to_account.account); - let from_init_nullifier = Nullifier::for_account_initialization(&from_account_id); - let to_init_nullifier = Nullifier::for_account_initialization(&to_account_id); - let mut state = V03State::new() - .with_private_accounts([ - (from_commitment.clone(), from_init_nullifier), - (to_commitment.clone(), to_init_nullifier), - ]) - .with_test_programs(); - let amount: u128 = 37; - let instruction: (u128, ProgramId, u32, Option) = ( - amount, - crate::test_methods::simple_balance_transfer().id(), - number_of_calls, - None, - ); - - let (from_ss, from_epk) = - SharedSecretKey::encapsulate_deterministic(&from_keys.vpk(), &[0_u8; 32], 0); - - let (to_ss, to_epk) = - SharedSecretKey::encapsulate_deterministic(&to_keys.vpk(), &[0_u8; 32], 1); - - let mut dependencies = HashMap::new(); - - dependencies.insert(simple_transfers.id(), simple_transfers); - let program_with_deps = ProgramWithDependencies::new(chain_caller, dependencies); - - let from_new_nonce = Nonce::default().private_account_nonce_increment(&from_keys.nsk); - let to_new_nonce = Nonce::default().private_account_nonce_increment(&to_keys.nsk); - - let from_expected_post = Account { - balance: initial_balance - u128::from(number_of_calls) * amount, - nonce: from_new_nonce, - ..from_account.account.clone() - }; - let from_expected_commitment = Commitment::new(&from_account_id, &from_expected_post); - - let to_expected_post = Account { - balance: u128::from(number_of_calls) * amount, - nonce: to_new_nonce, - ..to_account.account.clone() - }; - let to_expected_commitment = Commitment::new(&to_account_id, &to_expected_post); - - // Act - let (output, proof) = execute_and_prove( - vec![to_account, from_account], - Program::serialize_instruction(instruction).unwrap(), - vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: to_epk, - view_tag: EncryptedAccountData::compute_view_tag( - &to_keys.npk(), - &to_keys.vpk(), - ), - ssk: to_ss, - nsk: from_keys.nsk, - membership_proof: state - .get_proof_for_commitment(&from_commitment) - .expect("from's commitment must be in state"), - identifier: 0, - }, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: from_epk, - view_tag: EncryptedAccountData::compute_view_tag( - &from_keys.npk(), - &from_keys.vpk(), - ), - ssk: from_ss, - nsk: to_keys.nsk, - membership_proof: state - .get_proof_for_commitment(&to_commitment) - .expect("to's commitment must be in state"), - identifier: 0, - }, - ], - &program_with_deps, - ) - .unwrap(); - - let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); - let witness_set = WitnessSet::for_message(&message, proof, &[]); - let transaction = PrivacyPreservingTransaction::new(message, witness_set); - - state - .transition_from_privacy_preserving_transaction(&transaction, 1, 0) - .unwrap(); - - // Assert - assert!( - state - .get_proof_for_commitment(&from_expected_commitment) - .is_some() - ); - assert!( - state - .get_proof_for_commitment(&to_expected_commitment) - .is_some() - ); - } - - #[test] - fn claiming_mechanism_cannot_claim_initialied_accounts() { - let claimer = crate::test_methods::claimer(); - let mut state = V03State::new().with_test_programs(); - let account_id = AccountId::new([2; 32]); - - // Insert an account with non-default program owner - state.force_insert_account( - account_id, - Account { - program_owner: [1, 2, 3, 4, 5, 6, 7, 8], - ..Account::default() - }, - ); - - let message = - public_transaction::Message::try_new(claimer.id(), vec![account_id], vec![], ()) - .unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 1, 0); - - assert!(matches!( - result, - Err(LeeError::InvalidProgramBehavior( - InvalidProgramBehaviorError::ClaimedNonDefaultAccount { account_id: err_account_id } - )) if err_account_id == account_id - )); - } - - /// This test ensures that even if a malicious program tries to perform overflow of balances - /// it will not be able to break the balance validation. - #[test] - fn malicious_program_cannot_break_balance_validation_if_not_in_genesis() { - let sender_key = PrivateKey::try_new([37; 32]).unwrap(); - let sender_id = AccountId::from(&PublicKey::new_from_private_key(&sender_key)); - let sender_init_balance: u128 = 10; - - let recipient_key = PrivateKey::try_new([42; 32]).unwrap(); - let recipient_id = AccountId::from(&PublicKey::new_from_private_key(&recipient_key)); - let recipient_init_balance: u128 = 10; - - let modified_transfer_id = crate::test_methods::modified_transfer_program().id(); - - let mut state = V03State::new() - .with_public_accounts([ - ( - sender_id, - Account { - program_owner: modified_transfer_id, - balance: sender_init_balance, - ..Account::default() - }, - ), - ( - recipient_id, - Account { - program_owner: modified_transfer_id, - balance: recipient_init_balance, - ..Account::default() - }, - ), - ]) - .with_test_programs(); - - let balance_to_move: u128 = 4; - - let sender = AccountWithMetadata::new(state.get_account_by_id(sender_id), true, sender_id); - - let sender_nonce = sender.account.nonce; - - let _recipient = - AccountWithMetadata::new(state.get_account_by_id(recipient_id), false, sender_id); - - let message = public_transaction::Message::try_new( - modified_transfer_id, - vec![sender_id, recipient_id], - vec![sender_nonce], - balance_to_move, - ) - .unwrap(); - - let witness_set = public_transaction::WitnessSet::for_message(&message, &[&sender_key]); - let tx = PublicTransaction::new(message, witness_set); - let res = state.transition_from_public_transaction(&tx, 2, 0); - let expected_total_balance_pre_states = WrappedBalanceSum::from_balances( - [sender_init_balance, recipient_init_balance].into_iter(), - ) - .unwrap(); - let expected_total_balance_post_states = WrappedBalanceSum::from_balances( - [sender_init_balance, recipient_init_balance, u128::MAX, 1].into_iter(), - ) - .unwrap(); - assert!(matches!( - res, - Err(LeeError::InvalidProgramBehavior( - InvalidProgramBehaviorError::ExecutionValidationFailed( - ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states } - ) - )) if total_balance_pre_states == expected_total_balance_pre_states && total_balance_post_states == expected_total_balance_post_states - )); - - let sender_post = state.get_account_by_id(sender_id); - let recipient_post = state.get_account_by_id(recipient_id); - - let expected_sender_post = { - let mut this = state.get_account_by_id(sender_id); - this.balance = sender_init_balance; - this.nonce = Nonce(0); - this - }; - - let expected_recipient_post = { - let mut this = state.get_account_by_id(sender_id); - this.balance = recipient_init_balance; - this.nonce = Nonce(0); - this - }; - - assert_eq!(expected_sender_post, sender_post); - assert_eq!(expected_recipient_post, recipient_post); - } - - #[test] - fn private_authorized_uninitialized_account() { - let mut state = V03State::new().with_test_programs(); - - // Set up keys for the authorized private account - let private_keys = test_private_account_keys_1(); - - // Create an authorized private account with default values (new account being initialized) - let authorized_account = - AccountWithMetadata::new(Account::default(), true, (&private_keys.npk(), 0)); - - let program = crate::test_methods::simple_balance_transfer(); - - // Set up parameters for the new account - let (shared_secret, epk) = - SharedSecretKey::encapsulate_deterministic(&private_keys.vpk(), &[0_u8; 32], 0); - - let instruction: u128 = 0; - - // Execute and prove the circuit with the authorized account but no commitment proof - let (output, proof) = execute_and_prove( - vec![authorized_account], - Program::serialize_instruction(instruction).unwrap(), - vec![InputAccountIdentity::PrivateAuthorizedInit { - epk, - view_tag: EncryptedAccountData::compute_view_tag( - &private_keys.npk(), - &private_keys.vpk(), - ), - ssk: shared_secret, - nsk: private_keys.nsk, - identifier: 0, - }], - &program.into(), - ) - .unwrap(); - - // Create message from circuit output - let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); - - let witness_set = WitnessSet::for_message(&message, proof, &[]); - - let tx = PrivacyPreservingTransaction::new(message, witness_set); - let result = state.transition_from_privacy_preserving_transaction(&tx, 1, 0); - assert!(result.is_ok()); - - let account_id = AccountId::for_regular_private_account(&private_keys.npk(), 0); - let nullifier = Nullifier::for_account_initialization(&account_id); - assert!(state.private_state.1.contains(&nullifier)); - } - - #[test] - fn private_unauthorized_uninitialized_account_can_still_be_claimed() { - let mut state = V03State::new().with_test_programs(); - - let private_keys = test_private_account_keys_1(); - // This is intentional: claim authorization was introduced to protect public accounts, - // especially PDAs. Private PDAs are not useful in practice because there is no way to - // operate them without the corresponding private keys, so unauthorized private claiming - // remains allowed. - let unauthorized_account = - AccountWithMetadata::new(Account::default(), false, (&private_keys.npk(), 0)); - - let program = crate::test_methods::claimer(); - let (shared_secret, epk) = - SharedSecretKey::encapsulate_deterministic(&private_keys.vpk(), &[0_u8; 32], 0); - - let (output, proof) = execute_and_prove( - vec![unauthorized_account], - Program::serialize_instruction(()).unwrap(), - vec![InputAccountIdentity::PrivateUnauthorized { - epk, - view_tag: EncryptedAccountData::compute_view_tag( - &private_keys.npk(), - &private_keys.vpk(), - ), - npk: private_keys.npk(), - ssk: shared_secret, - identifier: 0, - }], - &program.into(), - ) - .unwrap(); - - let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); - - let witness_set = WitnessSet::for_message(&message, proof, &[]); - let tx = PrivacyPreservingTransaction::new(message, witness_set); - - state - .transition_from_privacy_preserving_transaction(&tx, 1, 0) - .unwrap(); - - let account_id = AccountId::for_regular_private_account(&private_keys.npk(), 0); - let nullifier = Nullifier::for_account_initialization(&account_id); - assert!(state.private_state.1.contains(&nullifier)); - } - - #[test] - fn private_account_claimed_then_used_without_init_flag_should_fail() { - let mut state = V03State::new().with_test_programs(); - - // Set up keys for the private account - let private_keys = test_private_account_keys_1(); - - // Step 1: Create a new private account with authorization - let authorized_account = - AccountWithMetadata::new(Account::default(), true, (&private_keys.npk(), 0)); - - let claimer_program = crate::test_methods::claimer(); - - // Set up parameters for claiming the new account - let (shared_secret, epk) = - SharedSecretKey::encapsulate_deterministic(&private_keys.vpk(), &[0_u8; 32], 0); - - let instruction = (); - - // Step 2: Execute claimer program to claim the account with authentication - let (output, proof) = execute_and_prove( - vec![authorized_account.clone()], - Program::serialize_instruction(instruction).unwrap(), - vec![InputAccountIdentity::PrivateAuthorizedInit { - epk, - view_tag: EncryptedAccountData::compute_view_tag( - &private_keys.npk(), - &private_keys.vpk(), - ), - ssk: shared_secret, - nsk: private_keys.nsk, - identifier: 0, - }], - &claimer_program.into(), - ) - .unwrap(); - - let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); - - let witness_set = WitnessSet::for_message(&message, proof, &[]); - let tx = PrivacyPreservingTransaction::new(message, witness_set); - - // Claim should succeed - assert!( - state - .transition_from_privacy_preserving_transaction(&tx, 1, 0) - .is_ok() - ); - - // Verify the account is now initialized (nullifier exists) - let account_id = AccountId::for_regular_private_account(&private_keys.npk(), 0); - let nullifier = Nullifier::for_account_initialization(&account_id); - assert!(state.private_state.1.contains(&nullifier)); - - // Prepare new state of account - let account_metadata = { - let mut acc = authorized_account; - acc.account.program_owner = crate::test_methods::claimer().id(); - acc - }; - - let noop_program = crate::test_methods::noop(); - let shared_secret2 = - SharedSecretKey::encapsulate_deterministic(&private_keys.vpk(), &[0_u8; 32], 0).0; - - // Step 3: Try to execute noop program with authentication but without initialization - let res = execute_and_prove( - vec![account_metadata], - Program::serialize_instruction(()).unwrap(), - vec![InputAccountIdentity::PrivateAuthorizedInit { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &private_keys.npk(), - &private_keys.vpk(), - ), - ssk: shared_secret2, - nsk: private_keys.nsk, - identifier: 0, - }], - &noop_program.into(), - ); - - assert!(matches!(res, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn public_changer_claimer_no_data_change_no_claim_succeeds() { - let initial_data = []; - let mut state = V03State::new() - .with_public_accounts(public_state_from_balances(&initial_data)) - .with_test_programs(); - let account_id = AccountId::new([1; 32]); - let program_id = crate::test_methods::changer_claimer().id(); - // Don't change data (None) and don't claim (false) - let instruction: (Option>, bool) = (None, false); - - let message = - public_transaction::Message::try_new(program_id, vec![account_id], vec![], instruction) - .unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 1, 0); - - // Should succeed - no changes made, no claim needed - assert!(result.is_ok()); - // Account should remain default/unclaimed - assert_eq!(state.get_account_by_id(account_id), Account::default()); - } - - #[test] - fn public_changer_claimer_data_change_no_claim_fails() { - let initial_data = []; - let mut state = V03State::new() - .with_public_accounts(public_state_from_balances(&initial_data)) - .with_test_programs(); - let account_id = AccountId::new([1; 32]); - let program_id = crate::test_methods::changer_claimer().id(); - // Change data but don't claim (false) - should fail - let new_data = vec![1, 2, 3, 4, 5]; - let instruction: (Option>, bool) = (Some(new_data), false); - - let message = - public_transaction::Message::try_new(program_id, vec![account_id], vec![], instruction) - .unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 1, 0); - - // Should fail - cannot modify data without claiming the account - assert!(matches!( - result, - Err(LeeError::InvalidProgramBehavior( - InvalidProgramBehaviorError::DefaultAccountModifiedWithoutClaim { - account_id: err_account_id - } - )) if err_account_id == account_id - )); - } - - #[test] - fn private_changer_claimer_no_data_change_no_claim_succeeds() { - let program = crate::test_methods::changer_claimer(); - let sender_keys = test_private_account_keys_1(); - let private_account = - AccountWithMetadata::new(Account::default(), true, (&sender_keys.npk(), 0)); - // Don't change data (None) and don't claim (false) - let instruction: (Option>, bool) = (None, false); - - let result = execute_and_prove( - vec![private_account], - Program::serialize_instruction(instruction).unwrap(), - vec![InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &sender_keys.npk(), - &sender_keys.vpk(), - ), - ssk: SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0) - .0, - nsk: sender_keys.nsk, - membership_proof: (0, vec![]), - identifier: 0, - }], - &program.into(), - ); - - // Should succeed - no changes made, no claim needed - assert!(result.is_ok()); - } - - #[test] - fn private_changer_claimer_data_change_no_claim_fails() { - let program = crate::test_methods::changer_claimer(); - let sender_keys = test_private_account_keys_1(); - let private_account = - AccountWithMetadata::new(Account::default(), true, (&sender_keys.npk(), 0)); - // Change data but don't claim (false) - should fail - let new_data = vec![1, 2, 3, 4, 5]; - let instruction: (Option>, bool) = (Some(new_data), false); - - let result = execute_and_prove( - vec![private_account], - Program::serialize_instruction(instruction).unwrap(), - vec![InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &sender_keys.npk(), - &sender_keys.vpk(), - ), - ssk: SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0) - .0, - nsk: sender_keys.nsk, - membership_proof: (0, vec![]), - identifier: 0, - }], - &program.into(), - ); - - // Should fail - cannot modify data without claiming the account - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test] - fn malicious_authorization_changer_should_fail_in_privacy_preserving_circuit() { - // Arrange - let malicious_program = crate::test_methods::malicious_authorization_changer(); - let simple_transfers = crate::test_methods::simple_balance_transfer(); - let sender_keys = test_public_account_keys_1(); - let recipient_keys = test_private_account_keys_1(); - - let sender_account = AccountWithMetadata::new( - Account { - program_owner: simple_transfers.id(), - balance: 100, - ..Default::default() - }, - false, - sender_keys.account_id(), - ); - let recipient_account = - AccountWithMetadata::new(Account::default(), true, (&recipient_keys.npk(), 0)); - - let recipient_account_id = AccountId::for_regular_private_account(&recipient_keys.npk(), 0); - let recipient_commitment = - Commitment::new(&recipient_account_id, &recipient_account.account); - let recipient_init_nullifier = Nullifier::for_account_initialization(&recipient_account_id); - let state = V03State::new() - .with_public_accounts(public_state_from_balances(&[( - sender_account.account_id, - sender_account.account.balance, - )])) - .with_private_accounts([(recipient_commitment.clone(), recipient_init_nullifier)]) - .with_test_programs(); - - let balance_to_transfer = 10_u128; - let instruction = (balance_to_transfer, simple_transfers.id()); - - let recipient = - SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &[0_u8; 32], 0).0; - - let mut dependencies = HashMap::new(); - dependencies.insert(simple_transfers.id(), simple_transfers); - let program_with_deps = ProgramWithDependencies::new(malicious_program, dependencies); - - // Act - execute the malicious program - this should fail during proving - let result = execute_and_prove( - vec![sender_account, recipient_account], - Program::serialize_instruction(instruction).unwrap(), - vec![ - InputAccountIdentity::Public, - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(Vec::new()), - view_tag: EncryptedAccountData::compute_view_tag( - &recipient_keys.npk(), - &recipient_keys.vpk(), - ), - ssk: recipient, - nsk: recipient_keys.nsk, - membership_proof: state - .get_proof_for_commitment(&recipient_commitment) - .expect("recipient's commitment must be in state"), - identifier: 0, - }, - ], - &program_with_deps, - ); - - // Assert - should fail because the malicious program tries to manipulate is_authorized - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); - } - - #[test_case::test_case((Some(1), Some(3)), 3; "at upper bound")] - #[test_case::test_case((Some(1), Some(3)), 2; "inside range")] - #[test_case::test_case((Some(1), Some(3)), 0; "below range")] - #[test_case::test_case((Some(1), Some(3)), 1; "at lower bound")] - #[test_case::test_case((Some(1), Some(3)), 4; "above range")] - #[test_case::test_case((Some(1), None), 1; "lower bound only - at bound")] - #[test_case::test_case((Some(1), None), 10; "lower bound only - above")] - #[test_case::test_case((Some(1), None), 0; "lower bound only - below")] - #[test_case::test_case((None, Some(3)), 3; "upper bound only - at bound")] - #[test_case::test_case((None, Some(3)), 0; "upper bound only - below")] - #[test_case::test_case((None, Some(3)), 4; "upper bound only - above")] - #[test_case::test_case((None, None), 0; "no bounds - always valid")] - #[test_case::test_case((None, None), 100; "no bounds - always valid 2")] - fn validity_window_works_in_public_transactions( - validity_window: (Option, Option), - block_id: BlockId, - ) { - let block_validity_window: BlockValidityWindow = validity_window.try_into().unwrap(); - let validity_window_program = crate::test_methods::validity_window(); - let account_keys = test_public_account_keys_1(); - let pre = AccountWithMetadata::new(Account::default(), false, account_keys.account_id()); - let mut state = V03State::new().with_test_programs(); - let tx = { - let account_ids = vec![pre.account_id]; - let nonces = vec![]; - let program_id = validity_window_program.id(); - let instruction = ( - block_validity_window, - TimestampValidityWindow::new_unbounded(), - ); - let message = - public_transaction::Message::try_new(program_id, account_ids, nonces, instruction) - .unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - PublicTransaction::new(message, witness_set) - }; - let result = state.transition_from_public_transaction(&tx, block_id, 0); - let is_inside_validity_window = - match (block_validity_window.start(), block_validity_window.end()) { - (Some(s), Some(e)) => s <= block_id && block_id < e, - (Some(s), None) => s <= block_id, - (None, Some(e)) => block_id < e, - (None, None) => true, - }; - if is_inside_validity_window { - assert!(result.is_ok()); - } else { - assert!(matches!(result, Err(LeeError::OutOfValidityWindow))); - } - } - - #[test_case::test_case((Some(1), Some(3)), 3; "at upper bound")] - #[test_case::test_case((Some(1), Some(3)), 2; "inside range")] - #[test_case::test_case((Some(1), Some(3)), 0; "below range")] - #[test_case::test_case((Some(1), Some(3)), 1; "at lower bound")] - #[test_case::test_case((Some(1), Some(3)), 4; "above range")] - #[test_case::test_case((Some(1), None), 1; "lower bound only - at bound")] - #[test_case::test_case((Some(1), None), 10; "lower bound only - above")] - #[test_case::test_case((Some(1), None), 0; "lower bound only - below")] - #[test_case::test_case((None, Some(3)), 3; "upper bound only - at bound")] - #[test_case::test_case((None, Some(3)), 0; "upper bound only - below")] - #[test_case::test_case((None, Some(3)), 4; "upper bound only - above")] - #[test_case::test_case((None, None), 0; "no bounds - always valid")] - #[test_case::test_case((None, None), 100; "no bounds - always valid 2")] - fn timestamp_validity_window_works_in_public_transactions( - validity_window: (Option, Option), - timestamp: Timestamp, - ) { - let timestamp_validity_window: TimestampValidityWindow = - validity_window.try_into().unwrap(); - let validity_window_program = crate::test_methods::validity_window(); - let account_keys = test_public_account_keys_1(); - let pre = AccountWithMetadata::new(Account::default(), false, account_keys.account_id()); - let mut state = V03State::new().with_test_programs(); - let tx = { - let account_ids = vec![pre.account_id]; - let nonces = vec![]; - let program_id = validity_window_program.id(); - let instruction = ( - BlockValidityWindow::new_unbounded(), - timestamp_validity_window, - ); - let message = - public_transaction::Message::try_new(program_id, account_ids, nonces, instruction) - .unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - PublicTransaction::new(message, witness_set) - }; - let result = state.transition_from_public_transaction(&tx, 1, timestamp); - let is_inside_validity_window = match ( - timestamp_validity_window.start(), - timestamp_validity_window.end(), - ) { - (Some(s), Some(e)) => s <= timestamp && timestamp < e, - (Some(s), None) => s <= timestamp, - (None, Some(e)) => timestamp < e, - (None, None) => true, - }; - if is_inside_validity_window { - assert!(result.is_ok()); - } else { - assert!(matches!(result, Err(LeeError::OutOfValidityWindow))); - } - } - - #[test_case::test_case((Some(1), Some(3)), 3; "at upper bound")] - #[test_case::test_case((Some(1), Some(3)), 2; "inside range")] - #[test_case::test_case((Some(1), Some(3)), 0; "below range")] - #[test_case::test_case((Some(1), Some(3)), 1; "at lower bound")] - #[test_case::test_case((Some(1), Some(3)), 4; "above range")] - #[test_case::test_case((Some(1), None), 1; "lower bound only - at bound")] - #[test_case::test_case((Some(1), None), 10; "lower bound only - above")] - #[test_case::test_case((Some(1), None), 0; "lower bound only - below")] - #[test_case::test_case((None, Some(3)), 3; "upper bound only - at bound")] - #[test_case::test_case((None, Some(3)), 0; "upper bound only - below")] - #[test_case::test_case((None, Some(3)), 4; "upper bound only - above")] - #[test_case::test_case((None, None), 0; "no bounds - always valid")] - #[test_case::test_case((None, None), 100; "no bounds - always valid 2")] - fn validity_window_works_in_privacy_preserving_transactions( - validity_window: (Option, Option), - block_id: BlockId, - ) { - let block_validity_window: BlockValidityWindow = validity_window.try_into().unwrap(); - let validity_window_program = crate::test_methods::validity_window(); - let account_keys = test_private_account_keys_1(); - let pre = AccountWithMetadata::new(Account::default(), false, (&account_keys.npk(), 0)); - let mut state = V03State::new().with_test_programs(); - let tx = { - let (shared_secret, epk) = - SharedSecretKey::encapsulate_deterministic(&account_keys.vpk(), &[0_u8; 32], 0); - - let instruction = ( - block_validity_window, - TimestampValidityWindow::new_unbounded(), - ); - let (output, proof) = circuit::execute_and_prove( - vec![pre], - Program::serialize_instruction(instruction).unwrap(), - vec![InputAccountIdentity::PrivateUnauthorized { - epk, - view_tag: EncryptedAccountData::compute_view_tag( - &account_keys.npk(), - &account_keys.vpk(), - ), - npk: account_keys.npk(), - ssk: shared_secret, - identifier: 0, - }], - &validity_window_program.into(), - ) - .unwrap(); - - let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); - - let witness_set = WitnessSet::for_message(&message, proof, &[]); - PrivacyPreservingTransaction::new(message, witness_set) - }; - let result = state.transition_from_privacy_preserving_transaction(&tx, block_id, 0); - let is_inside_validity_window = - match (block_validity_window.start(), block_validity_window.end()) { - (Some(s), Some(e)) => s <= block_id && block_id < e, - (Some(s), None) => s <= block_id, - (None, Some(e)) => block_id < e, - (None, None) => true, - }; - if is_inside_validity_window { - assert!(result.is_ok()); - } else { - assert!(matches!(result, Err(LeeError::OutOfValidityWindow))); - } - } - - #[test_case::test_case((Some(1), Some(3)), 3; "at upper bound")] - #[test_case::test_case((Some(1), Some(3)), 2; "inside range")] - #[test_case::test_case((Some(1), Some(3)), 0; "below range")] - #[test_case::test_case((Some(1), Some(3)), 1; "at lower bound")] - #[test_case::test_case((Some(1), Some(3)), 4; "above range")] - #[test_case::test_case((Some(1), None), 1; "lower bound only - at bound")] - #[test_case::test_case((Some(1), None), 10; "lower bound only - above")] - #[test_case::test_case((Some(1), None), 0; "lower bound only - below")] - #[test_case::test_case((None, Some(3)), 3; "upper bound only - at bound")] - #[test_case::test_case((None, Some(3)), 0; "upper bound only - below")] - #[test_case::test_case((None, Some(3)), 4; "upper bound only - above")] - #[test_case::test_case((None, None), 0; "no bounds - always valid")] - #[test_case::test_case((None, None), 100; "no bounds - always valid 2")] - fn timestamp_validity_window_works_in_privacy_preserving_transactions( - validity_window: (Option, Option), - timestamp: Timestamp, - ) { - let timestamp_validity_window: TimestampValidityWindow = - validity_window.try_into().unwrap(); - let validity_window_program = crate::test_methods::validity_window(); - let account_keys = test_private_account_keys_1(); - let pre = AccountWithMetadata::new(Account::default(), false, (&account_keys.npk(), 0)); - let mut state = V03State::new().with_test_programs(); - let tx = { - let (shared_secret, epk) = - SharedSecretKey::encapsulate_deterministic(&account_keys.vpk(), &[0_u8; 32], 0); - - let instruction = ( - BlockValidityWindow::new_unbounded(), - timestamp_validity_window, - ); - let (output, proof) = circuit::execute_and_prove( - vec![pre], - Program::serialize_instruction(instruction).unwrap(), - vec![InputAccountIdentity::PrivateUnauthorized { - epk, - view_tag: EncryptedAccountData::compute_view_tag( - &account_keys.npk(), - &account_keys.vpk(), - ), - npk: account_keys.npk(), - ssk: shared_secret, - identifier: 0, - }], - &validity_window_program.into(), - ) - .unwrap(); - - let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); - - let witness_set = WitnessSet::for_message(&message, proof, &[]); - PrivacyPreservingTransaction::new(message, witness_set) - }; - let result = state.transition_from_privacy_preserving_transaction(&tx, 1, timestamp); - let is_inside_validity_window = match ( - timestamp_validity_window.start(), - timestamp_validity_window.end(), - ) { - (Some(s), Some(e)) => s <= timestamp && timestamp < e, - (Some(s), None) => s <= timestamp, - (None, Some(e)) => timestamp < e, - (None, None) => true, - }; - if is_inside_validity_window { - assert!(result.is_ok()); - } else { - assert!(matches!(result, Err(LeeError::OutOfValidityWindow))); - } - } - - #[test] - fn state_serialization_roundtrip() { - let account_id_1 = AccountId::new([1; 32]); - let account_id_2 = AccountId::new([2; 32]); - let initial_data = [(account_id_1, 100_u128), (account_id_2, 151_u128)]; - let state = V03State::new() - .with_public_accounts(public_state_from_balances(&initial_data)) - .with_test_programs(); - let bytes = borsh::to_vec(&state).unwrap(); - let state_from_bytes: V03State = borsh::from_slice(&bytes).unwrap(); - assert_eq!(state, state_from_bytes); - } - - #[test] - fn flash_swap_successful() { - let initiator = crate::test_methods::flash_swap_initiator(); - let callback = crate::test_methods::flash_swap_callback(); - let token = crate::test_methods::simple_balance_transfer(); - - let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32])); - let receiver_id = AccountId::for_public_pda(&callback.id(), &PdaSeed::new([1_u8; 32])); - - let initial_balance: u128 = 1000; - let amount_out: u128 = 100; - - let vault_account = Account { - program_owner: token.id(), - balance: initial_balance, - ..Account::default() - }; - let receiver_account = Account { - program_owner: token.id(), - balance: 0, - ..Account::default() - }; - - let mut state = V03State::new().with_test_programs(); - state.force_insert_account(vault_id, vault_account); - state.force_insert_account(receiver_id, receiver_account); - - // Callback instruction: return funds - let cb_instruction = CallbackInstruction { - return_funds: true, - token_program_id: token.id(), - amount: amount_out, - }; - let cb_data = Program::serialize_instruction(cb_instruction).unwrap(); - - let instruction = FlashSwapInstruction::Initiate { - token_program_id: token.id(), - callback_program_id: callback.id(), - amount_out, - callback_instruction_data: cb_data, - }; - - let tx = build_flash_swap_tx(&initiator, vault_id, receiver_id, instruction); - let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(result.is_ok(), "flash swap should succeed: {result:?}"); - - // Vault balance restored, receiver back to 0 - assert_eq!(state.get_account_by_id(vault_id).balance, initial_balance); - assert_eq!(state.get_account_by_id(receiver_id).balance, 0); - } - - #[test] - fn flash_swap_callback_keeps_funds_rollback() { - let initiator = crate::test_methods::flash_swap_initiator(); - let callback = crate::test_methods::flash_swap_callback(); - let token = crate::test_methods::simple_balance_transfer(); - - let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32])); - let receiver_id = AccountId::for_public_pda(&callback.id(), &PdaSeed::new([1_u8; 32])); - - let initial_balance: u128 = 1000; - let amount_out: u128 = 100; - - let vault_account = Account { - program_owner: token.id(), - balance: initial_balance, - ..Account::default() - }; - let receiver_account = Account { - program_owner: token.id(), - balance: 0, - ..Account::default() - }; - - let mut state = V03State::new().with_test_programs(); - state.force_insert_account(vault_id, vault_account); - state.force_insert_account(receiver_id, receiver_account); - - // Callback instruction: do NOT return funds - let cb_instruction = CallbackInstruction { - return_funds: false, - token_program_id: token.id(), - amount: amount_out, - }; - let cb_data = Program::serialize_instruction(cb_instruction).unwrap(); - - let instruction = FlashSwapInstruction::Initiate { - token_program_id: token.id(), - callback_program_id: callback.id(), - amount_out, - callback_instruction_data: cb_data, - }; - - let tx = build_flash_swap_tx(&initiator, vault_id, receiver_id, instruction); - let result = state.transition_from_public_transaction(&tx, 1, 0); - - // Invariant check fails → entire tx rolls back - assert!( - result.is_err(), - "flash swap should fail when callback keeps funds" - ); - - // State unchanged (rollback) - assert_eq!(state.get_account_by_id(vault_id).balance, initial_balance); - assert_eq!(state.get_account_by_id(receiver_id).balance, 0); - } - - #[test] - fn flash_swap_self_call_targets_correct_program() { - // Zero-amount flash swap: the invariant self-call still runs and succeeds - // because vault balance doesn't decrease. - let initiator = crate::test_methods::flash_swap_initiator(); - let callback = crate::test_methods::flash_swap_callback(); - let token = crate::test_methods::simple_balance_transfer(); - - let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32])); - let receiver_id = AccountId::for_public_pda(&callback.id(), &PdaSeed::new([1_u8; 32])); - - let initial_balance: u128 = 1000; - - let vault_account = Account { - program_owner: token.id(), - balance: initial_balance, - ..Account::default() - }; - let receiver_account = Account { - program_owner: token.id(), - balance: 0, - ..Account::default() - }; - - let mut state = V03State::new().with_test_programs(); - state.force_insert_account(vault_id, vault_account); - state.force_insert_account(receiver_id, receiver_account); - - let cb_instruction = CallbackInstruction { - return_funds: true, - token_program_id: token.id(), - amount: 0, - }; - let cb_data = Program::serialize_instruction(cb_instruction).unwrap(); - - let instruction = FlashSwapInstruction::Initiate { - token_program_id: token.id(), - callback_program_id: callback.id(), - amount_out: 0, - callback_instruction_data: cb_data, - }; - - let tx = build_flash_swap_tx(&initiator, vault_id, receiver_id, instruction); - let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!( - result.is_ok(), - "zero-amount flash swap should succeed: {result:?}" - ); - } - - #[test] - fn flash_swap_standalone_invariant_check_rejected() { - // Calling InvariantCheck directly (not as a chained self-call) should fail - // because caller_program_id will be None. - let initiator = crate::test_methods::flash_swap_initiator(); - let token = crate::test_methods::simple_balance_transfer(); - - let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32])); - - let vault_account = Account { - program_owner: token.id(), - balance: 1000, - ..Account::default() - }; - - let mut state = V03State::new().with_test_programs(); - state.force_insert_account(vault_id, vault_account); - - let instruction = FlashSwapInstruction::InvariantCheck { - min_vault_balance: 1000, - }; - - let message = public_transaction::Message::try_new( - initiator.id(), - vec![vault_id], - vec![], - instruction, - ) - .unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!( - result.is_err(), - "standalone InvariantCheck should be rejected (caller_program_id is None)" - ); - } - - #[test] - fn malicious_self_program_id_rejected_in_public_execution() { - let program = crate::test_methods::malicious_self_program_id(); - let acc_id = AccountId::new([99; 32]); - let account = Account::default(); - - let mut state = V03State::new().with_test_programs(); - state.force_insert_account(acc_id, account); - - let message = - public_transaction::Message::try_new(program.id(), vec![acc_id], vec![], ()).unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!( - result.is_err(), - "program with wrong self_program_id in output should be rejected" - ); - } - - #[test] - fn malicious_caller_program_id_rejected_in_public_execution() { - let program = crate::test_methods::malicious_caller_program_id(); - let acc_id = AccountId::new([99; 32]); - let account = Account::default(); - - let mut state = V03State::new().with_test_programs(); - state.force_insert_account(acc_id, account); - - let message = - public_transaction::Message::try_new(program.id(), vec![acc_id], vec![], ()).unwrap(); - let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - - let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!( - result.is_err(), - "program with spoofed caller_program_id in output should be rejected" - ); - } - - #[test] - fn two_private_pda_family_members_receive_and_spend() { - let funder_keys = test_public_account_keys_1(); - let alice_keys = test_private_account_keys_1(); - let alice_npk = alice_keys.npk(); - - let proxy = crate::test_methods::pda_spend_proxy(); - let simple_transfer = crate::test_methods::simple_balance_transfer(); - let proxy_id = proxy.id(); - let simple_transfer_id = simple_transfer.id(); - let seed = PdaSeed::new([42; 32]); - let amount: u128 = 100; - - let spend_with_deps = ProgramWithDependencies::new( - proxy, - [(simple_transfer_id, simple_transfer.clone())].into(), - ); - - let funder_id = funder_keys.account_id(); - let alice_pda_0_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, 0); - let alice_pda_1_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, 1); - let recipient_id = test_public_account_keys_2().account_id(); - let recipient_signing_key = test_public_account_keys_2().signing_key; - - let mut state = - V03State::new().with_public_accounts(public_state_from_balances(&[(funder_id, 500)])); - - let alice_pda_0_account = Account { - program_owner: simple_transfer_id, - balance: amount, - nonce: Nonce::private_account_nonce_init(&alice_pda_0_id), - ..Account::default() - }; - let alice_pda_1_account = Account { - program_owner: simple_transfer_id, - balance: amount, - nonce: Nonce::private_account_nonce_init(&alice_pda_1_id), - ..Account::default() - }; - - let (alice_shared_0, alice_epk_0) = - SharedSecretKey::encapsulate_deterministic(&alice_keys.vpk(), &[0_u8; 32], 0); - let (alice_shared_1, alice_epk_1) = - SharedSecretKey::encapsulate_deterministic(&alice_keys.vpk(), &[0_u8; 32], 1); - - // Fund alice_pda_0 via authenticated_transfer directly. - { - let funder_account = state.get_account_by_id(funder_id); - let funder_nonce = funder_account.nonce; - let (output, proof) = execute_and_prove( - vec![ - AccountWithMetadata::new(funder_account, true, funder_id), - AccountWithMetadata::new(Account::default(), false, alice_pda_0_id), - ], - Program::serialize_instruction(amount).unwrap(), - vec![ - InputAccountIdentity::Public, - InputAccountIdentity::PrivatePdaInit { - epk: alice_epk_0.clone(), - view_tag: EncryptedAccountData::compute_view_tag( - &alice_npk, - &alice_keys.vpk(), - ), - npk: alice_npk, - ssk: alice_shared_0, - identifier: 0, - seed: Some((seed, proxy_id)), - }, - ], - &simple_transfer.clone().into(), - ) - .unwrap(); - let message = - Message::try_from_circuit_output(vec![funder_id], vec![funder_nonce], output) - .unwrap(); - let witness_set = WitnessSet::for_message(&message, proof, &[&funder_keys.signing_key]); - state - .transition_from_privacy_preserving_transaction( - &PrivacyPreservingTransaction::new(message, witness_set), - 1, - 0, - ) - .unwrap(); - } - - // Fund alice_pda_1 the same way with identifier 1. - { - let funder_account = state.get_account_by_id(funder_id); - let funder_nonce = funder_account.nonce; - let (output, proof) = execute_and_prove( - vec![ - AccountWithMetadata::new(funder_account, true, funder_id), - AccountWithMetadata::new(Account::default(), false, alice_pda_1_id), - ], - Program::serialize_instruction(amount).unwrap(), - vec![ - InputAccountIdentity::Public, - InputAccountIdentity::PrivatePdaInit { - epk: alice_epk_1.clone(), - view_tag: EncryptedAccountData::compute_view_tag( - &alice_npk, - &alice_keys.vpk(), - ), - npk: alice_npk, - ssk: alice_shared_1, - identifier: 1, - seed: Some((seed, proxy_id)), - }, - ], - &simple_transfer.into(), - ) - .unwrap(); - let message = - Message::try_from_circuit_output(vec![funder_id], vec![funder_nonce], output) - .unwrap(); - let witness_set = WitnessSet::for_message(&message, proof, &[&funder_keys.signing_key]); - state - .transition_from_privacy_preserving_transaction( - &PrivacyPreservingTransaction::new(message, witness_set), - 2, - 0, - ) - .unwrap(); - } - - let commitment_pda_0 = Commitment::new(&alice_pda_0_id, &alice_pda_0_account); - let commitment_pda_1 = Commitment::new(&alice_pda_1_id, &alice_pda_1_account); - - assert!(state.get_proof_for_commitment(&commitment_pda_0).is_some()); - assert!(state.get_proof_for_commitment(&commitment_pda_1).is_some()); - - // Alice spends alice_pda_0 into the public recipient. - { - let recipient_account = state.get_account_by_id(recipient_id); - let (output, proof) = execute_and_prove( - vec![ - AccountWithMetadata::new(alice_pda_0_account, true, alice_pda_0_id), - AccountWithMetadata::new(recipient_account, true, recipient_id), - ], - Program::serialize_instruction((seed, amount, simple_transfer_id)).unwrap(), - vec![ - InputAccountIdentity::PrivatePdaUpdate { - epk: alice_epk_0, - view_tag: EncryptedAccountData::compute_view_tag( - &alice_npk, - &alice_keys.vpk(), - ), - ssk: alice_shared_0, - nsk: alice_keys.nsk, - membership_proof: state - .get_proof_for_commitment(&commitment_pda_0) - .expect("pda_0 must be in state"), - identifier: 0, - seed: None, - }, - InputAccountIdentity::Public, - ], - &spend_with_deps, - ) - .unwrap(); - let message = - Message::try_from_circuit_output(vec![recipient_id], vec![Nonce(0)], output) - .unwrap(); - let witness_set = WitnessSet::for_message(&message, proof, &[&recipient_signing_key]); - state - .transition_from_privacy_preserving_transaction( - &PrivacyPreservingTransaction::new(message, witness_set), - 3, - 0, - ) - .unwrap(); - } - - // Alice spends alice_pda_1 into the same public recipient. - { - let recipient_account = state.get_account_by_id(recipient_id); - let (output, proof) = execute_and_prove( - vec![ - AccountWithMetadata::new(alice_pda_1_account.clone(), true, alice_pda_1_id), - AccountWithMetadata::new(recipient_account, false, recipient_id), - ], - Program::serialize_instruction((seed, amount, simple_transfer_id)).unwrap(), - vec![ - InputAccountIdentity::PrivatePdaUpdate { - epk: alice_epk_1, - view_tag: EncryptedAccountData::compute_view_tag( - &alice_npk, - &alice_keys.vpk(), - ), - ssk: alice_shared_1, - nsk: alice_keys.nsk, - membership_proof: state - .get_proof_for_commitment(&commitment_pda_1) - .expect("pda_1 must be in state"), - identifier: 1, - seed: None, - }, - InputAccountIdentity::Public, - ], - &spend_with_deps, - ) - .unwrap(); - let message = - Message::try_from_circuit_output(vec![recipient_id], vec![], output).unwrap(); - let witness_set = WitnessSet::for_message(&message, proof, &[]); - state - .transition_from_privacy_preserving_transaction( - &PrivacyPreservingTransaction::new(message, witness_set), - 4, - 0, - ) - .unwrap(); - } - - assert_eq!(state.get_account_by_id(recipient_id).balance, 2 * amount); - - // Re-fund alice_pda_1 top-level via simple_transfer using PrivatePdaUpdate with an - // external seed. - let alice_pda_1_account_after_spend = Account { - program_owner: simple_transfer_id, - balance: 0, - nonce: alice_pda_1_account - .nonce - .private_account_nonce_increment(&alice_keys.nsk), - ..Account::default() - }; - let commitment_pda_1_after_spend = - Commitment::new(&alice_pda_1_id, &alice_pda_1_account_after_spend); - let alice_shared_1_refund = SharedSecretKey([12; 32]); - { - let recipient_account = state.get_account_by_id(recipient_id); - let recipient_nonce = recipient_account.nonce; - let (output, proof) = execute_and_prove( - vec![ - AccountWithMetadata::new(recipient_account, true, recipient_id), - AccountWithMetadata::new( - alice_pda_1_account_after_spend, - false, - alice_pda_1_id, - ), - ], - Program::serialize_instruction(amount).unwrap(), - vec![ - InputAccountIdentity::Public, - InputAccountIdentity::PrivatePdaUpdate { - epk: EphemeralPublicKey(vec![12_u8; 1088]), - view_tag: EncryptedAccountData::compute_view_tag( - &alice_npk, - &alice_keys.vpk(), - ), - nsk: alice_keys.nsk, - ssk: alice_shared_1_refund, - membership_proof: state - .get_proof_for_commitment(&commitment_pda_1_after_spend) - .expect("pda_1 after spend must be in state"), - identifier: 1, - seed: Some((seed, proxy_id)), - }, - ], - &crate::test_methods::simple_balance_transfer().into(), - ) - .unwrap(); - let message = - Message::try_from_circuit_output(vec![recipient_id], vec![recipient_nonce], output) - .unwrap(); - let witness_set = WitnessSet::for_message(&message, proof, &[&recipient_signing_key]); - state - .transition_from_privacy_preserving_transaction( - &PrivacyPreservingTransaction::new(message, witness_set), - 5, - 0, - ) - .unwrap(); - } - - assert_eq!(state.get_account_by_id(recipient_id).balance, amount); - } -} diff --git a/lee/state_machine/src/state/mod.rs b/lee/state_machine/src/state/mod.rs new file mode 100644 index 00000000..8b91b698 --- /dev/null +++ b/lee/state_machine/src/state/mod.rs @@ -0,0 +1,383 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; + +use borsh::{BorshDeserialize, BorshSerialize}; +use lee_core::{ + BlockId, Commitment, CommitmentSetDigest, DUMMY_COMMITMENT, MembershipProof, Nullifier, + Timestamp, + account::{Account, AccountId}, + program::ProgramId, +}; + +use crate::{ + error::LeeError, + merkle_tree::MerkleTree, + privacy_preserving_transaction::PrivacyPreservingTransaction, + program::Program, + program_deployment_transaction::ProgramDeploymentTransaction, + public_transaction::PublicTransaction, + validated_state_diff::{StateDiff, ValidatedStateDiff}, +}; + +pub const MAX_NUMBER_CHAINED_CALLS: usize = 10; + +#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[cfg_attr(test, derive(Debug))] +pub struct CommitmentSet { + merkle_tree: MerkleTree, + commitments: HashMap, + root_history: HashSet, +} + +impl CommitmentSet { + pub(crate) fn digest(&self) -> CommitmentSetDigest { + self.merkle_tree.root() + } + + /// Queries the `CommitmentSet` for a membership proof of commitment. + pub fn get_proof_for(&self, commitment: &Commitment) -> Option { + let index = *self.commitments.get(commitment)?; + + self.merkle_tree + .get_authentication_path_for(index) + .map(|path| (index, path)) + } + + /// Inserts a list of commitments to the `CommitmentSet`. + pub(crate) fn extend(&mut self, commitments: &[Commitment]) { + for commitment in commitments.iter().copied() { + let index = self.merkle_tree.insert(commitment.to_byte_array()); + self.commitments.insert(commitment, index); + } + self.root_history.insert(self.digest()); + } + + fn contains(&self, commitment: &Commitment) -> bool { + self.commitments.contains_key(commitment) + } + + /// Initializes an empty `CommitmentSet` with a given capacity. + /// If the capacity is not a `power_of_two`, then capacity is taken + /// to be the next `power_of_two`. + pub(crate) fn with_capacity(capacity: usize) -> Self { + Self { + merkle_tree: MerkleTree::with_capacity(capacity), + commitments: HashMap::new(), + root_history: HashSet::new(), + } + } +} + +#[cfg_attr(test, derive(Debug))] +#[derive(Clone, PartialEq, Eq)] +struct NullifierSet(BTreeSet); + +impl NullifierSet { + const fn new() -> Self { + Self(BTreeSet::new()) + } + + fn extend(&mut self, new_nullifiers: &[Nullifier]) { + self.0.extend(new_nullifiers); + } + + fn contains(&self, nullifier: &Nullifier) -> bool { + self.0.contains(nullifier) + } +} + +impl BorshSerialize for NullifierSet { + fn serialize(&self, writer: &mut W) -> std::io::Result<()> { + self.0.iter().collect::>().serialize(writer) + } +} + +impl BorshDeserialize for NullifierSet { + fn deserialize_reader(reader: &mut R) -> std::io::Result { + let vec = Vec::::deserialize_reader(reader)?; + + let mut set = BTreeSet::new(); + for n in vec { + if !set.insert(n) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "duplicate nullifier in NullifierSet", + )); + } + } + + Ok(Self(set)) + } +} + +#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[cfg_attr(test, derive(Debug))] +pub struct V03State { + public_state: HashMap, + private_state: (CommitmentSet, NullifierSet), + programs: HashMap, +} + +impl Default for V03State { + fn default() -> Self { + let mut commitment_set = CommitmentSet::with_capacity(32); + commitment_set.extend(&[DUMMY_COMMITMENT]); + let nullifier_set = NullifierSet::new(); + let private_state = (commitment_set, nullifier_set); + + Self { + public_state: HashMap::default(), + private_state, + programs: HashMap::default(), + } + } +} + +impl V03State { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn commitment_root(&self) -> CommitmentSetDigest { + self.private_state.0.digest() + } + + /// Initializes state with given public account balances leaving other account fields at their + /// default values. + #[must_use] + pub fn with_public_account_balances( + mut self, + balances: impl IntoIterator, + ) -> Self { + let public_accounts = balances.into_iter().map(|(account_id, balance)| { + ( + account_id, + Account { + balance, + ..Account::default() + }, + ) + }); + self.public_state.extend(public_accounts); + self + } + + /// Initializes state with given public accounts. + #[must_use] + pub fn with_public_accounts( + mut self, + public_accounts: impl IntoIterator, + ) -> Self { + self.public_state.extend(public_accounts); + self + } + + /// Initializes state with given private accounts. + #[must_use] + pub fn with_private_accounts( + mut self, + private_accounts: impl IntoIterator, + ) -> Self { + let (commitments, nullifiers): (Vec, Vec) = + private_accounts.into_iter().unzip(); + self.private_state.0.extend(&commitments); + self.private_state.1.extend(&nullifiers); + self + } + + /// Initializes state with given builtin programs. + #[must_use] + pub fn with_programs(mut self, programs: impl IntoIterator) -> Self { + for program in programs { + self.insert_program(program); + } + self + } + + pub(crate) fn insert_program(&mut self, program: Program) { + self.programs.insert(program.id(), program); + } + + pub fn apply_state_diff(&mut self, diff: ValidatedStateDiff) { + let StateDiff { + signer_account_ids, + public_diff, + new_commitments, + new_nullifiers, + program, + } = diff.into_state_diff(); + #[expect( + clippy::iter_over_hash_type, + reason = "Iteration order doesn't matter here" + )] + for (account_id, account) in public_diff { + *self.get_account_by_id_mut(account_id) = account; + } + for account_id in signer_account_ids { + self.get_account_by_id_mut(account_id) + .nonce + .public_account_nonce_increment(); + } + self.private_state.0.extend(&new_commitments); + self.private_state.1.extend(&new_nullifiers); + if let Some(program) = program { + self.insert_program(program); + } + } + + pub fn transition_from_public_transaction( + &mut self, + tx: &PublicTransaction, + block_id: BlockId, + timestamp: Timestamp, + ) -> Result<(), LeeError> { + let diff = ValidatedStateDiff::from_public_transaction(tx, self, block_id, timestamp)?; + self.apply_state_diff(diff); + Ok(()) + } + + pub fn transition_from_privacy_preserving_transaction( + &mut self, + tx: &PrivacyPreservingTransaction, + block_id: BlockId, + timestamp: Timestamp, + ) -> Result<(), LeeError> { + let diff = + ValidatedStateDiff::from_privacy_preserving_transaction(tx, self, block_id, timestamp)?; + self.apply_state_diff(diff); + Ok(()) + } + + pub fn transition_from_program_deployment_transaction( + &mut self, + tx: &ProgramDeploymentTransaction, + ) -> Result<(), LeeError> { + let diff = ValidatedStateDiff::from_program_deployment_transaction(tx, self)?; + self.apply_state_diff(diff); + Ok(()) + } + + fn get_account_by_id_mut(&mut self, account_id: AccountId) -> &mut Account { + self.public_state.entry(account_id).or_default() + } + + #[must_use] + pub fn get_account_by_id(&self, account_id: AccountId) -> Account { + self.public_state + .get(&account_id) + .cloned() + .unwrap_or_else(Account::default) + } + + /// Borrowing counterpart of [`Self::get_account_by_id`]. + #[must_use] + pub fn get_account_by_id_ref(&self, account_id: AccountId) -> Option<&Account> { + self.public_state.get(&account_id) + } + + #[must_use] + pub fn get_proof_for_commitment(&self, commitment: &Commitment) -> Option { + self.private_state.0.get_proof_for(commitment) + } + + pub(crate) const fn programs(&self) -> &HashMap { + &self.programs + } + + #[must_use] + pub fn commitment_set_digest(&self) -> CommitmentSetDigest { + self.private_state.0.digest() + } + + /// Order-independent fingerprint of the genesis-relevant state: the public + /// account set, the deployed program set, and the commitment-set digest. + /// + /// The sequencer and the indexer build the directly-seeded part of genesis + /// (base builtins plus any directly-seeded accounts) separately from their own + /// configs, so a divergence there would otherwise go unnoticed. Both nodes log + /// this at startup; equal values mean the two genesis states agree. Entries are + /// sorted by id before hashing, so the value does not depend on `HashMap` + /// iteration order. + #[must_use] + pub fn genesis_fingerprint(&self) -> [u8; 32] { + use sha2::{Digest as _, Sha256}; + + // Destructure so adding a `V03State` field forces a decision here about + // whether it belongs in the genesis fingerprint. + let Self { + public_state, + private_state, + programs, + } = self; + + let mut accounts: Vec<(&AccountId, &Account)> = public_state.iter().collect(); + accounts.sort_by(|a, b| a.0.as_ref().cmp(b.0.as_ref())); + + let mut program_ids: Vec = programs.keys().copied().collect(); + program_ids.sort_unstable(); + + let account_count = u64::try_from(accounts.len()).expect("account count fits in u64"); + let program_count = u64::try_from(program_ids.len()).expect("program count fits in u64"); + + let mut hasher = Sha256::new(); + hasher.update(account_count.to_le_bytes()); + for (id, account) in accounts { + hasher.update(id.as_ref()); + let bytes = borsh::to_vec(account).expect("Account is BorshSerialize"); + let len = u64::try_from(bytes.len()).expect("account encoding fits in u64"); + hasher.update(len.to_le_bytes()); + hasher.update(&bytes); + } + hasher.update(program_count.to_le_bytes()); + for id in program_ids { + for word in id { + hasher.update(word.to_le_bytes()); + } + } + hasher.update(private_state.0.digest()); + + let mut out = [0_u8; 32]; + out.copy_from_slice(&hasher.finalize()); + out + } + + pub(crate) fn check_commitments_are_new( + &self, + new_commitments: &[Commitment], + ) -> Result<(), LeeError> { + for commitment in new_commitments { + if self.private_state.0.contains(commitment) { + return Err(LeeError::InvalidInput("Commitment already seen".to_owned())); + } + } + Ok(()) + } + + pub(crate) fn check_nullifiers_are_valid( + &self, + new_nullifiers: &[(Nullifier, CommitmentSetDigest)], + ) -> Result<(), LeeError> { + for (nullifier, digest) in new_nullifiers { + if self.private_state.1.contains(nullifier) { + return Err(LeeError::InvalidInput("Nullifier already seen".to_owned())); + } + if !self.private_state.0.root_history.contains(digest) { + return Err(LeeError::InvalidInput( + "Unrecognized commitment set digest".to_owned(), + )); + } + } + Ok(()) + } +} + +#[cfg(any(test, feature = "test-utils"))] +impl V03State { + pub fn force_insert_account(&mut self, account_id: AccountId, account: Account) { + self.public_state.insert(account_id, account); + } +} + +#[cfg(test)] +pub mod tests; diff --git a/lee/state_machine/src/state/tests/authenticated_transfer.rs b/lee/state_machine/src/state/tests/authenticated_transfer.rs new file mode 100644 index 00000000..8d227fc3 --- /dev/null +++ b/lee/state_machine/src/state/tests/authenticated_transfer.rs @@ -0,0 +1,149 @@ +use super::*; + +#[test] +fn transition_from_authenticated_transfer_program_invocation_default_account_destination() { + let key = PrivateKey::try_new([1; 32]).unwrap(); + let account_id = AccountId::from(&PublicKey::new_from_private_key(&key)); + let initial_data = [( + account_id, + Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance: 100, + ..Account::default() + }, + )]; + let mut state = V03State::new() + .with_public_accounts(initial_data) + .with_test_programs(); + let from = account_id; + let to_key = PrivateKey::try_new([2; 32]).unwrap(); + let to = AccountId::from(&PublicKey::new_from_private_key(&to_key)); + assert_eq!(state.get_account_by_id(to), Account::default()); + let balance_to_move = 5; + + let tx = transfer_transaction(from, &key, 0, to, &to_key, 0, balance_to_move); + state.transition_from_public_transaction(&tx, 1, 0).unwrap(); + + assert_eq!(state.get_account_by_id(from).balance, 95); + assert_eq!(state.get_account_by_id(to).balance, 5); + assert_eq!(state.get_account_by_id(from).nonce, Nonce(1)); + assert_eq!(state.get_account_by_id(to).nonce, Nonce(1)); +} + +#[test] +fn transition_from_authenticated_transfer_program_invocation_insuficient_balance() { + let key = PrivateKey::try_new([1; 32]).unwrap(); + let account_id = AccountId::from(&PublicKey::new_from_private_key(&key)); + let mut state = V03State::new() + .with_public_account_balances([(account_id, 100)]) + .with_test_programs(); + let from = account_id; + let from_key = key; + let to_key = PrivateKey::try_new([2; 32]).unwrap(); + let to = AccountId::from(&PublicKey::new_from_private_key(&to_key)); + let balance_to_move = 101; + assert!(state.get_account_by_id(from).balance < balance_to_move); + + let tx = transfer_transaction(from, &from_key, 0, to, &to_key, 0, balance_to_move); + let result = state.transition_from_public_transaction(&tx, 1, 0); + + assert!(matches!(result, Err(LeeError::ProgramExecutionFailed(_)))); + assert_eq!(state.get_account_by_id(from).balance, 100); + assert_eq!(state.get_account_by_id(to).balance, 0); + assert_eq!(state.get_account_by_id(from).nonce, Nonce(0)); + assert_eq!(state.get_account_by_id(to).nonce, Nonce(0)); +} + +#[test] +fn transition_from_authenticated_transfer_program_invocation_non_default_account_destination() { + let key1 = PrivateKey::try_new([1; 32]).unwrap(); + let key2 = PrivateKey::try_new([2; 32]).unwrap(); + let account_id1 = AccountId::from(&PublicKey::new_from_private_key(&key1)); + let account_id2 = AccountId::from(&PublicKey::new_from_private_key(&key2)); + let initial_data = [ + ( + account_id1, + Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance: 100, + ..Account::default() + }, + ), + ( + account_id2, + Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance: 200, + ..Account::default() + }, + ), + ]; + let mut state = V03State::new() + .with_public_accounts(initial_data) + .with_test_programs(); + let from = account_id2; + let from_key = key2; + let to = account_id1; + let to_key = key1; + assert_ne!(state.get_account_by_id(to), Account::default()); + let balance_to_move = 8; + + let tx = transfer_transaction(from, &from_key, 0, to, &to_key, 0, balance_to_move); + state.transition_from_public_transaction(&tx, 1, 0).unwrap(); + + assert_eq!(state.get_account_by_id(from).balance, 192); + assert_eq!(state.get_account_by_id(to).balance, 108); + assert_eq!(state.get_account_by_id(from).nonce, Nonce(1)); + assert_eq!(state.get_account_by_id(to).nonce, Nonce(1)); +} + +#[test] +fn transition_from_sequence_of_authenticated_transfer_program_invocations() { + let key1 = PrivateKey::try_new([8; 32]).unwrap(); + let account_id1 = AccountId::from(&PublicKey::new_from_private_key(&key1)); + let key2 = PrivateKey::try_new([2; 32]).unwrap(); + let account_id2 = AccountId::from(&PublicKey::new_from_private_key(&key2)); + let initial_data = [( + account_id1, + Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance: 100, + ..Account::default() + }, + )]; + let mut state = V03State::new() + .with_public_accounts(initial_data) + .with_test_programs(); + let key3 = PrivateKey::try_new([3; 32]).unwrap(); + let account_id3 = AccountId::from(&PublicKey::new_from_private_key(&key3)); + let balance_to_move = 5; + + let tx = transfer_transaction( + account_id1, + &key1, + 0, + account_id2, + &key2, + 0, + balance_to_move, + ); + state.transition_from_public_transaction(&tx, 1, 0).unwrap(); + let balance_to_move = 3; + let tx = transfer_transaction( + account_id2, + &key2, + 1, + account_id3, + &key3, + 0, + balance_to_move, + ); + state.transition_from_public_transaction(&tx, 1, 0).unwrap(); + + assert_eq!(state.get_account_by_id(account_id1).balance, 95); + assert_eq!(state.get_account_by_id(account_id2).balance, 2); + assert_eq!(state.get_account_by_id(account_id3).balance, 3); + assert_eq!(state.get_account_by_id(account_id1).nonce, Nonce(1)); + assert_eq!(state.get_account_by_id(account_id2).nonce, Nonce(2)); + assert_eq!(state.get_account_by_id(account_id3).nonce, Nonce(1)); +} diff --git a/lee/state_machine/src/state/tests/changer_claimer.rs b/lee/state_machine/src/state/tests/changer_claimer.rs new file mode 100644 index 00000000..f6bb4932 --- /dev/null +++ b/lee/state_machine/src/state/tests/changer_claimer.rs @@ -0,0 +1,118 @@ +use super::*; + +#[test] +fn public_changer_claimer_no_data_change_no_claim_succeeds() { + let initial_data = []; + let mut state = V03State::new() + .with_public_accounts(public_state_from_balances(&initial_data)) + .with_test_programs(); + let account_id = AccountId::new([1; 32]); + let program_id = crate::test_methods::changer_claimer().id(); + // Don't change data (None) and don't claim (false) + let instruction: (Option>, bool) = (None, false); + + let message = + public_transaction::Message::try_new(program_id, vec![account_id], vec![], instruction) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + + // Should succeed - no changes made, no claim needed + assert!(result.is_ok()); + // Account should remain default/unclaimed + assert_eq!(state.get_account_by_id(account_id), Account::default()); +} + +#[test] +fn public_changer_claimer_data_change_no_claim_fails() { + let initial_data = []; + let mut state = V03State::new() + .with_public_accounts(public_state_from_balances(&initial_data)) + .with_test_programs(); + let account_id = AccountId::new([1; 32]); + let program_id = crate::test_methods::changer_claimer().id(); + // Change data but don't claim (false) - should fail + let new_data = vec![1, 2, 3, 4, 5]; + let instruction: (Option>, bool) = (Some(new_data), false); + + let message = + public_transaction::Message::try_new(program_id, vec![account_id], vec![], instruction) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + + // Should fail - cannot modify data without claiming the account + assert!(matches!( + result, + Err(LeeError::InvalidProgramBehavior( + InvalidProgramBehaviorError::DefaultAccountModifiedWithoutClaim { + account_id: err_account_id + } + )) if err_account_id == account_id + )); +} + +#[test] +fn private_changer_claimer_no_data_change_no_claim_succeeds() { + let program = crate::test_methods::changer_claimer(); + let sender_keys = test_private_account_keys_1(); + let private_account = AccountWithMetadata::new( + Account::default(), + true, + (&sender_keys.npk(), &sender_keys.vpk(), 0), + ); + // Don't change data (None) and don't claim (false) + let instruction: (Option>, bool) = (None, false); + + let result = execute_and_prove( + vec![private_account], + Program::serialize_instruction(instruction).unwrap(), + vec![InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: sender_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: sender_keys.nsk, + membership_proof: (0, vec![]), + identifier: 0, + }], + &program.into(), + ); + + // Should succeed - no changes made, no claim needed + assert!(result.is_ok()); +} + +#[test] +fn private_changer_claimer_data_change_no_claim_fails() { + let program = crate::test_methods::changer_claimer(); + let sender_keys = test_private_account_keys_1(); + let private_account = AccountWithMetadata::new( + Account::default(), + true, + (&sender_keys.npk(), &sender_keys.vpk(), 0), + ); + // Change data but don't claim (false) - should fail + let new_data = vec![1, 2, 3, 4, 5]; + let instruction: (Option>, bool) = (Some(new_data), false); + + let result = execute_and_prove( + vec![private_account], + Program::serialize_instruction(instruction).unwrap(), + vec![InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: sender_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: sender_keys.nsk, + membership_proof: (0, vec![]), + identifier: 0, + }], + &program.into(), + ); + + // Should fail - cannot modify data without claiming the account + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} diff --git a/lee/state_machine/src/state/tests/circuit.rs b/lee/state_machine/src/state/tests/circuit.rs new file mode 100644 index 00000000..17492824 --- /dev/null +++ b/lee/state_machine/src/state/tests/circuit.rs @@ -0,0 +1,1147 @@ +use super::*; + +#[test] +fn circuit_fails_if_visibility_masks_have_incorrect_lenght() { + let program = crate::test_methods::simple_balance_transfer(); + let public_account_1 = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 100, + ..Account::default() + }, + true, + AccountId::new([0; 32]), + ); + let public_account_2 = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 0, + ..Account::default() + }, + true, + AccountId::new([1; 32]), + ); + + // Single account_identity entry for a circuit execution with two pre_state accounts. + let result = execute_and_prove( + vec![public_account_1, public_account_2], + Program::serialize_instruction(10_u128).unwrap(), + vec![InputAccountIdentity::Public], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn circuit_fails_if_invalid_auth_keys_are_provided() { + let program = crate::test_methods::simple_balance_transfer(); + let sender_keys = test_private_account_keys_1(); + let recipient_keys = test_private_account_keys_2(); + let private_account_1 = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 100, + ..Account::default() + }, + true, + (&sender_keys.npk(), &sender_keys.vpk(), 0), + ); + let private_account_2 = AccountWithMetadata::new( + Account::default(), + true, + (&recipient_keys.npk(), &recipient_keys.vpk(), 0), + ); + + // Setting the recipient nsk to authorize the sender. + // This should be set to the sender private account in a normal circumstance. + // `PrivateAuthorizedUpdate` derives npk from nsk and asserts equality with + // `pre_state.account_id`, so a mismatched nsk fails that check. + let result = execute_and_prove( + vec![private_account_1, private_account_2], + Program::serialize_instruction(10_u128).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: sender_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: recipient_keys.nsk, + membership_proof: (0, vec![]), + identifier: 0, + }, + InputAccountIdentity::PrivateForeignInit { + vpk: recipient_keys.vpk(), + random_seed: [0; 32], + npk: recipient_keys.npk(), + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + }, + ], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provided() { + let program = crate::test_methods::simple_balance_transfer(); + let sender_keys = test_private_account_keys_1(); + let recipient_keys = test_private_account_keys_2(); + let private_account_1 = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 100, + ..Account::default() + }, + true, + (&sender_keys.npk(), &sender_keys.vpk(), 0), + ); + let private_account_2 = AccountWithMetadata::new( + Account { + // Non default balance + balance: 1, + ..Account::default() + }, + true, + (&recipient_keys.npk(), &recipient_keys.vpk(), 0), + ); + + let result = execute_and_prove( + vec![private_account_1, private_account_2], + Program::serialize_instruction(10_u128).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: sender_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: sender_keys.nsk, + membership_proof: (0, vec![]), + identifier: 0, + }, + InputAccountIdentity::PrivateForeignInit { + vpk: recipient_keys.vpk(), + random_seed: [0; 32], + npk: recipient_keys.npk(), + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + }, + ], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_provided() { + let program = crate::test_methods::simple_balance_transfer(); + let sender_keys = test_private_account_keys_1(); + let recipient_keys = test_private_account_keys_2(); + let private_account_1 = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 100, + ..Account::default() + }, + true, + (&sender_keys.npk(), &sender_keys.vpk(), 0), + ); + let private_account_2 = AccountWithMetadata::new( + Account { + // Non default program_owner + program_owner: [0, 1, 2, 3, 4, 5, 6, 7], + ..Account::default() + }, + true, + (&recipient_keys.npk(), &recipient_keys.vpk(), 0), + ); + + let result = execute_and_prove( + vec![private_account_1, private_account_2], + Program::serialize_instruction(10_u128).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: sender_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: sender_keys.nsk, + membership_proof: (0, vec![]), + identifier: 0, + }, + InputAccountIdentity::PrivateForeignInit { + vpk: recipient_keys.vpk(), + random_seed: [0; 32], + npk: recipient_keys.npk(), + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + }, + ], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided() { + let program = crate::test_methods::simple_balance_transfer(); + let sender_keys = test_private_account_keys_1(); + let recipient_keys = test_private_account_keys_2(); + let private_account_1 = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 100, + ..Account::default() + }, + true, + (&sender_keys.npk(), &sender_keys.vpk(), 0), + ); + let private_account_2 = AccountWithMetadata::new( + Account { + // Non default data + data: b"hola mundo".to_vec().try_into().unwrap(), + ..Account::default() + }, + true, + (&recipient_keys.npk(), &recipient_keys.vpk(), 0), + ); + + let result = execute_and_prove( + vec![private_account_1, private_account_2], + Program::serialize_instruction(10_u128).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: sender_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: sender_keys.nsk, + membership_proof: (0, vec![]), + identifier: 0, + }, + InputAccountIdentity::PrivateForeignInit { + vpk: recipient_keys.vpk(), + random_seed: [0; 32], + npk: recipient_keys.npk(), + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + }, + ], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided() { + let program = crate::test_methods::simple_balance_transfer(); + let sender_keys = test_private_account_keys_1(); + let recipient_keys = test_private_account_keys_2(); + let private_account_1 = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 100, + ..Account::default() + }, + true, + (&sender_keys.npk(), &sender_keys.vpk(), 0), + ); + let private_account_2 = AccountWithMetadata::new( + Account { + // Non default nonce + nonce: Nonce(0xdead_beef), + ..Account::default() + }, + true, + (&recipient_keys.npk(), &recipient_keys.vpk(), 0), + ); + + let result = execute_and_prove( + vec![private_account_1, private_account_2], + Program::serialize_instruction(10_u128).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: sender_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: sender_keys.nsk, + membership_proof: (0, vec![]), + identifier: 0, + }, + InputAccountIdentity::PrivateForeignInit { + vpk: recipient_keys.vpk(), + random_seed: [0; 32], + npk: recipient_keys.npk(), + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + }, + ], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_but_marked_as_unauthorized() + { + let program = crate::test_methods::simple_balance_transfer(); + let sender_keys = test_private_account_keys_1(); + let recipient_keys = test_private_account_keys_2(); + let private_account_1 = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 100, + ..Account::default() + }, + true, + (&sender_keys.npk(), &sender_keys.vpk(), 0), + ); + let private_account_2 = AccountWithMetadata::new( + Account::default(), + // This should be set to true in normal circumstances + false, + (&recipient_keys.npk(), &recipient_keys.vpk(), 0), + ); + + let result = execute_and_prove( + vec![private_account_1, private_account_2], + Program::serialize_instruction(10_u128).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: sender_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: sender_keys.nsk, + membership_proof: (0, vec![]), + identifier: 0, + }, + InputAccountIdentity::PrivateForeignInit { + vpk: recipient_keys.vpk(), + random_seed: [0; 32], + npk: recipient_keys.npk(), + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + }, + ], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +/// A private PDA account that no program claims via `Claim::Pda` and no caller authorizes via +/// `ChainedCall.pda_seeds` has no binding between its supplied npk and its `account_id`, +/// so the circuit must reject. Here `simple_balance_transfer` emits no claim for the +/// second account, leaving position 1 unbound. +#[test] +fn private_pda_without_binding_fails() { + let program = crate::test_methods::simple_balance_transfer(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let public_account_1 = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 100, + ..Account::default() + }, + true, + AccountId::new([0; 32]), + ); + let private_pda_account = + AccountWithMetadata::new(Account::default(), false, AccountId::new([1; 32])); + + let result = execute_and_prove( + vec![public_account_1, private_pda_account], + Program::serialize_instruction(10_u128).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivatePdaInit { + vpk: keys.vpk(), + random_seed: [0; 32], + npk, + identifier: u128::MAX, + commitment_root: DUMMY_COMMITMENT_HASH, + seed: None, + }, + ], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +/// Happy path: a program claims a new private PDA via `Claim::Pda(seed)`. The circuit +/// reads the npk for that `pre_state` from `private_account_keys` at the `pre_state`'s +/// position, derives `AccountId` via `AccountId::for_private_pda(program_id, seed, npk)`, and +/// asserts it equals the `pre_state`'s `account_id`. The equality both validates the claim +/// and binds the supplied npk to the `account_id`. +#[test] +fn private_pda_claim_succeeds() { + let program = crate::test_methods::pda_claimer(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + + let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), u128::MAX); + let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + + let result = execute_and_prove( + vec![pre_state], + Program::serialize_instruction(seed).unwrap(), + vec![InputAccountIdentity::PrivatePdaInit { + vpk: keys.vpk(), + random_seed: [0; 32], + npk, + identifier: u128::MAX, + commitment_root: DUMMY_COMMITMENT_HASH, + seed: None, + }], + &program.into(), + ); + + let (output, _proof) = result.expect("private PDA claim should succeed"); + assert_eq!(output.new_nullifiers.len(), 1); + assert_eq!(output.new_commitments.len(), 1); + assert_eq!(output.encrypted_private_post_states.len(), 1); + assert!(output.public_pre_states.is_empty()); + assert!(output.public_post_states.is_empty()); +} + +/// An npk is supplied that does not match the `pre_state`'s `account_id` under +/// `AccountId::for_private_pda(program, claim_seed, npk)`. The claim equality check rejects. +#[test] +fn private_pda_npk_mismatch_fails() { + // `keys_a` produces the `pre_state`'s `account_id` (the registered pair), `keys_b` is + // the mismatched pair supplied in `private_account_keys` for that pre_state. + let program = crate::test_methods::pda_claimer(); + let keys_a = test_private_account_keys_1(); + let keys_b = test_private_account_keys_2(); + let npk_a = keys_a.npk(); + let npk_b = keys_b.npk(); + let seed = PdaSeed::new([42; 32]); + + // `account_id` is derived from `npk_a`, but `npk_b` is supplied for this pre_state. + // `AccountId::for_private_pda(program, seed, npk_b) != account_id`, so the claim check in + // the circuit must reject. + let account_id = + AccountId::for_private_pda(&program.id(), &seed, &npk_a, &keys_a.vpk(), u128::MAX); + let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + + let result = execute_and_prove( + vec![pre_state], + Program::serialize_instruction(seed).unwrap(), + vec![InputAccountIdentity::PrivatePdaInit { + vpk: keys_b.vpk(), + random_seed: [0; 32], + npk: npk_b, + identifier: u128::MAX, + commitment_root: DUMMY_COMMITMENT_HASH, + seed: None, + }], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +/// Happy path for the caller-seeds authorization of a private PDA. The delegator claims a +/// private PDA via `Claim::Pda(seed)`, then chains to a callee (`noop`) delegating the same +/// seed via `ChainedCall.pda_seeds`. In the callee's step, the `pre_state`'s authorization +/// is established via the private derivation +/// `AccountId::for_private_pda(delegator, seed, npk) == pre.account_id`. +#[test] +fn caller_pda_seeds_authorize_private_pda_for_callee() { + let delegator = crate::test_methods::private_pda_delegator(); + let callee = crate::test_methods::auth_asserting_noop(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([77; 32]); + + let account_id = + AccountId::for_private_pda(&delegator.id(), &seed, &npk, &keys.vpk(), u128::MAX); + let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + + let callee_id = callee.id(); + let program_with_deps = ProgramWithDependencies::new(delegator, [(callee_id, callee)].into()); + + let result = execute_and_prove( + vec![pre_state], + Program::serialize_instruction((seed, seed, callee_id)).unwrap(), + vec![InputAccountIdentity::PrivatePdaInit { + vpk: keys.vpk(), + random_seed: [0; 32], + npk, + identifier: u128::MAX, + commitment_root: DUMMY_COMMITMENT_HASH, + seed: None, + }], + &program_with_deps, + ); + + let (output, _proof) = + result.expect("caller-seeds authorization of private PDA should succeed"); + assert_eq!(output.new_commitments.len(), 1); + assert_eq!(output.new_nullifiers.len(), 1); +} + +/// The delegator chains with a different seed than the one it claimed with. In the callee +/// step, neither public nor private caller-seeds authorization matches; `pre.is_authorized` +/// was set to `true` by the delegator but no proven source supports it, so the consistency +/// assertion rejects. +#[test] +fn caller_pda_seeds_with_wrong_seed_rejects_private_pda_for_callee() { + let delegator = crate::test_methods::private_pda_delegator(); + let callee = crate::test_methods::auth_asserting_noop(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let claim_seed = PdaSeed::new([77; 32]); + let wrong_delegated_seed = PdaSeed::new([88; 32]); + + let account_id = + AccountId::for_private_pda(&delegator.id(), &claim_seed, &npk, &keys.vpk(), u128::MAX); + let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + + let callee_id = callee.id(); + let program_with_deps = ProgramWithDependencies::new(delegator, [(callee_id, callee)].into()); + + let result = execute_and_prove( + vec![pre_state], + Program::serialize_instruction((claim_seed, wrong_delegated_seed, callee_id)).unwrap(), + vec![InputAccountIdentity::PrivatePdaInit { + vpk: keys.vpk(), + random_seed: [0; 32], + npk, + identifier: u128::MAX, + commitment_root: DUMMY_COMMITMENT_HASH, + seed: None, + }], + &program_with_deps, + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +/// Exploit-scenario pin. A single `(program_id, seed)` pair can derive a family of +/// `AccountId`s, one public PDA and one private PDA per distinct npk. Without the tx-wide +/// family-binding check, a program could claim `PDA_alice` (`alice_npk`) and +/// `PDA_bob` (`bob_npk`) under the same seed in one transaction, and once reuse +/// is supported a later chained call could delegate both to a callee via +/// `pda_seeds: [S]` and mix balances across them. The binding check rejects the setup +/// here: after the first claim records `(program, seed) → PDA_alice`, the second claim +/// tries to record `(program, seed) → PDA_bob` and panics. +#[test] +fn two_private_pda_claims_under_same_seed_are_rejected() { + let program = crate::test_methods::two_pda_claimer(); + let keys_a = test_private_account_keys_1(); + let keys_b = test_private_account_keys_2(); + let seed = PdaSeed::new([55; 32]); + + let account_a = AccountId::for_private_pda( + &program.id(), + &seed, + &keys_a.npk(), + &keys_a.vpk(), + u128::MAX, + ); + let account_b = AccountId::for_private_pda( + &program.id(), + &seed, + &keys_b.npk(), + &keys_b.vpk(), + u128::MAX, + ); + + let pre_a = AccountWithMetadata::new(Account::default(), false, account_a); + let pre_b = AccountWithMetadata::new(Account::default(), false, account_b); + + let result = execute_and_prove( + vec![pre_a, pre_b], + Program::serialize_instruction(seed).unwrap(), + vec![ + InputAccountIdentity::PrivatePdaInit { + vpk: keys_a.vpk(), + random_seed: [0; 32], + npk: keys_a.npk(), + identifier: u128::MAX, + commitment_root: DUMMY_COMMITMENT_HASH, + seed: None, + }, + InputAccountIdentity::PrivatePdaInit { + vpk: keys_b.vpk(), + random_seed: [0; 32], + npk: keys_b.npk(), + identifier: u128::MAX, + commitment_root: DUMMY_COMMITMENT_HASH, + seed: None, + }, + ], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +/// A private PDA that is reused at top level without an external seed in the identity still +/// fails binding. The noop program emits no `Claim::Pda` and there is no caller +/// `ChainedCall.pda_seeds`, so position 0 is never bound and the assertion fires. +/// Supplying `seed: Some((seed, owner_program_id))` in the `PrivatePdaUpdate` identity is +/// the correct path for top-level reuse; this test pins the failure when no seed is provided. +#[test] +fn private_pda_top_level_reuse_rejected_by_binding_check() { + let program = crate::test_methods::noop(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([99; 32]); + + // Simulate a previously-claimed private PDA: program_owner != DEFAULT, is_authorized = + // true, account_id derived via the private formula. + let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), u128::MAX); + let owned_pre_state = AccountWithMetadata::new( + Account { + program_owner: program.id(), + ..Account::default() + }, + true, + account_id, + ); + + let result = execute_and_prove( + vec![owned_pre_state], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::PrivatePdaInit { + vpk: keys.vpk(), + random_seed: [0; 32], + npk, + identifier: u128::MAX, + commitment_root: DUMMY_COMMITMENT_HASH, + seed: None, + }], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn private_accounts_can_only_be_initialized_once() { + let sender_keys = test_private_account_keys_1(); + let sender_nonce = Nonce(0xdead_beef); + + let sender_private_account = Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance: 100, + nonce: sender_nonce, + data: Data::default(), + }; + let recipient_keys = test_private_account_keys_2(); + + let mut state = V03State::new().with_private_account(&sender_keys, &sender_private_account); + + let balance_to_move = 37; + let balance_to_move_2 = 30; + + let tx = private_balance_transfer_for_tests( + &sender_keys, + &sender_private_account, + &recipient_keys, + balance_to_move, + &state, + ); + + state + .transition_from_privacy_preserving_transaction(&tx, 1, 0) + .unwrap(); + + let sender_private_account = Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance: 100, + nonce: sender_nonce, + data: Data::default(), + }; + + let tx = private_balance_transfer_for_tests( + &sender_keys, + &sender_private_account, + &recipient_keys, + balance_to_move_2, + &state, + ); + + let result = state.transition_from_privacy_preserving_transaction(&tx, 1, 0); + + assert!(matches!(result, Err(LeeError::InvalidInput(_)))); + let LeeError::InvalidInput(error_message) = result.err().unwrap() else { + panic!("Incorrect message error"); + }; + let expected_error_message = "Nullifier already seen".to_owned(); + assert_eq!(error_message, expected_error_message); +} + +#[test] +fn circuit_should_fail_if_there_are_repeated_ids() { + let program = crate::test_methods::simple_balance_transfer(); + let sender_keys = test_private_account_keys_1(); + let private_account_1 = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 100, + ..Account::default() + }, + true, + (&sender_keys.npk(), &sender_keys.vpk(), 0), + ); + + let result = execute_and_prove( + vec![private_account_1.clone(), private_account_1], + Program::serialize_instruction(100_u128).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: sender_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: sender_keys.nsk, + membership_proof: (1, vec![]), + identifier: 0, + }, + InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: sender_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: sender_keys.nsk, + membership_proof: (1, vec![]), + identifier: 0, + }, + ], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn private_authorized_uninitialized_account() { + let mut state = V03State::new().with_test_programs(); + + // Set up keys for the authorized private account + let private_keys = test_private_account_keys_1(); + + // Create an authorized private account with default values (new account being initialized) + let authorized_account = AccountWithMetadata::new( + Account::default(), + true, + (&private_keys.npk(), &private_keys.vpk(), 0), + ); + + let program = crate::test_methods::simple_balance_transfer(); + + // Set up parameters for the new account + + let instruction: u128 = 0; + + // Execute and prove the circuit with the authorized account but no commitment proof + let (output, proof) = execute_and_prove( + vec![authorized_account], + Program::serialize_instruction(instruction).unwrap(), + vec![InputAccountIdentity::PrivateAuthorizedInit { + vpk: private_keys.vpk(), + random_seed: [0; 32], + nsk: private_keys.nsk, + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + }], + &program.into(), + ) + .unwrap(); + + // Create message from circuit output + let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[]); + + let tx = PrivacyPreservingTransaction::new(message, witness_set); + let result = state.transition_from_privacy_preserving_transaction(&tx, 1, 0); + assert!(result.is_ok()); + + let account_id = + AccountId::for_regular_private_account(&private_keys.npk(), &private_keys.vpk(), 0); + let nullifier = Nullifier::for_account_initialization(&account_id); + assert!(state.private_state.1.contains(&nullifier)); +} + +#[test] +fn private_unauthorized_uninitialized_account_can_still_be_claimed() { + let mut state = V03State::new().with_test_programs(); + + let private_keys = test_private_account_keys_1(); + // This is intentional: claim authorization was introduced to protect public accounts, + // especially PDAs. Private PDAs are not useful in practice because there is no way to + // operate them without the corresponding private keys, so unauthorized private claiming + // remains allowed. + let unauthorized_account = AccountWithMetadata::new( + Account::default(), + true, + (&private_keys.npk(), &private_keys.vpk(), 0), + ); + + let program = crate::test_methods::claimer(); + + let (output, proof) = execute_and_prove( + vec![unauthorized_account], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::PrivateForeignInit { + vpk: private_keys.vpk(), + random_seed: [0; 32], + npk: private_keys.npk(), + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + }], + &program.into(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + + state + .transition_from_privacy_preserving_transaction(&tx, 1, 0) + .unwrap(); + + let account_id = + AccountId::for_regular_private_account(&private_keys.npk(), &private_keys.vpk(), 0); + let nullifier = Nullifier::for_account_initialization(&account_id); + assert!(state.private_state.1.contains(&nullifier)); +} + +#[test] +fn private_account_claimed_then_used_without_init_flag_should_fail() { + let mut state = V03State::new().with_test_programs(); + + // Set up keys for the private account + let private_keys = test_private_account_keys_1(); + + // Step 1: Create a new private account with authorization + let authorized_account = AccountWithMetadata::new( + Account::default(), + true, + (&private_keys.npk(), &private_keys.vpk(), 0), + ); + + let claimer_program = crate::test_methods::claimer(); + + // Set up parameters for claiming the new account + + let instruction = (); + + // Step 2: Execute claimer program to claim the account with authentication + let (output, proof) = execute_and_prove( + vec![authorized_account.clone()], + Program::serialize_instruction(instruction).unwrap(), + vec![InputAccountIdentity::PrivateAuthorizedInit { + vpk: private_keys.vpk(), + random_seed: [0; 32], + nsk: private_keys.nsk, + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + }], + &claimer_program.into(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + + // Claim should succeed + assert!( + state + .transition_from_privacy_preserving_transaction(&tx, 1, 0) + .is_ok() + ); + + // Verify the account is now initialized (nullifier exists) + let account_id = + AccountId::for_regular_private_account(&private_keys.npk(), &private_keys.vpk(), 0); + let nullifier = Nullifier::for_account_initialization(&account_id); + assert!(state.private_state.1.contains(&nullifier)); + + // Prepare new state of account + let account_metadata = { + let mut acc = authorized_account; + acc.account.program_owner = crate::test_methods::claimer().id(); + acc + }; + + let noop_program = crate::test_methods::noop(); + + // Step 3: Try to execute noop program with authentication but without initialization + let res = execute_and_prove( + vec![account_metadata], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::PrivateAuthorizedInit { + vpk: private_keys.vpk(), + random_seed: [0; 32], + nsk: private_keys.nsk, + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + }], + &noop_program.into(), + ); + + assert!(matches!(res, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn two_private_pda_family_members_receive_and_spend() { + let funder_keys = test_public_account_keys_1(); + let alice_keys = test_private_account_keys_1(); + let alice_npk = alice_keys.npk(); + + let proxy = crate::test_methods::pda_spend_proxy(); + let simple_transfer = crate::test_methods::simple_balance_transfer(); + let proxy_id = proxy.id(); + let simple_transfer_id = simple_transfer.id(); + let seed = PdaSeed::new([42; 32]); + let amount: u128 = 100; + + let spend_with_deps = ProgramWithDependencies::new( + proxy, + [(simple_transfer_id, simple_transfer.clone())].into(), + ); + + let funder_id = funder_keys.account_id(); + let alice_pda_0_id = + AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, &alice_keys.vpk(), 0); + let alice_pda_1_id = + AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, &alice_keys.vpk(), 1); + let recipient_id = test_public_account_keys_2().account_id(); + let recipient_signing_key = test_public_account_keys_2().signing_key; + + let mut state = + V03State::new().with_public_accounts(public_state_from_balances(&[(funder_id, 500)])); + + let alice_pda_0_account = Account { + program_owner: simple_transfer_id, + balance: amount, + nonce: Nonce::private_account_nonce_init(&alice_pda_0_id), + ..Account::default() + }; + let alice_pda_1_account = Account { + program_owner: simple_transfer_id, + balance: amount, + nonce: Nonce::private_account_nonce_init(&alice_pda_1_id), + ..Account::default() + }; + + // Fund alice_pda_0 via authenticated_transfer directly. + { + let funder_account = state.get_account_by_id(funder_id); + let funder_nonce = funder_account.nonce; + let (output, proof) = execute_and_prove( + vec![ + AccountWithMetadata::new(funder_account, true, funder_id), + AccountWithMetadata::new(Account::default(), false, alice_pda_0_id), + ], + Program::serialize_instruction(amount).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivatePdaInit { + vpk: alice_keys.vpk(), + random_seed: [0; 32], + npk: alice_npk, + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + seed: Some((seed, proxy_id)), + }, + ], + &simple_transfer.clone().into(), + ) + .unwrap(); + let message = + Message::try_from_circuit_output(vec![funder_id], vec![funder_nonce], output).unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&funder_keys.signing_key]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 1, + 0, + ) + .unwrap(); + } + + // Fund alice_pda_1 the same way with identifier 1. + { + let funder_account = state.get_account_by_id(funder_id); + let funder_nonce = funder_account.nonce; + let (output, proof) = execute_and_prove( + vec![ + AccountWithMetadata::new(funder_account, true, funder_id), + AccountWithMetadata::new(Account::default(), false, alice_pda_1_id), + ], + Program::serialize_instruction(amount).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivatePdaInit { + vpk: alice_keys.vpk(), + random_seed: [0; 32], + npk: alice_npk, + identifier: 1, + commitment_root: DUMMY_COMMITMENT_HASH, + seed: Some((seed, proxy_id)), + }, + ], + &simple_transfer.into(), + ) + .unwrap(); + let message = + Message::try_from_circuit_output(vec![funder_id], vec![funder_nonce], output).unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&funder_keys.signing_key]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 2, + 0, + ) + .unwrap(); + } + + let commitment_pda_0 = Commitment::new(&alice_pda_0_id, &alice_pda_0_account); + let commitment_pda_1 = Commitment::new(&alice_pda_1_id, &alice_pda_1_account); + + assert!(state.get_proof_for_commitment(&commitment_pda_0).is_some()); + assert!(state.get_proof_for_commitment(&commitment_pda_1).is_some()); + + // Alice spends alice_pda_0 into the public recipient. + { + let recipient_account = state.get_account_by_id(recipient_id); + let (output, proof) = execute_and_prove( + vec![ + AccountWithMetadata::new(alice_pda_0_account, true, alice_pda_0_id), + AccountWithMetadata::new(recipient_account, true, recipient_id), + ], + Program::serialize_instruction((seed, amount, simple_transfer_id)).unwrap(), + vec![ + InputAccountIdentity::PrivatePdaUpdate { + vpk: alice_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: alice_keys.nsk, + membership_proof: state + .get_proof_for_commitment(&commitment_pda_0) + .expect("pda_0 must be in state"), + identifier: 0, + seed: None, + }, + InputAccountIdentity::Public, + ], + &spend_with_deps, + ) + .unwrap(); + let message = + Message::try_from_circuit_output(vec![recipient_id], vec![Nonce(0)], output).unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&recipient_signing_key]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 3, + 0, + ) + .unwrap(); + } + + // Alice spends alice_pda_1 into the same public recipient. + { + let recipient_account = state.get_account_by_id(recipient_id); + let (output, proof) = execute_and_prove( + vec![ + AccountWithMetadata::new(alice_pda_1_account.clone(), true, alice_pda_1_id), + AccountWithMetadata::new(recipient_account, false, recipient_id), + ], + Program::serialize_instruction((seed, amount, simple_transfer_id)).unwrap(), + vec![ + InputAccountIdentity::PrivatePdaUpdate { + vpk: alice_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: alice_keys.nsk, + membership_proof: state + .get_proof_for_commitment(&commitment_pda_1) + .expect("pda_1 must be in state"), + identifier: 1, + seed: None, + }, + InputAccountIdentity::Public, + ], + &spend_with_deps, + ) + .unwrap(); + let message = Message::try_from_circuit_output(vec![recipient_id], vec![], output).unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 4, + 0, + ) + .unwrap(); + } + + assert_eq!(state.get_account_by_id(recipient_id).balance, 2 * amount); + + // Re-fund alice_pda_1 top-level via simple_transfer using PrivatePdaUpdate with an + // external seed. + let alice_pda_1_account_after_spend = Account { + program_owner: simple_transfer_id, + balance: 0, + nonce: alice_pda_1_account + .nonce + .private_account_nonce_increment(&alice_keys.nsk), + ..Account::default() + }; + let commitment_pda_1_after_spend = + Commitment::new(&alice_pda_1_id, &alice_pda_1_account_after_spend); + { + let recipient_account = state.get_account_by_id(recipient_id); + let recipient_nonce = recipient_account.nonce; + let (output, proof) = execute_and_prove( + vec![ + AccountWithMetadata::new(recipient_account, true, recipient_id), + AccountWithMetadata::new(alice_pda_1_account_after_spend, false, alice_pda_1_id), + ], + Program::serialize_instruction(amount).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivatePdaUpdate { + vpk: alice_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: alice_keys.nsk, + membership_proof: state + .get_proof_for_commitment(&commitment_pda_1_after_spend) + .expect("pda_1 after spend must be in state"), + identifier: 1, + seed: Some((seed, proxy_id)), + }, + ], + &crate::test_methods::simple_balance_transfer().into(), + ) + .unwrap(); + let message = + Message::try_from_circuit_output(vec![recipient_id], vec![recipient_nonce], output) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&recipient_signing_key]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 5, + 0, + ) + .unwrap(); + } + + assert_eq!(state.get_account_by_id(recipient_id).balance, amount); +} diff --git a/lee/state_machine/src/state/tests/claiming.rs b/lee/state_machine/src/state/tests/claiming.rs new file mode 100644 index 00000000..3bdc315c --- /dev/null +++ b/lee/state_machine/src/state/tests/claiming.rs @@ -0,0 +1,610 @@ +use super::*; + +#[test] +fn claiming_mechanism() { + let program = crate::test_methods::simple_balance_transfer(); + let from_key = PrivateKey::try_new([1; 32]).unwrap(); + let from = AccountId::from(&PublicKey::new_from_private_key(&from_key)); + let initial_balance = 100; + let initial_data = [(from, initial_balance)]; + let mut state = V03State::new() + .with_public_accounts(public_state_from_balances(&initial_data)) + .with_test_programs(); + let to_key = PrivateKey::try_new([2; 32]).unwrap(); + let to = AccountId::from(&PublicKey::new_from_private_key(&to_key)); + let amount: u128 = 37; + + // Check the recipient is an uninitialized account + assert_eq!(state.get_account_by_id(to), Account::default()); + + let expected_recipient_post = Account { + program_owner: program.id(), + balance: amount, + nonce: Nonce(1), + ..Account::default() + }; + + let message = public_transaction::Message::try_new( + program.id(), + vec![from, to], + vec![Nonce(0), Nonce(0)], + amount, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&from_key, &to_key]); + let tx = PublicTransaction::new(message, witness_set); + + state.transition_from_public_transaction(&tx, 1, 0).unwrap(); + + let recipient_post = state.get_account_by_id(to); + + assert_eq!(recipient_post, expected_recipient_post); +} + +#[test] +fn unauthorized_public_account_claiming_fails() { + let program = crate::test_methods::simple_balance_transfer(); + let account_key = PrivateKey::try_new([9; 32]).unwrap(); + let account_id = AccountId::from(&PublicKey::new_from_private_key(&account_key)); + let mut state = V03State::new().with_test_programs(); + + assert_eq!(state.get_account_by_id(account_id), Account::default()); + + let message = + public_transaction::Message::try_new(program.id(), vec![account_id], vec![], 0_u128) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 2, 0); + + assert!(matches!(result, Err(LeeError::InvalidProgramBehavior(_)))); + assert_eq!(state.get_account_by_id(account_id), Account::default()); +} + +#[test] +fn authorized_public_account_claiming_succeeds() { + let program = crate::test_methods::simple_balance_transfer(); + let account_key = PrivateKey::try_new([10; 32]).unwrap(); + let account_id = AccountId::from(&PublicKey::new_from_private_key(&account_key)); + let mut state = V03State::new().with_test_programs(); + + assert_eq!(state.get_account_by_id(account_id), Account::default()); + + let message = public_transaction::Message::try_new( + program.id(), + vec![account_id], + vec![Nonce(0)], + 0_u128, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&account_key]); + let tx = PublicTransaction::new(message, witness_set); + + state.transition_from_public_transaction(&tx, 1, 0).unwrap(); + + assert_eq!( + state.get_account_by_id(account_id), + Account { + program_owner: program.id(), + nonce: Nonce(1), + ..Account::default() + } + ); +} + +#[test] +fn public_chained_call() { + let program = crate::test_methods::chain_caller(); + let key = PrivateKey::try_new([1; 32]).unwrap(); + let from = AccountId::from(&PublicKey::new_from_private_key(&key)); + let to = AccountId::new([2; 32]); + let initial_balance = 1000; + let initial_data = [(from, initial_balance), (to, 0)]; + let mut state = V03State::new() + .with_public_accounts(public_state_from_balances(&initial_data)) + .with_test_programs(); + let from_key = key; + let amount: u128 = 37; + let instruction: (u128, ProgramId, u32, Option) = ( + amount, + crate::test_methods::simple_balance_transfer().id(), + 2, + None, + ); + + let expected_to_post = Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance: amount * 2, // The `chain_caller` chains the program twice + ..Account::default() + }; + + let message = public_transaction::Message::try_new( + program.id(), + vec![to, from], // The chain_caller program permutes the account order in the chain + // call + vec![Nonce(0)], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&from_key]); + let tx = PublicTransaction::new(message, witness_set); + + state.transition_from_public_transaction(&tx, 1, 0).unwrap(); + + let from_post = state.get_account_by_id(from); + let to_post = state.get_account_by_id(to); + // The `chain_caller` program calls the program twice + assert_eq!(from_post.balance, initial_balance - 2 * amount); + assert_eq!(to_post, expected_to_post); +} + +#[test] +fn execution_fails_if_chained_calls_exceeds_depth() { + let program = crate::test_methods::chain_caller(); + let key = PrivateKey::try_new([1; 32]).unwrap(); + let from = AccountId::from(&PublicKey::new_from_private_key(&key)); + let to = AccountId::new([2; 32]); + let initial_balance = 100; + let initial_data = [(from, initial_balance), (to, 0)]; + let mut state = V03State::new() + .with_public_accounts(public_state_from_balances(&initial_data)) + .with_test_programs(); + let from_key = key; + let amount: u128 = 0; + let instruction: (u128, ProgramId, u32, Option) = ( + amount, + crate::test_methods::simple_balance_transfer().id(), + u32::try_from(MAX_NUMBER_CHAINED_CALLS).expect("MAX_NUMBER_CHAINED_CALLS fits in u32") + 1, + None, + ); + + let message = public_transaction::Message::try_new( + program.id(), + vec![to, from], // The chain_caller program permutes the account order in the chain + // call + vec![Nonce(0)], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&from_key]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + assert!(matches!( + result, + Err(LeeError::MaxChainedCallsDepthExceeded) + )); +} + +#[test] +fn execution_that_requires_authentication_of_a_program_derived_account_id_succeeds() { + let chain_caller = crate::test_methods::chain_caller(); + let pda_seed = PdaSeed::new([37; 32]); + let from = AccountId::for_public_pda(&chain_caller.id(), &pda_seed); + let to = AccountId::new([2; 32]); + let initial_balance = 1000; + let initial_data = [(from, initial_balance), (to, 0)]; + let mut state = V03State::new() + .with_public_accounts(public_state_from_balances(&initial_data)) + .with_test_programs(); + let amount: u128 = 58; + let instruction: (u128, ProgramId, u32, Option) = ( + amount, + crate::test_methods::simple_balance_transfer().id(), + 1, + Some(pda_seed), + ); + + let expected_to_post = Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance: amount, // The `chain_caller` chains the program twice + ..Account::default() + }; + let message = public_transaction::Message::try_new( + chain_caller.id(), + vec![to, from], // The chain_caller program permutes the account order in the chain + // call + vec![], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + state.transition_from_public_transaction(&tx, 1, 0).unwrap(); + + let from_post = state.get_account_by_id(from); + let to_post = state.get_account_by_id(to); + assert_eq!(from_post.balance, initial_balance - amount); + assert_eq!(to_post, expected_to_post); +} + +#[test] +fn claiming_mechanism_within_chain_call() { + // This test calls the authenticated transfer program through the chain_caller program. + // The transfer is made from an initialized sender to an uninitialized recipient. And + // it is expected that the recipient account is claimed by the authenticated transfer + // program and not the chained_caller program. + let chain_caller = crate::test_methods::chain_caller(); + let simple_transfer = crate::test_methods::simple_balance_transfer(); + let from_key = PrivateKey::try_new([1; 32]).unwrap(); + let from = AccountId::from(&PublicKey::new_from_private_key(&from_key)); + let initial_balance = 100; + let initial_data = [(from, initial_balance)]; + let mut state = V03State::new() + .with_public_accounts(public_state_from_balances(&initial_data)) + .with_test_programs(); + let to_key = PrivateKey::try_new([2; 32]).unwrap(); + let to = AccountId::from(&PublicKey::new_from_private_key(&to_key)); + let amount: u128 = 37; + + // Check the recipient is an uninitialized account + assert_eq!(state.get_account_by_id(to), Account::default()); + + let expected_to_post = Account { + // The expected program owner is the authenticated transfer program + program_owner: simple_transfer.id(), + balance: amount, + nonce: Nonce(1), + ..Account::default() + }; + + // The transaction executes the chain_caller program, which internally calls the + // authenticated_transfer program + let instruction: (u128, ProgramId, u32, Option) = ( + amount, + crate::test_methods::simple_balance_transfer().id(), + 1, + None, + ); + let message = public_transaction::Message::try_new( + chain_caller.id(), + vec![to, from], // The chain_caller program permutes the account order in the chain + // call + vec![Nonce(0), Nonce(0)], + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&from_key, &to_key]); + let tx = PublicTransaction::new(message, witness_set); + + state.transition_from_public_transaction(&tx, 1, 0).unwrap(); + + let from_post = state.get_account_by_id(from); + let to_post = state.get_account_by_id(to); + assert_eq!(from_post.balance, initial_balance - amount); + assert_eq!(to_post, expected_to_post); +} + +#[test] +fn unauthorized_public_account_claiming_fails_when_executed_privately() { + let program = crate::test_methods::simple_balance_transfer(); + let account_id = AccountId::new([11; 32]); + let public_account = AccountWithMetadata::new(Account::default(), false, account_id); + + let result = execute_and_prove( + vec![public_account], + Program::serialize_instruction(0_u128).unwrap(), + vec![InputAccountIdentity::Public], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn authorized_public_account_claiming_succeeds_when_executed_privately() { + let program = crate::test_methods::simple_balance_transfer(); + let program_id = program.id(); + let sender_keys = test_private_account_keys_1(); + let sender_private_account = Account { + program_owner: program_id, + balance: 100, + ..Account::default() + }; + let sender_account_id = + AccountId::for_regular_private_account(&sender_keys.npk(), &sender_keys.vpk(), 0); + let sender_commitment = Commitment::new(&sender_account_id, &sender_private_account); + let sender_init_nullifier = Nullifier::for_account_initialization(&sender_account_id); + let mut state = + V03State::new().with_private_accounts([(sender_commitment, sender_init_nullifier)]); + let sender_pre = AccountWithMetadata::new( + sender_private_account, + true, + (&sender_keys.npk(), &sender_keys.vpk(), 0), + ); + let recipient_private_key = PrivateKey::try_new([2; 32]).unwrap(); + let recipient_account_id = + AccountId::from(&PublicKey::new_from_private_key(&recipient_private_key)); + let recipient_pre = AccountWithMetadata::new(Account::default(), true, recipient_account_id); + + let balance = 37; + + let (output, proof) = execute_and_prove( + vec![sender_pre, recipient_pre], + Program::serialize_instruction(balance).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: sender_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: sender_keys.nsk, + membership_proof: state + .get_proof_for_commitment(&sender_commitment) + .expect("sender's commitment must be in state"), + identifier: 0, + }, + InputAccountIdentity::Public, + ], + &program.into(), + ) + .unwrap(); + + let message = + Message::try_from_circuit_output(vec![recipient_account_id], vec![Nonce(0)], output) + .unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[&recipient_private_key]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + + state + .transition_from_privacy_preserving_transaction(&tx, 1, 0) + .unwrap(); + + let nullifier = Nullifier::for_account_update(&sender_commitment, &sender_keys.nsk); + assert!(state.private_state.1.contains(&nullifier)); + + assert_eq!( + state.get_account_by_id(recipient_account_id), + Account { + program_owner: program_id, + balance, + nonce: Nonce(1), + ..Account::default() + } + ); +} + +#[test_case::test_case(1; "single call")] +#[test_case::test_case(2; "two calls")] +fn private_chained_call(number_of_calls: u32) { + // Arrange + let chain_caller = crate::test_methods::chain_caller(); + let simple_transfers = crate::test_methods::simple_balance_transfer(); + let from_keys = test_private_account_keys_1(); + let to_keys = test_private_account_keys_2(); + let initial_balance = 100; + let from_account = AccountWithMetadata::new( + Account { + program_owner: simple_transfers.id(), + balance: initial_balance, + ..Account::default() + }, + true, + (&from_keys.npk(), &from_keys.vpk(), 0), + ); + let to_account = AccountWithMetadata::new( + Account { + program_owner: simple_transfers.id(), + ..Account::default() + }, + true, + (&to_keys.npk(), &to_keys.vpk(), 0), + ); + + let from_account_id = + AccountId::for_regular_private_account(&from_keys.npk(), &from_keys.vpk(), 0); + let to_account_id = AccountId::for_regular_private_account(&to_keys.npk(), &to_keys.vpk(), 0); + let from_commitment = Commitment::new(&from_account_id, &from_account.account); + let to_commitment = Commitment::new(&to_account_id, &to_account.account); + let from_init_nullifier = Nullifier::for_account_initialization(&from_account_id); + let to_init_nullifier = Nullifier::for_account_initialization(&to_account_id); + let mut state = V03State::new() + .with_private_accounts([ + (from_commitment, from_init_nullifier), + (to_commitment, to_init_nullifier), + ]) + .with_test_programs(); + let amount: u128 = 37; + let instruction: (u128, ProgramId, u32, Option) = ( + amount, + crate::test_methods::simple_balance_transfer().id(), + number_of_calls, + None, + ); + + let mut dependencies = HashMap::new(); + + dependencies.insert(simple_transfers.id(), simple_transfers); + let program_with_deps = ProgramWithDependencies::new(chain_caller, dependencies); + + let from_new_nonce = Nonce::default().private_account_nonce_increment(&from_keys.nsk); + let to_new_nonce = Nonce::default().private_account_nonce_increment(&to_keys.nsk); + + let from_expected_post = Account { + balance: initial_balance - u128::from(number_of_calls) * amount, + nonce: from_new_nonce, + ..from_account.account.clone() + }; + let from_expected_commitment = Commitment::new(&from_account_id, &from_expected_post); + + let to_expected_post = Account { + balance: u128::from(number_of_calls) * amount, + nonce: to_new_nonce, + ..to_account.account.clone() + }; + let to_expected_commitment = Commitment::new(&to_account_id, &to_expected_post); + + // Act + let (output, proof) = execute_and_prove( + vec![to_account, from_account], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: from_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: from_keys.nsk, + membership_proof: state + .get_proof_for_commitment(&from_commitment) + .expect("from's commitment must be in state"), + identifier: 0, + }, + InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: to_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: to_keys.nsk, + membership_proof: state + .get_proof_for_commitment(&to_commitment) + .expect("to's commitment must be in state"), + identifier: 0, + }, + ], + &program_with_deps, + ) + .unwrap(); + + let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[]); + let transaction = PrivacyPreservingTransaction::new(message, witness_set); + + state + .transition_from_privacy_preserving_transaction(&transaction, 1, 0) + .unwrap(); + + // Assert + assert!( + state + .get_proof_for_commitment(&from_expected_commitment) + .is_some() + ); + assert!( + state + .get_proof_for_commitment(&to_expected_commitment) + .is_some() + ); +} + +#[test] +fn claiming_mechanism_cannot_claim_initialied_accounts() { + let claimer = crate::test_methods::claimer(); + let mut state = V03State::new().with_test_programs(); + let account_id = AccountId::new([2; 32]); + + // Insert an account with non-default program owner + state.force_insert_account( + account_id, + Account { + program_owner: [1, 2, 3, 4, 5, 6, 7, 8], + ..Account::default() + }, + ); + + let message = + public_transaction::Message::try_new(claimer.id(), vec![account_id], vec![], ()).unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + + assert!(matches!( + result, + Err(LeeError::InvalidProgramBehavior( + InvalidProgramBehaviorError::ClaimedNonDefaultAccount { account_id: err_account_id } + )) if err_account_id == account_id + )); +} + +/// This test ensures that even if a malicious program tries to perform overflow of balances +/// it will not be able to break the balance validation. +#[test] +fn malicious_program_cannot_break_balance_validation_if_not_in_genesis() { + let sender_key = PrivateKey::try_new([37; 32]).unwrap(); + let sender_id = AccountId::from(&PublicKey::new_from_private_key(&sender_key)); + let sender_init_balance: u128 = 10; + + let recipient_key = PrivateKey::try_new([42; 32]).unwrap(); + let recipient_id = AccountId::from(&PublicKey::new_from_private_key(&recipient_key)); + let recipient_init_balance: u128 = 10; + + let modified_transfer_id = crate::test_methods::modified_transfer_program().id(); + + let mut state = V03State::new() + .with_public_accounts([ + ( + sender_id, + Account { + program_owner: modified_transfer_id, + balance: sender_init_balance, + ..Account::default() + }, + ), + ( + recipient_id, + Account { + program_owner: modified_transfer_id, + balance: recipient_init_balance, + ..Account::default() + }, + ), + ]) + .with_test_programs(); + + let balance_to_move: u128 = 4; + + let sender = AccountWithMetadata::new(state.get_account_by_id(sender_id), true, sender_id); + + let sender_nonce = sender.account.nonce; + + let _recipient = + AccountWithMetadata::new(state.get_account_by_id(recipient_id), false, sender_id); + + let message = public_transaction::Message::try_new( + modified_transfer_id, + vec![sender_id, recipient_id], + vec![sender_nonce], + balance_to_move, + ) + .unwrap(); + + let witness_set = public_transaction::WitnessSet::for_message(&message, &[&sender_key]); + let tx = PublicTransaction::new(message, witness_set); + let res = state.transition_from_public_transaction(&tx, 2, 0); + let expected_total_balance_pre_states = + WrappedBalanceSum::from_balances([sender_init_balance, recipient_init_balance].into_iter()) + .unwrap(); + let expected_total_balance_post_states = WrappedBalanceSum::from_balances( + [sender_init_balance, recipient_init_balance, u128::MAX, 1].into_iter(), + ) + .unwrap(); + assert!(matches!( + res, + Err(LeeError::InvalidProgramBehavior( + InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states } + ) + )) if total_balance_pre_states == expected_total_balance_pre_states && total_balance_post_states == expected_total_balance_post_states + )); + + let sender_post = state.get_account_by_id(sender_id); + let recipient_post = state.get_account_by_id(recipient_id); + + let expected_sender_post = { + let mut this = state.get_account_by_id(sender_id); + this.balance = sender_init_balance; + this.nonce = Nonce(0); + this + }; + + let expected_recipient_post = { + let mut this = state.get_account_by_id(sender_id); + this.balance = recipient_init_balance; + this.nonce = Nonce(0); + this + }; + + assert_eq!(expected_sender_post, sender_post); + assert_eq!(expected_recipient_post, recipient_post); +} diff --git a/lee/state_machine/src/state/tests/flash_swap.rs b/lee/state_machine/src/state/tests/flash_swap.rs new file mode 100644 index 00000000..be8f1c10 --- /dev/null +++ b/lee/state_machine/src/state/tests/flash_swap.rs @@ -0,0 +1,235 @@ +use super::*; + +#[test] +fn flash_swap_successful() { + let initiator = crate::test_methods::flash_swap_initiator(); + let callback = crate::test_methods::flash_swap_callback(); + let token = crate::test_methods::simple_balance_transfer(); + + let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32])); + let receiver_id = AccountId::for_public_pda(&callback.id(), &PdaSeed::new([1_u8; 32])); + + let initial_balance: u128 = 1000; + let amount_out: u128 = 100; + + let vault_account = Account { + program_owner: token.id(), + balance: initial_balance, + ..Account::default() + }; + let receiver_account = Account { + program_owner: token.id(), + balance: 0, + ..Account::default() + }; + + let mut state = V03State::new().with_test_programs(); + state.force_insert_account(vault_id, vault_account); + state.force_insert_account(receiver_id, receiver_account); + + // Callback instruction: return funds + let cb_instruction = CallbackInstruction { + return_funds: true, + token_program_id: token.id(), + amount: amount_out, + }; + let cb_data = Program::serialize_instruction(cb_instruction).unwrap(); + + let instruction = FlashSwapInstruction::Initiate { + token_program_id: token.id(), + callback_program_id: callback.id(), + amount_out, + callback_instruction_data: cb_data, + }; + + let tx = build_flash_swap_tx(&initiator, vault_id, receiver_id, instruction); + let result = state.transition_from_public_transaction(&tx, 1, 0); + assert!(result.is_ok(), "flash swap should succeed: {result:?}"); + + // Vault balance restored, receiver back to 0 + assert_eq!(state.get_account_by_id(vault_id).balance, initial_balance); + assert_eq!(state.get_account_by_id(receiver_id).balance, 0); +} + +#[test] +fn flash_swap_callback_keeps_funds_rollback() { + let initiator = crate::test_methods::flash_swap_initiator(); + let callback = crate::test_methods::flash_swap_callback(); + let token = crate::test_methods::simple_balance_transfer(); + + let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32])); + let receiver_id = AccountId::for_public_pda(&callback.id(), &PdaSeed::new([1_u8; 32])); + + let initial_balance: u128 = 1000; + let amount_out: u128 = 100; + + let vault_account = Account { + program_owner: token.id(), + balance: initial_balance, + ..Account::default() + }; + let receiver_account = Account { + program_owner: token.id(), + balance: 0, + ..Account::default() + }; + + let mut state = V03State::new().with_test_programs(); + state.force_insert_account(vault_id, vault_account); + state.force_insert_account(receiver_id, receiver_account); + + // Callback instruction: do NOT return funds + let cb_instruction = CallbackInstruction { + return_funds: false, + token_program_id: token.id(), + amount: amount_out, + }; + let cb_data = Program::serialize_instruction(cb_instruction).unwrap(); + + let instruction = FlashSwapInstruction::Initiate { + token_program_id: token.id(), + callback_program_id: callback.id(), + amount_out, + callback_instruction_data: cb_data, + }; + + let tx = build_flash_swap_tx(&initiator, vault_id, receiver_id, instruction); + let result = state.transition_from_public_transaction(&tx, 1, 0); + + // Invariant check fails → entire tx rolls back + assert!( + result.is_err(), + "flash swap should fail when callback keeps funds" + ); + + // State unchanged (rollback) + assert_eq!(state.get_account_by_id(vault_id).balance, initial_balance); + assert_eq!(state.get_account_by_id(receiver_id).balance, 0); +} + +#[test] +fn flash_swap_self_call_targets_correct_program() { + // Zero-amount flash swap: the invariant self-call still runs and succeeds + // because vault balance doesn't decrease. + let initiator = crate::test_methods::flash_swap_initiator(); + let callback = crate::test_methods::flash_swap_callback(); + let token = crate::test_methods::simple_balance_transfer(); + + let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32])); + let receiver_id = AccountId::for_public_pda(&callback.id(), &PdaSeed::new([1_u8; 32])); + + let initial_balance: u128 = 1000; + + let vault_account = Account { + program_owner: token.id(), + balance: initial_balance, + ..Account::default() + }; + let receiver_account = Account { + program_owner: token.id(), + balance: 0, + ..Account::default() + }; + + let mut state = V03State::new().with_test_programs(); + state.force_insert_account(vault_id, vault_account); + state.force_insert_account(receiver_id, receiver_account); + + let cb_instruction = CallbackInstruction { + return_funds: true, + token_program_id: token.id(), + amount: 0, + }; + let cb_data = Program::serialize_instruction(cb_instruction).unwrap(); + + let instruction = FlashSwapInstruction::Initiate { + token_program_id: token.id(), + callback_program_id: callback.id(), + amount_out: 0, + callback_instruction_data: cb_data, + }; + + let tx = build_flash_swap_tx(&initiator, vault_id, receiver_id, instruction); + let result = state.transition_from_public_transaction(&tx, 1, 0); + assert!( + result.is_ok(), + "zero-amount flash swap should succeed: {result:?}" + ); +} + +#[test] +fn flash_swap_standalone_invariant_check_rejected() { + // Calling InvariantCheck directly (not as a chained self-call) should fail + // because caller_program_id will be None. + let initiator = crate::test_methods::flash_swap_initiator(); + let token = crate::test_methods::simple_balance_transfer(); + + let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32])); + + let vault_account = Account { + program_owner: token.id(), + balance: 1000, + ..Account::default() + }; + + let mut state = V03State::new().with_test_programs(); + state.force_insert_account(vault_id, vault_account); + + let instruction = FlashSwapInstruction::InvariantCheck { + min_vault_balance: 1000, + }; + + let message = + public_transaction::Message::try_new(initiator.id(), vec![vault_id], vec![], instruction) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + assert!( + result.is_err(), + "standalone InvariantCheck should be rejected (caller_program_id is None)" + ); +} + +#[test] +fn malicious_self_program_id_rejected_in_public_execution() { + let program = crate::test_methods::malicious_self_program_id(); + let acc_id = AccountId::new([99; 32]); + let account = Account::default(); + + let mut state = V03State::new().with_test_programs(); + state.force_insert_account(acc_id, account); + + let message = + public_transaction::Message::try_new(program.id(), vec![acc_id], vec![], ()).unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + assert!( + result.is_err(), + "program with wrong self_program_id in output should be rejected" + ); +} + +#[test] +fn malicious_caller_program_id_rejected_in_public_execution() { + let program = crate::test_methods::malicious_caller_program_id(); + let acc_id = AccountId::new([99; 32]); + let account = Account::default(); + + let mut state = V03State::new().with_test_programs(); + state.force_insert_account(acc_id, account); + + let message = + public_transaction::Message::try_new(program.id(), vec![acc_id], vec![], ()).unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + assert!( + result.is_err(), + "program with spoofed caller_program_id in output should be rejected" + ); +} diff --git a/lee/state_machine/src/state/tests/genesis.rs b/lee/state_machine/src/state/tests/genesis.rs new file mode 100644 index 00000000..f67628ee --- /dev/null +++ b/lee/state_machine/src/state/tests/genesis.rs @@ -0,0 +1,128 @@ +use super::*; + +#[test] +fn new_works() { + let key1 = PrivateKey::try_new([1; 32]).unwrap(); + let key2 = PrivateKey::try_new([2; 32]).unwrap(); + let addr1 = AccountId::from(&PublicKey::new_from_private_key(&key1)); + let addr2 = AccountId::from(&PublicKey::new_from_private_key(&key2)); + let expected_public_state = { + let mut this = HashMap::new(); + this.insert( + addr1, + Account { + balance: 100, + ..Account::default() + }, + ); + this.insert( + addr2, + Account { + balance: 151, + ..Account::default() + }, + ); + this + }; + let expected_builtin_programs = HashMap::new(); + + let state = + V03State::new().with_public_account_balances([(addr1, 100_u128), (addr2, 151_u128)]); + + assert_eq!(state.public_state, expected_public_state); + assert_eq!(state.programs, expected_builtin_programs); +} + +#[test] +fn new_includes_nullifiers_for_private_accounts() { + let keys1 = test_private_account_keys_1(); + let keys2 = test_private_account_keys_2(); + + let account = Account { + balance: 100, + ..Account::default() + }; + + let account_id1 = AccountId::for_regular_private_account(&keys1.npk(), &keys1.vpk(), 0); + let account_id2 = AccountId::for_regular_private_account(&keys2.npk(), &keys2.vpk(), 0); + + let init_commitment1 = Commitment::new(&account_id1, &account); + let init_commitment2 = Commitment::new(&account_id2, &account); + let init_nullifier1 = Nullifier::for_account_initialization(&account_id1); + let init_nullifier2 = Nullifier::for_account_initialization(&account_id2); + + let initial_private_accounts = vec![ + (init_commitment1, init_nullifier1), + (init_commitment2, init_nullifier2), + ]; + + let state = V03State::new().with_private_accounts(initial_private_accounts); + + assert!(state.private_state.1.contains(&init_nullifier1)); + assert!(state.private_state.1.contains(&init_nullifier2)); +} + +#[test] +fn insert_program() { + let mut state = V03State::new(); + let program_to_insert = crate::test_methods::simple_balance_transfer(); + let program_id = program_to_insert.id(); + assert!(!state.programs.contains_key(&program_id)); + + state.insert_program(program_to_insert); + + assert!(state.programs.contains_key(&program_id)); +} + +#[test] +fn get_account_by_account_id_non_default_account() { + let key = PrivateKey::try_new([1; 32]).unwrap(); + let account_id = AccountId::from(&PublicKey::new_from_private_key(&key)); + let initial_data = [( + account_id, + Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance: 100, + ..Account::default() + }, + )]; + let state = V03State::new().with_public_accounts(initial_data); + let expected_account = &state.public_state[&account_id]; + + let account = state.get_account_by_id(account_id); + + assert_eq!(&account, expected_account); +} + +#[test] +fn get_account_by_account_id_default_account() { + let addr2 = AccountId::new([0; 32]); + let state = V03State::new(); + let expected_account = Account::default(); + + let account = state.get_account_by_id(addr2); + + assert_eq!(account, expected_account); +} + +#[test] +fn builtin_programs_getter() { + let state = V03State::new(); + + let builtin_programs = state.programs(); + + assert_eq!(builtin_programs, &state.programs); +} + +#[test] +fn state_serialization_roundtrip() { + let account_id_1 = AccountId::new([1; 32]); + let account_id_2 = AccountId::new([2; 32]); + let initial_data = [(account_id_1, 100_u128), (account_id_2, 151_u128)]; + let state = V03State::new() + .with_public_accounts(public_state_from_balances(&initial_data)) + .with_test_programs(); + let bytes = borsh::to_vec(&state).unwrap(); + let state_from_bytes: V03State = borsh::from_slice(&bytes).unwrap(); + assert_eq!(state, state_from_bytes); +} diff --git a/lee/state_machine/src/state/tests/mod.rs b/lee/state_machine/src/state/tests/mod.rs new file mode 100644 index 00000000..399d268f --- /dev/null +++ b/lee/state_machine/src/state/tests/mod.rs @@ -0,0 +1,428 @@ +#![expect( + clippy::arithmetic_side_effects, + clippy::shadow_unrelated, + reason = "We don't care about it in tests" +)] + +use std::collections::HashMap; + +use lee_core::{ + BlockId, Commitment, DUMMY_COMMITMENT_HASH, InputAccountIdentity, Nullifier, + NullifierPublicKey, NullifierSecretKey, Timestamp, + account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data}, + encryption::ViewingPublicKey, + program::{ + BlockValidityWindow, ExecutionValidationError, MAX_NUMBER_CHAINED_CALLS, PdaSeed, + ProgramId, TimestampValidityWindow, WrappedBalanceSum, + }, +}; + +use crate::{ + PublicKey, PublicTransaction, V03State, + error::{InvalidProgramBehaviorError, LeeError}, + execute_and_prove, + privacy_preserving_transaction::{ + PrivacyPreservingTransaction, circuit::ProgramWithDependencies, message::Message, + witness_set::WitnessSet, + }, + program::Program, + public_transaction, + signature::PrivateKey, +}; + +mod authenticated_transfer; +mod changer_claimer; +mod circuit; +mod claiming; +mod flash_swap; +mod genesis; +mod privacy_preserving; +mod public_program_rules; +mod validity_window; + +impl V03State { + /// Include test programs in the builtin programs map. + #[must_use] + pub fn with_test_programs(mut self) -> Self { + self.insert_program(crate::test_methods::simple_balance_transfer()); + self.insert_program(crate::test_methods::nonce_changer()); + self.insert_program(crate::test_methods::extra_output()); + self.insert_program(crate::test_methods::missing_output()); + self.insert_program(crate::test_methods::dropped_account()); + self.insert_program(crate::test_methods::program_owner_changer()); + self.insert_program(crate::test_methods::data_changer()); + self.insert_program(crate::test_methods::minter()); + self.insert_program(crate::test_methods::burner()); + self.insert_program(crate::test_methods::auth_asserting_noop()); + self.insert_program(crate::test_methods::private_pda_delegator()); + self.insert_program(crate::test_methods::pda_claimer()); + self.insert_program(crate::test_methods::two_pda_claimer()); + self.insert_program(crate::test_methods::noop()); + self.insert_program(crate::test_methods::chain_caller()); + self.insert_program(crate::test_methods::modified_transfer_program()); + self.insert_program(crate::test_methods::malicious_authorization_changer()); + self.insert_program(crate::test_methods::validity_window()); + self.insert_program(crate::test_methods::flash_swap_initiator()); + self.insert_program(crate::test_methods::flash_swap_callback()); + self.insert_program(crate::test_methods::malicious_self_program_id()); + self.insert_program(crate::test_methods::malicious_caller_program_id()); + self.insert_program(crate::test_methods::pda_spend_proxy()); + self.insert_program(crate::test_methods::claimer()); + self.insert_program(crate::test_methods::changer_claimer()); + self.insert_program(crate::test_methods::validity_window_chain_caller()); + self.insert_program(crate::test_methods::simple_transfer_proxy()); + self.insert_program(crate::test_methods::malicious_injector()); + self.insert_program(crate::test_methods::malicious_launderer()); + self.insert_program(crate::test_methods::modified_transfer_program()); + self + } + + #[must_use] + pub fn with_non_default_accounts_but_default_program_owners(mut self) -> Self { + let account_with_default_values_except_balance = Account { + balance: 100, + ..Account::default() + }; + let account_with_default_values_except_nonce = Account { + nonce: Nonce(37), + ..Account::default() + }; + let account_with_default_values_except_data = Account { + data: vec![0xca, 0xfe].try_into().unwrap(), + ..Account::default() + }; + self.force_insert_account( + AccountId::new([255; 32]), + account_with_default_values_except_balance, + ); + self.force_insert_account( + AccountId::new([254; 32]), + account_with_default_values_except_nonce, + ); + self.force_insert_account( + AccountId::new([253; 32]), + account_with_default_values_except_data, + ); + self + } + + #[must_use] + pub fn with_account_owned_by_burner_program(mut self) -> Self { + let account = Account { + program_owner: crate::test_methods::burner().id(), + balance: 100, + ..Default::default() + }; + self.force_insert_account(AccountId::new([252; 32]), account); + self + } + + #[must_use] + pub fn with_private_account(mut self, keys: &TestPrivateKeys, account: &Account) -> Self { + let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), 0); + let commitment = Commitment::new(&account_id, account); + self.private_state.0.extend(&[commitment]); + self + } +} + +pub struct TestPublicKeys { + pub signing_key: PrivateKey, +} + +impl TestPublicKeys { + pub fn account_id(&self) -> AccountId { + AccountId::from(&PublicKey::new_from_private_key(&self.signing_key)) + } +} + +pub struct TestPrivateKeys { + pub nsk: NullifierSecretKey, + pub d: [u8; 32], + pub z: [u8; 32], +} + +impl TestPrivateKeys { + pub fn npk(&self) -> NullifierPublicKey { + NullifierPublicKey::from(&self.nsk) + } + + pub fn vpk(&self) -> ViewingPublicKey { + ViewingPublicKey::from_seed(&self.d, &self.z) + } +} + +// ── Flash Swap types (mirrors of guest types for host-side serialisation) ── + +#[derive(serde::Serialize, serde::Deserialize)] +struct CallbackInstruction { + return_funds: bool, + token_program_id: ProgramId, + amount: u128, +} + +#[derive(serde::Serialize, serde::Deserialize)] +enum FlashSwapInstruction { + Initiate { + token_program_id: ProgramId, + callback_program_id: ProgramId, + amount_out: u128, + callback_instruction_data: Vec, + }, + InvariantCheck { + min_vault_balance: u128, + }, +} + +fn public_state_from_balances(initial_data: &[(AccountId, u128)]) -> HashMap { + initial_data + .iter() + .copied() + .map(|(account_id, balance)| { + ( + account_id, + Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance, + ..Account::default() + }, + ) + }) + .collect() +} + +fn transfer_transaction( + from: AccountId, + from_key: &PrivateKey, + from_nonce: u128, + to: AccountId, + to_key: &PrivateKey, + to_nonce: u128, + balance: u128, +) -> PublicTransaction { + let account_ids = vec![from, to]; + let nonces = vec![Nonce(from_nonce), Nonce(to_nonce)]; + let program_id = crate::test_methods::simple_balance_transfer().id(); + let message = + public_transaction::Message::try_new(program_id, account_ids, nonces, balance).unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[from_key, to_key]); + PublicTransaction::new(message, witness_set) +} + +fn build_flash_swap_tx( + initiator: &Program, + vault_id: AccountId, + receiver_id: AccountId, + instruction: FlashSwapInstruction, +) -> PublicTransaction { + let message = public_transaction::Message::try_new( + initiator.id(), + vec![vault_id, receiver_id], + vec![], // no signers — vault is PDA-authorised + instruction, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + PublicTransaction::new(message, witness_set) +} + +fn test_public_account_keys_1() -> TestPublicKeys { + TestPublicKeys { + signing_key: PrivateKey::try_new([37; 32]).unwrap(), + } +} + +fn test_public_account_keys_2() -> TestPublicKeys { + TestPublicKeys { + signing_key: PrivateKey::try_new([38; 32]).unwrap(), + } +} + +pub fn test_private_account_keys_1() -> TestPrivateKeys { + TestPrivateKeys { + nsk: [13; 32], + d: [31; 32], + z: [32; 32], + } +} + +pub fn test_private_account_keys_2() -> TestPrivateKeys { + TestPrivateKeys { + nsk: [38; 32], + d: [83; 32], + z: [84; 32], + } +} + +fn shielded_balance_transfer_for_tests( + sender_keys: &TestPublicKeys, + recipient_keys: &TestPrivateKeys, + balance_to_move: u128, + state: &V03State, +) -> PrivacyPreservingTransaction { + let sender = AccountWithMetadata::new( + state.get_account_by_id(sender_keys.account_id()), + true, + sender_keys.account_id(), + ); + + let sender_nonce = sender.account.nonce; + + let recipient = AccountWithMetadata::new( + Account::default(), + true, + (&recipient_keys.npk(), &recipient_keys.vpk(), 0), + ); + + let (output, proof) = crate::privacy_preserving_transaction::circuit::execute_and_prove( + vec![sender, recipient], + Program::serialize_instruction(balance_to_move).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateForeignInit { + vpk: recipient_keys.vpk(), + random_seed: [0; 32], + npk: recipient_keys.npk(), + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + }, + ], + &crate::test_methods::simple_balance_transfer().into(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output( + vec![sender_keys.account_id()], + vec![sender_nonce], + output, + ) + .unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[&sender_keys.signing_key]); + PrivacyPreservingTransaction::new(message, witness_set) +} + +fn private_balance_transfer_for_tests( + sender_keys: &TestPrivateKeys, + sender_private_account: &Account, + recipient_keys: &TestPrivateKeys, + balance_to_move: u128, + state: &V03State, +) -> PrivacyPreservingTransaction { + let program = crate::test_methods::simple_balance_transfer(); + let sender_account_id = + AccountId::for_regular_private_account(&sender_keys.npk(), &sender_keys.vpk(), 0); + let sender_commitment = Commitment::new(&sender_account_id, sender_private_account); + let sender_pre = AccountWithMetadata::new( + sender_private_account.clone(), + true, + (&sender_keys.npk(), &sender_keys.vpk(), 0), + ); + let recipient_pre = AccountWithMetadata::new( + Account::default(), + true, + (&recipient_keys.npk(), &recipient_keys.vpk(), 0), + ); + + let (output, proof) = crate::privacy_preserving_transaction::circuit::execute_and_prove( + vec![sender_pre, recipient_pre], + Program::serialize_instruction(balance_to_move).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: sender_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: sender_keys.nsk, + membership_proof: state + .get_proof_for_commitment(&sender_commitment) + .expect("sender's commitment must be in state"), + identifier: 0, + }, + InputAccountIdentity::PrivateForeignInit { + vpk: recipient_keys.vpk(), + random_seed: [0; 32], + npk: recipient_keys.npk(), + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + }, + ], + &program.into(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[]); + + PrivacyPreservingTransaction::new(message, witness_set) +} + +fn deshielded_balance_transfer_for_tests( + sender_keys: &TestPrivateKeys, + sender_private_account: &Account, + recipient_account_id: &AccountId, + balance_to_move: u128, + state: &V03State, +) -> PrivacyPreservingTransaction { + let program = crate::test_methods::simple_balance_transfer(); + let sender_account_id = + AccountId::for_regular_private_account(&sender_keys.npk(), &sender_keys.vpk(), 0); + let sender_commitment = Commitment::new(&sender_account_id, sender_private_account); + let sender_pre = AccountWithMetadata::new( + sender_private_account.clone(), + true, + (&sender_keys.npk(), &sender_keys.vpk(), 0), + ); + let recipient_pre = AccountWithMetadata::new( + state.get_account_by_id(*recipient_account_id), + false, + *recipient_account_id, + ); + + let (output, proof) = crate::privacy_preserving_transaction::circuit::execute_and_prove( + vec![sender_pre, recipient_pre], + Program::serialize_instruction(balance_to_move).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: sender_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: sender_keys.nsk, + membership_proof: state + .get_proof_for_commitment(&sender_commitment) + .expect("sender's commitment must be in state"), + identifier: 0, + }, + InputAccountIdentity::Public, + ], + &program.into(), + ) + .unwrap(); + + let message = + Message::try_from_circuit_output(vec![*recipient_account_id], vec![], output).unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[]); + + PrivacyPreservingTransaction::new(message, witness_set) +} + +fn valid_private_transfer_tx_and_state() -> (V03State, PrivacyPreservingTransaction) { + let sender_keys = test_private_account_keys_1(); + let sender_private_account = Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance: 100, + nonce: Nonce(0xdead_beef), + ..Account::default() + }; + let recipient_keys = test_private_account_keys_2(); + let state = V03State::new().with_private_account(&sender_keys, &sender_private_account); + let tx = private_balance_transfer_for_tests( + &sender_keys, + &sender_private_account, + &recipient_keys, + 37, + &state, + ); + (state, tx) +} diff --git a/lee/state_machine/src/state/tests/privacy_preserving.rs b/lee/state_machine/src/state/tests/privacy_preserving.rs new file mode 100644 index 00000000..bc51ce4f --- /dev/null +++ b/lee/state_machine/src/state/tests/privacy_preserving.rs @@ -0,0 +1,540 @@ +use super::*; + +#[test] +fn transition_from_privacy_preserving_transaction_shielded() { + let sender_keys = test_public_account_keys_1(); + let recipient_keys = test_private_account_keys_1(); + + let mut state = V03State::new().with_public_accounts([( + sender_keys.account_id(), + Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance: 200, + ..Account::default() + }, + )]); + + let balance_to_move = 37; + + let tx = + shielded_balance_transfer_for_tests(&sender_keys, &recipient_keys, balance_to_move, &state); + + let expected_sender_post = { + let mut this = state.get_account_by_id(sender_keys.account_id()); + this.balance -= balance_to_move; + this.nonce.public_account_nonce_increment(); + this + }; + + let [expected_new_commitment] = tx.message().new_commitments.clone().try_into().unwrap(); + assert!(!state.private_state.0.contains(&expected_new_commitment)); + + state + .transition_from_privacy_preserving_transaction(&tx, 1, 0) + .unwrap(); + + let sender_post = state.get_account_by_id(sender_keys.account_id()); + assert_eq!(sender_post, expected_sender_post); + assert!(state.private_state.0.contains(&expected_new_commitment)); + + assert_eq!( + state.get_account_by_id(sender_keys.account_id()).balance, + 200 - balance_to_move + ); +} + +#[test] +fn transition_from_privacy_preserving_transaction_private() { + let sender_keys = test_private_account_keys_1(); + let sender_nonce = Nonce(0xdead_beef); + + let sender_private_account = Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance: 100, + nonce: sender_nonce, + data: Data::default(), + }; + let recipient_keys = test_private_account_keys_2(); + + let mut state = V03State::new().with_private_account(&sender_keys, &sender_private_account); + + let balance_to_move = 37; + + let tx = private_balance_transfer_for_tests( + &sender_keys, + &sender_private_account, + &recipient_keys, + balance_to_move, + &state, + ); + + let sender_account_id = + AccountId::for_regular_private_account(&sender_keys.npk(), &sender_keys.vpk(), 0); + let recipient_account_id = + AccountId::for_regular_private_account(&recipient_keys.npk(), &recipient_keys.vpk(), 0); + let expected_new_commitment_1 = Commitment::new( + &sender_account_id, + &Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk), + balance: sender_private_account.balance - balance_to_move, + data: Data::default(), + }, + ); + + let sender_pre_commitment = Commitment::new(&sender_account_id, &sender_private_account); + let expected_new_nullifier = + Nullifier::for_account_update(&sender_pre_commitment, &sender_keys.nsk); + + let expected_new_commitment_2 = Commitment::new( + &recipient_account_id, + &Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + nonce: Nonce::private_account_nonce_init(&recipient_account_id), + balance: balance_to_move, + ..Account::default() + }, + ); + + let previous_public_state = state.public_state.clone(); + assert!(state.private_state.0.contains(&sender_pre_commitment)); + assert!(!state.private_state.0.contains(&expected_new_commitment_1)); + assert!(!state.private_state.0.contains(&expected_new_commitment_2)); + assert!(!state.private_state.1.contains(&expected_new_nullifier)); + + state + .transition_from_privacy_preserving_transaction(&tx, 1, 0) + .unwrap(); + + assert_eq!(state.public_state, previous_public_state); + assert!(state.private_state.0.contains(&sender_pre_commitment)); + assert!(state.private_state.0.contains(&expected_new_commitment_1)); + assert!(state.private_state.0.contains(&expected_new_commitment_2)); + assert!(state.private_state.1.contains(&expected_new_nullifier)); +} + +/// After a valid fully-private tx is proven, tampering with a note's epk should +/// make the shielding proof invalid. +#[test] +fn privacy_tampered_epk_is_rejected() { + use crate::validated_state_diff::ValidatedStateDiff; + + let (state, mut tx) = valid_private_transfer_tx_and_state(); + + // Baseline: the untampered tx verifies + assert!( + ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0).is_ok(), + "the unmodified private transfer must verify" + ); + + // Flip a byte of the first note's epk + tx.message.encrypted_private_post_states[0].epk.0[0] ^= 0xFF; + + assert!( + matches!( + ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0), + Err(LeeError::InvalidPrivacyPreservingProof) + ), + "a tampered epk must be rejected by proof verification" + ); +} + +/// After a valid fully-private tx is proven, tampering with a note's view tag should +/// make the shielding proof invalid. +#[test] +fn privacy_tampered_view_tag_is_rejected() { + use crate::validated_state_diff::ValidatedStateDiff; + + let (state, mut tx) = valid_private_transfer_tx_and_state(); + + // Baseline: the untampered tx verifies. + assert!( + ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0).is_ok(), + "the unmodified private transfer must verify" + ); + + // Flip the first note's view_tag + tx.message.encrypted_private_post_states[0].view_tag ^= 0xFF; + + assert!( + matches!( + ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0), + Err(LeeError::InvalidPrivacyPreservingProof) + ), + "a tampered view_tag must be rejected by proof verification" + ); +} + +#[test] +fn transition_from_privacy_preserving_transaction_deshielded() { + let sender_keys = test_private_account_keys_1(); + let sender_nonce = Nonce(0xdead_beef); + + let sender_private_account = Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance: 100, + nonce: sender_nonce, + data: Data::default(), + }; + let recipient_keys = test_public_account_keys_1(); + let recipient_initial_balance = 400; + let mut state = V03State::new() + .with_public_accounts([( + recipient_keys.account_id(), + Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance: recipient_initial_balance, + ..Account::default() + }, + )]) + .with_private_account(&sender_keys, &sender_private_account); + + let balance_to_move = 37; + + let expected_recipient_post = { + let mut this = state.get_account_by_id(recipient_keys.account_id()); + this.balance += balance_to_move; + this + }; + + let tx = deshielded_balance_transfer_for_tests( + &sender_keys, + &sender_private_account, + &recipient_keys.account_id(), + balance_to_move, + &state, + ); + + let sender_account_id = + AccountId::for_regular_private_account(&sender_keys.npk(), &sender_keys.vpk(), 0); + let expected_new_commitment = Commitment::new( + &sender_account_id, + &Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk), + balance: sender_private_account.balance - balance_to_move, + data: Data::default(), + }, + ); + + let sender_pre_commitment = Commitment::new(&sender_account_id, &sender_private_account); + let expected_new_nullifier = + Nullifier::for_account_update(&sender_pre_commitment, &sender_keys.nsk); + + assert!(state.private_state.0.contains(&sender_pre_commitment)); + assert!(!state.private_state.0.contains(&expected_new_commitment)); + assert!(!state.private_state.1.contains(&expected_new_nullifier)); + + state + .transition_from_privacy_preserving_transaction(&tx, 1, 0) + .unwrap(); + + let recipient_post = state.get_account_by_id(recipient_keys.account_id()); + assert_eq!(recipient_post, expected_recipient_post); + assert!(state.private_state.0.contains(&sender_pre_commitment)); + assert!(state.private_state.0.contains(&expected_new_commitment)); + assert!(state.private_state.1.contains(&expected_new_nullifier)); + assert_eq!( + state.get_account_by_id(recipient_keys.account_id()).balance, + recipient_initial_balance + balance_to_move + ); +} + +#[test] +fn burner_program_should_fail_in_privacy_preserving_circuit() { + let program = crate::test_methods::burner(); + let public_account = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 100, + ..Account::default() + }, + true, + AccountId::new([0; 32]), + ); + + let result = execute_and_prove( + vec![public_account], + Program::serialize_instruction(10_u128).unwrap(), + vec![InputAccountIdentity::Public], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn minter_program_should_fail_in_privacy_preserving_circuit() { + let program = crate::test_methods::minter(); + let public_account = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 0, + ..Account::default() + }, + true, + AccountId::new([0; 32]), + ); + + let result = execute_and_prove( + vec![public_account], + Program::serialize_instruction(10_u128).unwrap(), + vec![InputAccountIdentity::Public], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn nonce_changer_program_should_fail_in_privacy_preserving_circuit() { + let program = crate::test_methods::nonce_changer(); + let public_account = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 0, + ..Account::default() + }, + true, + AccountId::new([0; 32]), + ); + + let result = execute_and_prove( + vec![public_account], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::Public], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn data_changer_program_should_fail_for_non_owned_account_in_privacy_preserving_circuit() { + let program = crate::test_methods::data_changer(); + let public_account = AccountWithMetadata::new( + Account { + program_owner: [0, 1, 2, 3, 4, 5, 6, 7], + balance: 0, + ..Account::default() + }, + true, + AccountId::new([0; 32]), + ); + + let result = execute_and_prove( + vec![public_account], + Program::serialize_instruction(vec![0]).unwrap(), + vec![InputAccountIdentity::Public], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn data_changer_program_should_fail_for_too_large_data_in_privacy_preserving_circuit() { + let program = crate::test_methods::data_changer(); + let public_account = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 0, + ..Account::default() + }, + true, + AccountId::new([0; 32]), + ); + + let large_data: Vec = + vec![ + 0; + usize::try_from(lee_core::account::data::DATA_MAX_LENGTH.as_u64()) + .expect("DATA_MAX_LENGTH fits in usize") + + 1 + ]; + + let result = execute_and_prove( + vec![public_account], + Program::serialize_instruction(large_data).unwrap(), + vec![InputAccountIdentity::Public], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::ProgramProveFailed(_)))); +} + +#[test] +fn extra_output_program_should_fail_in_privacy_preserving_circuit() { + let program = crate::test_methods::extra_output(); + let public_account = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 0, + ..Account::default() + }, + true, + AccountId::new([0; 32]), + ); + + let result = execute_and_prove( + vec![public_account], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::Public], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn missing_output_program_should_fail_in_privacy_preserving_circuit() { + let program = crate::test_methods::missing_output(); + let public_account_1 = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 0, + ..Account::default() + }, + true, + AccountId::new([0; 32]), + ); + let public_account_2 = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 0, + ..Account::default() + }, + true, + AccountId::new([1; 32]), + ); + + let result = execute_and_prove( + vec![public_account_1, public_account_2], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::Public, InputAccountIdentity::Public], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn program_owner_changer_should_fail_in_privacy_preserving_circuit() { + let program = crate::test_methods::program_owner_changer(); + let public_account = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 0, + ..Account::default() + }, + true, + AccountId::new([0; 32]), + ); + + let result = execute_and_prove( + vec![public_account], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::Public], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn transfer_from_non_owned_account_should_fail_in_privacy_preserving_circuit() { + let program = crate::test_methods::simple_balance_transfer(); + let public_account_1 = AccountWithMetadata::new( + Account { + program_owner: [0, 1, 2, 3, 4, 5, 6, 7], + balance: 100, + ..Account::default() + }, + true, + AccountId::new([0; 32]), + ); + let public_account_2 = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 0, + ..Account::default() + }, + true, + AccountId::new([1; 32]), + ); + + let result = execute_and_prove( + vec![public_account_1, public_account_2], + Program::serialize_instruction(10_u128).unwrap(), + vec![InputAccountIdentity::Public, InputAccountIdentity::Public], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn malicious_authorization_changer_should_fail_in_privacy_preserving_circuit() { + // Arrange + let malicious_program = crate::test_methods::malicious_authorization_changer(); + let simple_transfers = crate::test_methods::simple_balance_transfer(); + let sender_keys = test_public_account_keys_1(); + let recipient_keys = test_private_account_keys_1(); + + let sender_account = AccountWithMetadata::new( + Account { + program_owner: simple_transfers.id(), + balance: 100, + ..Default::default() + }, + false, + sender_keys.account_id(), + ); + let recipient_account = AccountWithMetadata::new( + Account::default(), + true, + (&recipient_keys.npk(), &recipient_keys.vpk(), 0), + ); + + let recipient_account_id = + AccountId::for_regular_private_account(&recipient_keys.npk(), &recipient_keys.vpk(), 0); + let recipient_commitment = Commitment::new(&recipient_account_id, &recipient_account.account); + let recipient_init_nullifier = Nullifier::for_account_initialization(&recipient_account_id); + let state = V03State::new() + .with_public_accounts(public_state_from_balances(&[( + sender_account.account_id, + sender_account.account.balance, + )])) + .with_private_accounts([(recipient_commitment, recipient_init_nullifier)]) + .with_test_programs(); + + let balance_to_transfer = 10_u128; + let instruction = (balance_to_transfer, simple_transfers.id()); + + let mut dependencies = HashMap::new(); + dependencies.insert(simple_transfers.id(), simple_transfers); + let program_with_deps = ProgramWithDependencies::new(malicious_program, dependencies); + + // Act - execute the malicious program - this should fail during proving + let result = execute_and_prove( + vec![sender_account, recipient_account], + Program::serialize_instruction(instruction).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: recipient_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: recipient_keys.nsk, + membership_proof: state + .get_proof_for_commitment(&recipient_commitment) + .expect("recipient's commitment must be in state"), + identifier: 0, + }, + ], + &program_with_deps, + ); + + // Assert - should fail because the malicious program tries to manipulate is_authorized + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} diff --git a/lee/state_machine/src/state/tests/public_program_rules.rs b/lee/state_machine/src/state/tests/public_program_rules.rs new file mode 100644 index 00000000..236bddcf --- /dev/null +++ b/lee/state_machine/src/state/tests/public_program_rules.rs @@ -0,0 +1,377 @@ +use super::*; + +#[test] +fn program_should_fail_if_modifies_nonces() { + let account_id = AccountId::new([1; 32]); + let mut state = V03State::new() + .with_public_account_balances([(account_id, 100)]) + .with_test_programs(); + let account_ids = vec![account_id]; + let program_id = crate::test_methods::nonce_changer().id(); + let message = + public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + + assert!(matches!( + result, + Err(LeeError::InvalidProgramBehavior( + InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::ModifiedNonce { account_id: err_account_id } + ) + )) if err_account_id == account_id + )); +} + +#[test] +fn program_should_fail_if_output_accounts_exceed_inputs() { + let mut state = V03State::new() + .with_public_account_balances([(AccountId::new([1; 32]), 0)]) + .with_test_programs(); + let account_ids = vec![AccountId::new([1; 32])]; + let program_id = crate::test_methods::extra_output().id(); + let message = + public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + + assert!(matches!( + result, + Err(LeeError::InvalidProgramBehavior( + InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::MismatchedPreStatePostStateLength { + pre_state_length, + post_state_length + } + ) + )) if pre_state_length == 1 && post_state_length == 2 + )); +} + +#[test] +fn program_should_fail_with_missing_output_accounts() { + let mut state = V03State::new() + .with_public_account_balances([(AccountId::new([1; 32]), 100)]) + .with_test_programs(); + let account_ids = vec![AccountId::new([1; 32]), AccountId::new([2; 32])]; + let program_id = crate::test_methods::missing_output().id(); + let message = + public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + + assert!(matches!( + result, + Err(LeeError::InvalidProgramBehavior( + InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::MismatchedPreStatePostStateLength { + pre_state_length, + post_state_length + } + ) + )) if pre_state_length == 2 && post_state_length == 1 + )); +} + +/// A program can drop an entire account from its own output — both its `pre_state` and +/// `post_state` together, not just one side — while staying internally consistent +/// (`pre_states.len() == post_states.len()` within its own report, so `validate_execution`'s +/// length check alone can't catch it). This must still be rejected: every account the caller +/// declared in the transaction must appear somewhere in the final diff. +#[test] +fn program_should_fail_if_it_drops_a_declared_account() { + // Both accounts need a non-default program_owner: an account left at DEFAULT_PROGRAM_ID with + // non-default data would itself violate the (separate, pre-existing) "claim before mutating a + // default-owned account" rule the moment it's echoed back — unrelated to what this test + // targets. `with_public_account_balances` leaves program_owner at DEFAULT_PROGRAM_ID, so use + // `with_public_accounts` to set it explicitly instead. + let mut state = V03State::new() + .with_public_accounts([ + ( + AccountId::new([1; 32]), + Account { + program_owner: crate::test_methods::dropped_account().id(), + balance: 100, + ..Account::default() + }, + ), + ( + AccountId::new([2; 32]), + Account { + program_owner: crate::test_methods::dropped_account().id(), + balance: 0, + ..Account::default() + }, + ), + ]) + .with_test_programs(); + let account_ids = vec![AccountId::new([1; 32]), AccountId::new([2; 32])]; + let program_id = crate::test_methods::dropped_account().id(); + let message = + public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + + assert!( + matches!( + result, + Err(LeeError::InvalidProgramBehavior( + InvalidProgramBehaviorError::DeclaredAccountMissingFromOutput { account_id } + )) if account_id == AccountId::new([2; 32]) + ), + "expected DeclaredAccountMissingFromOutput for the dropped account, got {result:?}" + ); +} + +#[test] +fn program_should_fail_if_modifies_program_owner_with_only_non_default_program_owner() { + let initial_data = [( + AccountId::new([1; 32]), + Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + ..Account::default() + }, + )]; + let mut state = V03State::new() + .with_public_accounts(initial_data) + .with_test_programs(); + let account_id = AccountId::new([1; 32]); + let account = state.get_account_by_id(account_id); + // Assert the target account only differs from the default account in the program owner + // field + assert_ne!(account.program_owner, Account::default().program_owner); + assert_eq!(account.balance, Account::default().balance); + assert_eq!(account.nonce, Account::default().nonce); + assert_eq!(account.data, Account::default().data); + let program_id = crate::test_methods::program_owner_changer().id(); + let message = + public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + + assert!(matches!( + result, + Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id } + ))) if err_account_id == account_id + )); +} + +#[test] +fn program_should_fail_if_modifies_program_owner_with_only_non_default_balance() { + let initial_data = HashMap::new(); + let mut state = V03State::new() + .with_public_accounts(initial_data) + .with_test_programs() + .with_non_default_accounts_but_default_program_owners(); + let account_id = AccountId::new([255; 32]); + let account = state.get_account_by_id(account_id); + // Assert the target account only differs from the default account in balance field + assert_eq!(account.program_owner, Account::default().program_owner); + assert_ne!(account.balance, Account::default().balance); + assert_eq!(account.nonce, Account::default().nonce); + assert_eq!(account.data, Account::default().data); + let program_id = crate::test_methods::program_owner_changer().id(); + let message = + public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + + assert!(matches!( + result, + Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id } + ))) if err_account_id == account_id + )); +} + +#[test] +fn program_should_fail_if_modifies_program_owner_with_only_non_default_nonce() { + let initial_data = HashMap::new(); + let mut state = V03State::new() + .with_public_accounts(initial_data) + .with_test_programs() + .with_non_default_accounts_but_default_program_owners(); + let account_id = AccountId::new([254; 32]); + let account = state.get_account_by_id(account_id); + // Assert the target account only differs from the default account in nonce field + assert_eq!(account.program_owner, Account::default().program_owner); + assert_eq!(account.balance, Account::default().balance); + assert_ne!(account.nonce, Account::default().nonce); + assert_eq!(account.data, Account::default().data); + let program_id = crate::test_methods::program_owner_changer().id(); + let message = + public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + + assert!(matches!( + result, + Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id } + ))) if err_account_id == account_id + )); +} + +#[test] +fn program_should_fail_if_modifies_program_owner_with_only_non_default_data() { + let initial_data = HashMap::new(); + let mut state = V03State::new() + .with_public_accounts(initial_data) + .with_test_programs() + .with_non_default_accounts_but_default_program_owners(); + let account_id = AccountId::new([253; 32]); + let account = state.get_account_by_id(account_id); + // Assert the target account only differs from the default account in data field + assert_eq!(account.program_owner, Account::default().program_owner); + assert_eq!(account.balance, Account::default().balance); + assert_eq!(account.nonce, Account::default().nonce); + assert_ne!(account.data, Account::default().data); + let program_id = crate::test_methods::program_owner_changer().id(); + let message = + public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + + assert!(matches!( + result, + Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id } + ))) if err_account_id == account_id + )); +} + +#[test] +fn program_should_fail_if_transfers_balance_from_non_owned_account() { + let sender_account_id = AccountId::new([1; 32]); + let receiver_account_id = AccountId::new([2; 32]); + let mut state = V03State::new() + .with_public_account_balances([(sender_account_id, 100)]) + .with_test_programs(); + let balance_to_move: u128 = 1; + let program_id = crate::test_methods::simple_balance_transfer().id(); + assert_ne!( + state.get_account_by_id(sender_account_id).program_owner, + program_id + ); + let message = public_transaction::Message::try_new( + program_id, + vec![sender_account_id, receiver_account_id], + vec![], + balance_to_move, + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + + assert!(matches!( + result, + Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::UnauthorizedBalanceDecrease { account_id: err_account_id, owner_program_id, executing_program_id } + ))) if err_account_id == sender_account_id && owner_program_id != program_id && executing_program_id == program_id + )); +} + +#[test] +fn program_should_fail_if_modifies_data_of_non_owned_account() { + let initial_data = HashMap::new(); + let mut state = V03State::new() + .with_public_accounts(initial_data) + .with_test_programs() + .with_non_default_accounts_but_default_program_owners(); + let account_id = AccountId::new([255; 32]); + let program_id = crate::test_methods::data_changer().id(); + + assert_ne!(state.get_account_by_id(account_id), Account::default()); + assert_ne!( + state.get_account_by_id(account_id).program_owner, + program_id + ); + let message = + public_transaction::Message::try_new(program_id, vec![account_id], vec![], vec![0]) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 1, 0); + + assert!(matches!( + result, + Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::UnauthorizedDataModification { account_id: err_account_id, executing_program_id } + ))) if err_account_id == account_id && executing_program_id == program_id + )); +} + +#[test] +fn program_should_fail_if_does_not_preserve_total_balance_by_minting() { + let initial_data = HashMap::new(); + let mut state = V03State::new() + .with_public_accounts(initial_data) + .with_test_programs(); + let account_id = AccountId::new([1; 32]); + let program_id = crate::test_methods::minter().id(); + + let message = + public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + + let result = state.transition_from_public_transaction(&tx, 2, 0); + + assert!(matches!( + result, + Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states } + ))) if total_balance_pre_states == 0.into() && total_balance_post_states == 1.into() + )); +} + +#[test] +fn program_should_fail_if_does_not_preserve_total_balance_by_burning() { + let initial_data = HashMap::new(); + let mut state = V03State::new() + .with_public_accounts(initial_data) + .with_test_programs() + .with_account_owned_by_burner_program(); + let program_id = crate::test_methods::burner().id(); + let account_id = AccountId::new([252; 32]); + assert_eq!( + state.get_account_by_id(account_id).program_owner, + program_id + ); + let balance_to_burn: u128 = 1; + assert!(state.get_account_by_id(account_id).balance > balance_to_burn); + + let message = + public_transaction::Message::try_new(program_id, vec![account_id], vec![], balance_to_burn) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + let result = state.transition_from_public_transaction(&tx, 2, 0); + + assert!(matches!( + result, + Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states } + ))) if total_balance_pre_states == 100.into() && total_balance_post_states == 99.into() + )); +} diff --git a/lee/state_machine/src/state/tests/validity_window.rs b/lee/state_machine/src/state/tests/validity_window.rs new file mode 100644 index 00000000..7e314bc2 --- /dev/null +++ b/lee/state_machine/src/state/tests/validity_window.rs @@ -0,0 +1,237 @@ +use super::*; + +#[test_case::test_case((Some(1), Some(3)), 3; "at upper bound")] +#[test_case::test_case((Some(1), Some(3)), 2; "inside range")] +#[test_case::test_case((Some(1), Some(3)), 0; "below range")] +#[test_case::test_case((Some(1), Some(3)), 1; "at lower bound")] +#[test_case::test_case((Some(1), Some(3)), 4; "above range")] +#[test_case::test_case((Some(1), None), 1; "lower bound only - at bound")] +#[test_case::test_case((Some(1), None), 10; "lower bound only - above")] +#[test_case::test_case((Some(1), None), 0; "lower bound only - below")] +#[test_case::test_case((None, Some(3)), 3; "upper bound only - at bound")] +#[test_case::test_case((None, Some(3)), 0; "upper bound only - below")] +#[test_case::test_case((None, Some(3)), 4; "upper bound only - above")] +#[test_case::test_case((None, None), 0; "no bounds - always valid")] +#[test_case::test_case((None, None), 100; "no bounds - always valid 2")] +fn validity_window_works_in_public_transactions( + validity_window: (Option, Option), + block_id: BlockId, +) { + let block_validity_window: BlockValidityWindow = validity_window.try_into().unwrap(); + let validity_window_program = crate::test_methods::validity_window(); + let account_keys = test_public_account_keys_1(); + let pre = AccountWithMetadata::new(Account::default(), false, account_keys.account_id()); + let mut state = V03State::new().with_test_programs(); + let tx = { + let account_ids = vec![pre.account_id]; + let nonces = vec![]; + let program_id = validity_window_program.id(); + let instruction = ( + block_validity_window, + TimestampValidityWindow::new_unbounded(), + ); + let message = + public_transaction::Message::try_new(program_id, account_ids, nonces, instruction) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + PublicTransaction::new(message, witness_set) + }; + let result = state.transition_from_public_transaction(&tx, block_id, 0); + let is_inside_validity_window = + match (block_validity_window.start(), block_validity_window.end()) { + (Some(s), Some(e)) => s <= block_id && block_id < e, + (Some(s), None) => s <= block_id, + (None, Some(e)) => block_id < e, + (None, None) => true, + }; + if is_inside_validity_window { + assert!(result.is_ok()); + } else { + assert!(matches!(result, Err(LeeError::OutOfValidityWindow))); + } +} + +#[test_case::test_case((Some(1), Some(3)), 3; "at upper bound")] +#[test_case::test_case((Some(1), Some(3)), 2; "inside range")] +#[test_case::test_case((Some(1), Some(3)), 0; "below range")] +#[test_case::test_case((Some(1), Some(3)), 1; "at lower bound")] +#[test_case::test_case((Some(1), Some(3)), 4; "above range")] +#[test_case::test_case((Some(1), None), 1; "lower bound only - at bound")] +#[test_case::test_case((Some(1), None), 10; "lower bound only - above")] +#[test_case::test_case((Some(1), None), 0; "lower bound only - below")] +#[test_case::test_case((None, Some(3)), 3; "upper bound only - at bound")] +#[test_case::test_case((None, Some(3)), 0; "upper bound only - below")] +#[test_case::test_case((None, Some(3)), 4; "upper bound only - above")] +#[test_case::test_case((None, None), 0; "no bounds - always valid")] +#[test_case::test_case((None, None), 100; "no bounds - always valid 2")] +fn timestamp_validity_window_works_in_public_transactions( + validity_window: (Option, Option), + timestamp: Timestamp, +) { + let timestamp_validity_window: TimestampValidityWindow = validity_window.try_into().unwrap(); + let validity_window_program = crate::test_methods::validity_window(); + let account_keys = test_public_account_keys_1(); + let pre = AccountWithMetadata::new(Account::default(), false, account_keys.account_id()); + let mut state = V03State::new().with_test_programs(); + let tx = { + let account_ids = vec![pre.account_id]; + let nonces = vec![]; + let program_id = validity_window_program.id(); + let instruction = ( + BlockValidityWindow::new_unbounded(), + timestamp_validity_window, + ); + let message = + public_transaction::Message::try_new(program_id, account_ids, nonces, instruction) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[]); + PublicTransaction::new(message, witness_set) + }; + let result = state.transition_from_public_transaction(&tx, 1, timestamp); + let is_inside_validity_window = match ( + timestamp_validity_window.start(), + timestamp_validity_window.end(), + ) { + (Some(s), Some(e)) => s <= timestamp && timestamp < e, + (Some(s), None) => s <= timestamp, + (None, Some(e)) => timestamp < e, + (None, None) => true, + }; + if is_inside_validity_window { + assert!(result.is_ok()); + } else { + assert!(matches!(result, Err(LeeError::OutOfValidityWindow))); + } +} + +#[test_case::test_case((Some(1), Some(3)), 3; "at upper bound")] +#[test_case::test_case((Some(1), Some(3)), 2; "inside range")] +#[test_case::test_case((Some(1), Some(3)), 0; "below range")] +#[test_case::test_case((Some(1), Some(3)), 1; "at lower bound")] +#[test_case::test_case((Some(1), Some(3)), 4; "above range")] +#[test_case::test_case((Some(1), None), 1; "lower bound only - at bound")] +#[test_case::test_case((Some(1), None), 10; "lower bound only - above")] +#[test_case::test_case((Some(1), None), 0; "lower bound only - below")] +#[test_case::test_case((None, Some(3)), 3; "upper bound only - at bound")] +#[test_case::test_case((None, Some(3)), 0; "upper bound only - below")] +#[test_case::test_case((None, Some(3)), 4; "upper bound only - above")] +#[test_case::test_case((None, None), 0; "no bounds - always valid")] +#[test_case::test_case((None, None), 100; "no bounds - always valid 2")] +fn validity_window_works_in_privacy_preserving_transactions( + validity_window: (Option, Option), + block_id: BlockId, +) { + let block_validity_window: BlockValidityWindow = validity_window.try_into().unwrap(); + let validity_window_program = crate::test_methods::validity_window(); + let account_keys = test_private_account_keys_1(); + let pre = AccountWithMetadata::new( + Account::default(), + true, + (&account_keys.npk(), &account_keys.vpk(), 0), + ); + let mut state = V03State::new().with_test_programs(); + let tx = { + let instruction = ( + block_validity_window, + TimestampValidityWindow::new_unbounded(), + ); + let (output, proof) = crate::privacy_preserving_transaction::circuit::execute_and_prove( + vec![pre], + Program::serialize_instruction(instruction).unwrap(), + vec![InputAccountIdentity::PrivateForeignInit { + vpk: account_keys.vpk(), + random_seed: [0; 32], + npk: account_keys.npk(), + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + }], + &validity_window_program.into(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[]); + PrivacyPreservingTransaction::new(message, witness_set) + }; + let result = state.transition_from_privacy_preserving_transaction(&tx, block_id, 0); + let is_inside_validity_window = + match (block_validity_window.start(), block_validity_window.end()) { + (Some(s), Some(e)) => s <= block_id && block_id < e, + (Some(s), None) => s <= block_id, + (None, Some(e)) => block_id < e, + (None, None) => true, + }; + if is_inside_validity_window { + assert!(result.is_ok()); + } else { + assert!(matches!(result, Err(LeeError::OutOfValidityWindow))); + } +} + +#[test_case::test_case((Some(1), Some(3)), 3; "at upper bound")] +#[test_case::test_case((Some(1), Some(3)), 2; "inside range")] +#[test_case::test_case((Some(1), Some(3)), 0; "below range")] +#[test_case::test_case((Some(1), Some(3)), 1; "at lower bound")] +#[test_case::test_case((Some(1), Some(3)), 4; "above range")] +#[test_case::test_case((Some(1), None), 1; "lower bound only - at bound")] +#[test_case::test_case((Some(1), None), 10; "lower bound only - above")] +#[test_case::test_case((Some(1), None), 0; "lower bound only - below")] +#[test_case::test_case((None, Some(3)), 3; "upper bound only - at bound")] +#[test_case::test_case((None, Some(3)), 0; "upper bound only - below")] +#[test_case::test_case((None, Some(3)), 4; "upper bound only - above")] +#[test_case::test_case((None, None), 0; "no bounds - always valid")] +#[test_case::test_case((None, None), 100; "no bounds - always valid 2")] +fn timestamp_validity_window_works_in_privacy_preserving_transactions( + validity_window: (Option, Option), + timestamp: Timestamp, +) { + let timestamp_validity_window: TimestampValidityWindow = validity_window.try_into().unwrap(); + let validity_window_program = crate::test_methods::validity_window(); + let account_keys = test_private_account_keys_1(); + let pre = AccountWithMetadata::new( + Account::default(), + true, + (&account_keys.npk(), &account_keys.vpk(), 0), + ); + let mut state = V03State::new().with_test_programs(); + let tx = { + let instruction = ( + BlockValidityWindow::new_unbounded(), + timestamp_validity_window, + ); + let (output, proof) = crate::privacy_preserving_transaction::circuit::execute_and_prove( + vec![pre], + Program::serialize_instruction(instruction).unwrap(), + vec![InputAccountIdentity::PrivateForeignInit { + vpk: account_keys.vpk(), + random_seed: [0; 32], + npk: account_keys.npk(), + identifier: 0, + commitment_root: DUMMY_COMMITMENT_HASH, + }], + &validity_window_program.into(), + ) + .unwrap(); + + let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[]); + PrivacyPreservingTransaction::new(message, witness_set) + }; + let result = state.transition_from_privacy_preserving_transaction(&tx, 1, timestamp); + let is_inside_validity_window = match ( + timestamp_validity_window.start(), + timestamp_validity_window.end(), + ) { + (Some(s), Some(e)) => s <= timestamp && timestamp < e, + (Some(s), None) => s <= timestamp, + (None, Some(e)) => timestamp < e, + (None, None) => true, + }; + if is_inside_validity_window { + assert!(result.is_ok()); + } else { + assert!(matches!(result, Err(LeeError::OutOfValidityWindow))); + } +} diff --git a/lee/state_machine/src/test_utils.rs b/lee/state_machine/src/test_utils.rs new file mode 100644 index 00000000..f9325ddd --- /dev/null +++ b/lee/state_machine/src/test_utils.rs @@ -0,0 +1,28 @@ +//! Test-only constructors for otherwise-opaque state types. +//! +//! A [`ValidatedStateDiff`] can normally only be produced by the transaction validation +//! functions, which guarantees it has been checked before any state mutation. These +//! helpers let downstream crates unit-test *post-execution* validation logic — e.g. the +//! system-account and bridge guards in `common` — against a hand-built diff, without +//! running a program in the zkVM. + +use std::collections::HashMap; + +use crate::{ + Account, AccountId, + validated_state_diff::{StateDiff, ValidatedStateDiff}, +}; + +/// Builds a [`ValidatedStateDiff`] carrying only the given public-account changes. +#[must_use] +pub const fn validated_state_diff_from_public_diff( + public_diff: HashMap, +) -> ValidatedStateDiff { + ValidatedStateDiff::new_unchecked(StateDiff { + signer_account_ids: Vec::new(), + public_diff, + new_commitments: Vec::new(), + new_nullifiers: Vec::new(), + program: None, + }) +} diff --git a/lee/state_machine/src/validated_state_diff.rs b/lee/state_machine/src/validated_state_diff.rs deleted file mode 100644 index 44a307af..00000000 --- a/lee/state_machine/src/validated_state_diff.rs +++ /dev/null @@ -1,1055 +0,0 @@ -use std::{ - collections::{HashMap, HashSet, VecDeque}, - hash::Hash, -}; - -use lee_core::{ - BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, Timestamp, - account::{Account, AccountId, AccountWithMetadata}, - program::{ - ChainedCall, Claim, DEFAULT_PROGRAM_ID, ProgramId, compute_public_authorized_pdas, - validate_execution, - }, -}; -use log::debug; - -use crate::{ - V03State, ensure, - error::{InvalidProgramBehaviorError, LeeError}, - privacy_preserving_transaction::{ - PrivacyPreservingTransaction, circuit::Proof, message::Message, - }, - program::Program, - program_deployment_transaction::ProgramDeploymentTransaction, - public_transaction::PublicTransaction, - state::MAX_NUMBER_CHAINED_CALLS, -}; - -pub struct StateDiff { - pub signer_account_ids: Vec, - pub public_diff: HashMap, - pub new_commitments: Vec, - pub new_nullifiers: Vec, - pub program: Option, -} - -/// The validated output of executing or verifying a transaction, ready to be applied to the state. -/// -/// Can only be constructed by the transaction validation functions inside this crate, ensuring the -/// diff has been checked before any state mutation occurs. -pub struct ValidatedStateDiff(StateDiff); - -impl ValidatedStateDiff { - pub fn from_public_transaction( - tx: &PublicTransaction, - state: &V03State, - block_id: BlockId, - timestamp: Timestamp, - ) -> Result { - let message = tx.message(); - let witness_set = tx.witness_set(); - - ensure!( - !message.account_ids.is_empty(), - LeeError::InvalidInput("Public transaction must have at least one account".into()) - ); - - // All account_ids must be different - ensure!( - message.account_ids.iter().collect::>().len() == message.account_ids.len(), - LeeError::InvalidInput("Duplicate account_ids found in message".into(),) - ); - - // Check exactly one nonce is provided for each signature - ensure!( - message.nonces.len() == witness_set.signatures_and_public_keys.len(), - LeeError::InvalidInput( - "Mismatch between number of nonces and signatures/public keys".into(), - ) - ); - - // Check the signatures are valid - ensure!( - witness_set.is_valid_for(message), - LeeError::InvalidInput("Invalid signature for given message and public key".into()) - ); - - let signer_account_ids = tx.signer_account_ids(); - // Check nonces corresponds to the current nonces on the public state. - for (account_id, nonce) in signer_account_ids.iter().zip(&message.nonces) { - let current_nonce = state.get_account_by_id(*account_id).nonce; - ensure!( - current_nonce == *nonce, - LeeError::InvalidInput("Nonce mismatch".into()) - ); - } - - // Build pre_states for execution - let input_pre_states: Vec<_> = message - .account_ids - .iter() - .map(|account_id| { - AccountWithMetadata::new( - state.get_account_by_id(*account_id), - signer_account_ids.contains(account_id), - *account_id, - ) - }) - .collect(); - - let mut state_diff: HashMap = HashMap::new(); - - let initial_call = ChainedCall { - program_id: message.program_id, - instruction_data: message.instruction_data.clone(), - pre_states: input_pre_states, - pda_seeds: vec![], - }; - - #[expect( - clippy::items_after_statements, - reason = "More readable to keep it behind the place where it's used" - )] - #[derive(Debug)] - struct CallerData { - program_id: Option, - authorized_accounts: HashSet, - } - - let initial_caller_data = CallerData { - program_id: None, - authorized_accounts: signer_account_ids.iter().copied().collect(), - }; - - let mut chained_calls = - VecDeque::<(ChainedCall, CallerData)>::from_iter([(initial_call, initial_caller_data)]); - let mut chain_calls_counter = 0; - - while let Some((chained_call, caller_data)) = chained_calls.pop_front() { - ensure!( - chain_calls_counter <= MAX_NUMBER_CHAINED_CALLS, - LeeError::MaxChainedCallsDepthExceeded - ); - - // Check that the `program_id` corresponds to a deployed program - let Some(program) = state.programs().get(&chained_call.program_id) else { - return Err(LeeError::InvalidInput("Unknown program".into())); - }; - - debug!( - "Program {:?} pre_states: {:?}, instruction_data: {:?}", - chained_call.program_id, chained_call.pre_states, chained_call.instruction_data - ); - let mut program_output = program.execute( - caller_data.program_id, - &chained_call.pre_states, - &chained_call.instruction_data, - )?; - debug!( - "Program {:?} output: {:?}", - chained_call.program_id, program_output - ); - - let authorized_pdas = - compute_public_authorized_pdas(caller_data.program_id, &chained_call.pda_seeds); - - // Account is authorized if it is either in the caller's authorized accounts or in the - // list of PDAs the caller has authorized. - let is_authorized = |account_id: &AccountId| { - authorized_pdas.contains(account_id) - || caller_data.authorized_accounts.contains(account_id) - }; - - for pre in &program_output.pre_states { - let account_id = pre.account_id; - // Check that the program output pre_states coincide with the values in the public - // state or with any modifications to those values during the chain of calls. - let expected_pre = state_diff - .get(&account_id) - .cloned() - .unwrap_or_else(|| state.get_account_by_id(account_id)); - ensure!( - pre.account == expected_pre, - InvalidProgramBehaviorError::InconsistentAccountPreState { - account_id, - expected: Box::new(expected_pre), - actual: Box::new(pre.account.clone()) - } - ); - - // Check that the program output pre_states marked as authorized are indeed - // authorized, and vice-versa. - let is_indeed_authorized = is_authorized(&account_id); - ensure!( - !pre.is_authorized || is_indeed_authorized, - InvalidProgramBehaviorError::InvalidAccountAuthorization { account_id } - ); - ensure!( - pre.is_authorized || !is_indeed_authorized, - InvalidProgramBehaviorError::AuthorizedAccountMarkedAsNotAuthorized { - account_id - } - ); - } - - // Verify that the program output's self_program_id matches the expected program ID. - ensure!( - program_output.self_program_id == chained_call.program_id, - InvalidProgramBehaviorError::MismatchedProgramId { - expected: chained_call.program_id, - actual: program_output.self_program_id - } - ); - - // Verify that the program output's caller_program_id matches the actual caller. - ensure!( - program_output.caller_program_id == caller_data.program_id, - InvalidProgramBehaviorError::MismatchedCallerProgramId { - expected: caller_data.program_id, - actual: program_output.caller_program_id, - } - ); - - // Verify execution corresponds to a well-behaved program. - // See the # Programs section for the definition of the `validate_execution` method. - validate_execution( - &program_output.pre_states, - &program_output.post_states, - chained_call.program_id, - ) - .map_err(InvalidProgramBehaviorError::ExecutionValidationFailed)?; - - // Verify validity window - ensure!( - program_output.block_validity_window.is_valid_for(block_id) - && program_output - .timestamp_validity_window - .is_valid_for(timestamp), - LeeError::OutOfValidityWindow - ); - - for (i, post) in program_output.post_states.iter_mut().enumerate() { - let Some(claim) = post.required_claim() else { - continue; - }; - let pre = &program_output.pre_states[i]; - let account_id = pre.account_id; - - // The invoked program can only claim accounts with default program id. - ensure!( - post.account().program_owner == DEFAULT_PROGRAM_ID, - InvalidProgramBehaviorError::ClaimedNonDefaultAccount { account_id } - ); - - match claim { - Claim::Authorized => { - // The program can only claim accounts that were authorized by the signer. - ensure!( - pre.is_authorized, - InvalidProgramBehaviorError::ClaimedUnauthorizedAccount { account_id } - ); - } - Claim::Pda(seed) => { - // The program can only claim accounts that correspond to the PDAs it is - // authorized to claim. The public-execution path only sees public - // accounts, so the public-PDA derivation is the correct formula here. - let pda = AccountId::for_public_pda(&chained_call.program_id, &seed); - ensure!( - account_id == pda, - InvalidProgramBehaviorError::MismatchedPdaClaim { - expected: pda, - actual: account_id - } - ); - } - } - - post.account_mut().program_owner = chained_call.program_id; - } - - // Update the state diff - for (pre, post) in program_output - .pre_states - .iter() - .zip(program_output.post_states.iter()) - { - state_diff.insert(pre.account_id, post.account().clone()); - } - - // Source from `program_output.pre_states`, not `chained_call.pre_states`: - // the loop above already gates program_output's `is_authorized` via the - // `!pre.is_authorized || is_indeed_authorized` check, while `chained_call. - // pre_states` is caller-controlled and can be forged (audit-issue 91). - // - // Union with the caller's authorized set so that authorization is monotonically - // growing: once an account is authorized at any point in the chain it remains - // authorized for all subsequent calls. - let authorized_accounts: HashSet<_> = caller_data - .authorized_accounts - .into_iter() - .chain( - program_output - .pre_states - .iter() - .filter(|pre| pre.is_authorized) - .map(|pre| pre.account_id), - ) - .collect(); - for new_call in program_output.chained_calls.into_iter().rev() { - chained_calls.push_front(( - new_call, - CallerData { - program_id: Some(chained_call.program_id), - authorized_accounts: authorized_accounts.clone(), - }, - )); - } - - chain_calls_counter = chain_calls_counter - .checked_add(1) - .expect("we check the max depth at the beginning of the loop"); - } - - // Check that all modified uninitialized accounts where claimed - for (account_id, post) in state_diff.iter().filter_map(|(account_id, post)| { - let pre = state.get_account_by_id(*account_id); - if pre.program_owner != DEFAULT_PROGRAM_ID { - return None; - } - if pre == *post { - return None; - } - Some((*account_id, post)) - }) { - ensure!( - post.program_owner != DEFAULT_PROGRAM_ID, - InvalidProgramBehaviorError::DefaultAccountModifiedWithoutClaim { account_id } - ); - } - - Ok(Self(StateDiff { - signer_account_ids, - public_diff: state_diff, - new_commitments: vec![], - new_nullifiers: vec![], - program: None, - })) - } - - pub fn from_privacy_preserving_transaction( - tx: &PrivacyPreservingTransaction, - state: &V03State, - block_id: BlockId, - timestamp: Timestamp, - ) -> Result { - let message = &tx.message; - let witness_set = &tx.witness_set; - - // 1. Commitments or nullifiers are non empty - ensure!( - !message.new_commitments.is_empty() || !message.new_nullifiers.is_empty(), - LeeError::InvalidInput( - "Empty commitments and empty nullifiers found in message".into(), - ) - ); - - // 2. Check there are no duplicate account_ids in the public_account_ids list. - ensure!( - n_unique(&message.public_account_ids) == message.public_account_ids.len(), - LeeError::InvalidInput("Duplicate account_ids found in message".into()) - ); - - // Check there are no duplicate nullifiers in the new_nullifiers list - ensure!( - n_unique( - &message - .new_nullifiers - .iter() - .map(|(n, _)| n) - .collect::>() - ) == message.new_nullifiers.len(), - LeeError::InvalidInput("Duplicate nullifiers found in message".into()) - ); - - // Check there are no duplicate commitments in the new_commitments list - ensure!( - n_unique(&message.new_commitments) == message.new_commitments.len(), - LeeError::InvalidInput("Duplicate commitments found in message".into()) - ); - - // 3. Nonce checks and Valid signatures - // Check exactly one nonce is provided for each signature - ensure!( - message.nonces.len() == witness_set.signatures_and_public_keys.len(), - LeeError::InvalidInput( - "Mismatch between number of nonces and signatures/public keys".into(), - ) - ); - - // Check the signatures are valid - ensure!( - witness_set.signatures_are_valid_for(message), - LeeError::InvalidInput("Invalid signature for given message and public key".into()) - ); - - let signer_account_ids = tx.signer_account_ids(); - // Check nonces corresponds to the current nonces on the public state. - for (account_id, nonce) in signer_account_ids.iter().zip(&message.nonces) { - let current_nonce = state.get_account_by_id(*account_id).nonce; - ensure!( - current_nonce == *nonce, - LeeError::InvalidInput("Nonce mismatch".into()) - ); - } - - // Verify validity window - ensure!( - message.block_validity_window.is_valid_for(block_id) - && message.timestamp_validity_window.is_valid_for(timestamp), - LeeError::OutOfValidityWindow - ); - - // Build pre_states for proof verification - let public_pre_states: Vec<_> = message - .public_account_ids - .iter() - .map(|account_id| { - AccountWithMetadata::new( - state.get_account_by_id(*account_id), - signer_account_ids.contains(account_id), - *account_id, - ) - }) - .collect(); - - // 4. Proof verification - check_privacy_preserving_circuit_proof_is_valid( - &witness_set.proof, - &public_pre_states, - message, - )?; - - // 5. Commitment freshness - state.check_commitments_are_new(&message.new_commitments)?; - - // 6. Nullifier uniqueness - state.check_nullifiers_are_valid(&message.new_nullifiers)?; - - let public_diff = message - .public_account_ids - .iter() - .copied() - .zip(message.public_post_states.clone()) - .collect(); - let new_nullifiers = message - .new_nullifiers - .iter() - .copied() - .map(|(nullifier, _)| nullifier) - .collect(); - - Ok(Self(StateDiff { - signer_account_ids, - public_diff, - new_commitments: message.new_commitments.clone(), - new_nullifiers, - program: None, - })) - } - - pub fn from_program_deployment_transaction( - tx: &ProgramDeploymentTransaction, - state: &V03State, - ) -> Result { - // TODO: remove clone - let program = Program::new(tx.message.bytecode.clone().into())?; - if state.programs().contains_key(&program.id()) { - return Err(LeeError::ProgramAlreadyExists); - } - Ok(Self(StateDiff { - signer_account_ids: vec![], - public_diff: HashMap::new(), - new_commitments: vec![], - new_nullifiers: vec![], - program: Some(program), - })) - } - - /// Returns the public account changes produced by this transaction. - /// - /// Used by callers (e.g. the sequencer) to inspect the diff before committing it, for example - /// to enforce that system accounts are not modified by user transactions. - #[must_use] - pub fn public_diff(&self) -> HashMap { - self.0.public_diff.clone() - } - - pub(crate) fn into_state_diff(self) -> StateDiff { - self.0 - } -} - -fn check_privacy_preserving_circuit_proof_is_valid( - proof: &Proof, - public_pre_states: &[AccountWithMetadata], - message: &Message, -) -> Result<(), LeeError> { - let output = PrivacyPreservingCircuitOutput { - public_pre_states: public_pre_states.to_vec(), - public_post_states: message.public_post_states.clone(), - encrypted_private_post_states: message.encrypted_private_post_states.clone(), - new_commitments: message.new_commitments.clone(), - new_nullifiers: message.new_nullifiers.clone(), - block_validity_window: message.block_validity_window, - timestamp_validity_window: message.timestamp_validity_window, - }; - proof - .is_valid_for(&output) - .then_some(()) - .ok_or(LeeError::InvalidPrivacyPreservingProof) -} - -fn n_unique(data: &[T]) -> usize { - let set: HashSet<&T> = data.iter().collect(); - set.len() -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use lee_core::account::{Account, AccountId, Nonce}; - - use crate::{ - PrivateKey, PublicKey, V03State, - error::{InvalidProgramBehaviorError, LeeError}, - program::Program, - public_transaction::{Message, WitnessSet}, - validated_state_diff::ValidatedStateDiff, - }; - - fn public_state_from_balances( - initial_data: &[(AccountId, u128)], - ) -> HashMap { - initial_data - .iter() - .copied() - .map(|(account_id, balance)| { - ( - account_id, - Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance, - ..Account::default() - }, - ) - }) - .collect() - } - - #[test] - fn public_diff_reflects_a_successful_transfer() { - // A successful native transfer must record the debited sender in - // `public_diff()`. Catches the mutation that replaces `public_diff` with - // `HashMap::new()` (which would hide every account change). - let from_key = PrivateKey::try_new([1_u8; 32]).unwrap(); - let from = AccountId::from(&PublicKey::new_from_private_key(&from_key)); - let to_key = PrivateKey::try_new([2_u8; 32]).unwrap(); - let to = AccountId::from(&PublicKey::new_from_private_key(&to_key)); - - let state = V03State::new() - .with_public_accounts(public_state_from_balances(&[(from, 100)])) - .with_programs(std::iter::once( - crate::test_methods::simple_balance_transfer(), - )); - let program_id = crate::test_methods::simple_balance_transfer().id(); - let message = - Message::try_new(program_id, vec![from, to], vec![Nonce(0), Nonce(0)], 5_u128).unwrap(); - let witness_set = WitnessSet::for_message(&message, &[&from_key, &to_key]); - let tx = crate::PublicTransaction::new(message, witness_set); - - let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) - .expect("a valid native transfer must validate"); - let public_diff = diff.public_diff(); - - assert!( - public_diff.contains_key(&from), - "public_diff must contain the debited sender", - ); - assert_eq!( - public_diff[&from].balance, 95, - "sender balance in the diff must reflect the debit", - ); - } - - /// Privacy-path version of the authorization-injection attack. The test passes when the - /// attack is rejected and the victim's balance is left untouched. - /// - /// `execute_and_prove` succeeds because each inner receipt is individually valid and the - /// outer circuit faithfully commits whatever the attacker's program output says, including - /// `victim(is_authorized=true)`. The circuit has no access to chain state and cannot know - /// the victim never signed. - /// - /// The host-side validator is what catches the attack: it independently reconstructs - /// `public_pre_states` from chain state using `signer_account_ids.contains(victim_id) = false`, - /// so it expects `victim(is_authorized=false)`. The committed journal and the reconstructed - /// expected output diverge, `receipt.verify` fails, and `from_privacy_preserving_transaction` - /// returns an error before any state is applied. - #[test] - fn privacy_malicious_programs_cannot_drain_public_victim() { - use lee_core::{ - Commitment, EncryptedAccountData, InputAccountIdentity, SharedSecretKey, - account::{Account, AccountWithMetadata}, - }; - - use crate::{ - PrivacyPreservingTransaction, - privacy_preserving_transaction::{ - circuit::{ProgramWithDependencies, execute_and_prove}, - message::Message, - witness_set::WitnessSet, - }, - state::{CommitmentSet, tests::test_private_account_keys_1}, - }; - - type InjectorInstruction = ( - lee_core::program::ProgramId, // p2_id - lee_core::program::ProgramId, // simple_balance_transfer_id - [u8; 32], // victim_id_raw - u128, // victim_balance - u128, // victim_nonce - lee_core::program::ProgramId, // victim_program_owner - [u8; 32], // recipient_id_raw - u128, // amount - ); - - // Attacker controls a private account. - let attacker_keys = test_private_account_keys_1(); - let attacker_id = AccountId::for_regular_private_account(&attacker_keys.npk(), 0); - let (attacker_ssk, attacker_epk) = SharedSecretKey::encapsulate(&attacker_keys.vpk()); - - let victim_id = AccountId::new([20_u8; 32]); - let recipient_id = AccountId::new([42_u8; 32]); - let victim_balance = 5_000_u128; - - // genesis sets program_owner = simple_balance_transfer_program.id() on all accounts. - let state = V03State::new() - .with_public_accounts(public_state_from_balances(&[ - (victim_id, victim_balance), - (recipient_id, 0), - ])) - .with_programs([ - crate::test_methods::simple_balance_transfer(), - crate::test_methods::malicious_injector(), - crate::test_methods::malicious_launderer(), - ]); - - // Build attacker's private account and its local commitment tree. - let attacker_account = Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance: 100, - ..Account::default() - }; - let attacker_commitment = Commitment::new(&attacker_id, &attacker_account); - let mut commitment_set = CommitmentSet::with_capacity(1); - commitment_set.extend(std::slice::from_ref(&attacker_commitment)); - let membership_proof = commitment_set - .get_proof_for(&attacker_commitment) - .expect("attacker commitment must be in the set"); - - let attacker_pre = AccountWithMetadata::new(attacker_account, true, attacker_id); - - let victim_account = state.get_account_by_id(victim_id); - let instruction: InjectorInstruction = ( - crate::test_methods::malicious_launderer().id(), - crate::test_methods::simple_balance_transfer().id(), - *victim_id.value(), - victim_account.balance, - victim_account.nonce.0, - victim_account.program_owner, - *recipient_id.value(), - victim_balance, - ); - let instruction_data = Program::serialize_instruction(instruction).unwrap(); - - let p2 = crate::test_methods::malicious_launderer(); - let at = crate::test_methods::simple_balance_transfer(); - let program_with_deps = ProgramWithDependencies::new( - crate::test_methods::malicious_injector(), - [(p2.id(), p2), (at.id(), at)].into(), - ); - - // account_identities order must match self.pre_states as built by the circuit: - // [0] attacker — first seen in P1's program_output.pre_states - // [1] victim — first seen in simple_balance_transfer's program_output.pre_states - // [2] recipient — first seen in simple_balance_transfer's program_output.pre_states - let account_identities = vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: attacker_epk, - view_tag: EncryptedAccountData::compute_view_tag( - &attacker_keys.npk(), - &attacker_keys.vpk(), - ), - ssk: attacker_ssk, - nsk: attacker_keys.nsk, - membership_proof, - identifier: 0, - }, - InputAccountIdentity::Public, // victim - InputAccountIdentity::Public, // recipient - ]; - - // execute_and_prove succeeds: all inner receipts are valid. - // The outer circuit commits victim(is_authorized=true) to its journal. - let (circuit_output, proof) = execute_and_prove( - vec![attacker_pre], - instruction_data, - account_identities, - &program_with_deps, - ) - .expect("execute_and_prove should succeed \u{2014} the programs execute correctly"); - - // public_account_ids lists the Public entries from account_identities, in order. - // The single ciphertext belongs to attacker's private account update. - let message = Message::try_from_circuit_output( - vec![victim_id, recipient_id], - vec![], // no public signers, no nonces - circuit_output, - ) - .unwrap(); - - let witness_set = WitnessSet::for_message(&message, proof, &[]); // no signatures - let tx = PrivacyPreservingTransaction::new(message, witness_set); - - let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0); - - assert!( - matches!(result, Err(LeeError::InvalidPrivacyPreservingProof)), - "attack privacy transaction should be rejected with InvalidPrivacyPreservingProof" - ); - assert_eq!(state.get_account_by_id(victim_id).balance, victim_balance); - assert_eq!(state.get_account_by_id(recipient_id).balance, 0); - } - - /// Private-victim variant of the authorization-injection attack. The test passes when the - /// attack is rejected and the recipient's balance remains zero. - /// - /// After the circuit's Vacant branch accepts the injected `victim(is_authorized=true)` - /// verbatim, the attacker must choose how to declare the victim in `account_identities`. - /// There are two routes, both closed: - /// - /// - **mask=1 (`PrivateAuthorizedUpdate`)**: the circuit derives `account_id = - /// AccountId::for_regular_private_account(&npk_from(nsk), identifier)` and asserts it matches - /// `pre_state.account_id`. Passing this check requires the victim's `nsk`, which the attacker - /// does not have. `execute_and_prove` panics inside the ZKVM and no proof is produced. - /// - /// - **mask=0 (`Public`)**: the circuit places the account in `public_pre_states` and - /// `execute_and_prove` succeeds. The host-side validator then reconstructs - /// `public_pre_states` from chain state; `state.get_account_by_id(victim_id)` returns the - /// default account (balance=0) because the victim has no public state entry. The committed - /// journal and the reconstructed expected output diverge, `receipt.verify` fails, and - /// `from_privacy_preserving_transaction` returns an error before any state is applied. This - /// test exercises this route. - #[test] - fn privacy_malicious_programs_cannot_drain_private_victim() { - use lee_core::{ - Commitment, EncryptedAccountData, InputAccountIdentity, SharedSecretKey, - account::{Account, AccountWithMetadata}, - }; - - use crate::{ - PrivacyPreservingTransaction, - privacy_preserving_transaction::{ - circuit::{ProgramWithDependencies, execute_and_prove}, - message::Message, - witness_set::WitnessSet, - }, - state::{ - CommitmentSet, - tests::{test_private_account_keys_1, test_private_account_keys_2}, - }, - }; - - type InjectorInstruction = ( - lee_core::program::ProgramId, // p2_id - lee_core::program::ProgramId, // simple_balance_transfer_id - [u8; 32], // victim_id_raw - u128, // victim_balance - u128, // victim_nonce - lee_core::program::ProgramId, // victim_program_owner - [u8; 32], // recipient_id_raw - u128, // amount - ); - - // Attacker controls a private account. - let attacker_keys = test_private_account_keys_1(); - let attacker_id = AccountId::for_regular_private_account(&attacker_keys.npk(), 0); - let (attacker_ssk, attacker_epk) = SharedSecretKey::encapsulate(&attacker_keys.vpk()); - - // Victim is a private account — not registered in public chain state. - let victim_keys = test_private_account_keys_2(); - let victim_id = AccountId::for_regular_private_account(&victim_keys.npk(), 0); - let victim_balance = 5_000_u128; - - let recipient_id = AccountId::new([42_u8; 32]); - - // Victim has no public state entry; only recipient is registered at genesis. - let state = V03State::new() - .with_public_accounts(public_state_from_balances(&[(recipient_id, 0)])) - .with_programs([ - crate::test_methods::simple_balance_transfer(), - crate::test_methods::malicious_injector(), - crate::test_methods::malicious_launderer(), - ]); - - // Build attacker's private account and its local commitment tree. - let attacker_account = Account { - program_owner: crate::test_methods::simple_balance_transfer().id(), - balance: 100, - ..Account::default() - }; - let attacker_commitment = Commitment::new(&attacker_id, &attacker_account); - let mut commitment_set = CommitmentSet::with_capacity(1); - commitment_set.extend(std::slice::from_ref(&attacker_commitment)); - let membership_proof = commitment_set - .get_proof_for(&attacker_commitment) - .expect("attacker commitment must be in the set"); - - let attacker_pre = AccountWithMetadata::new(attacker_account, true, attacker_id); - - // The attacker supplies the victim's account data directly — it cannot be read from - // public state. The injected balance and program_owner allow simple_balance_transfer - // to succeed inside the circuit, which has no access to chain state and cannot detect - // that these values are fabricated. - let instruction: InjectorInstruction = ( - crate::test_methods::malicious_launderer().id(), - crate::test_methods::simple_balance_transfer().id(), - *victim_id.value(), - victim_balance, - 0_u128, // nonce - crate::test_methods::simple_balance_transfer().id(), // program_owner - *recipient_id.value(), - victim_balance, - ); - let instruction_data = Program::serialize_instruction(instruction).unwrap(); - - let p2 = crate::test_methods::malicious_launderer(); - let at = crate::test_methods::simple_balance_transfer(); - let program_with_deps = ProgramWithDependencies::new( - crate::test_methods::malicious_injector(), - [(p2.id(), p2), (at.id(), at)].into(), - ); - - // account_identities order must match self.pre_states as built by the circuit: - // [0] attacker — first seen in P1's program_output.pre_states - // [1] victim — first seen in simple_balance_transfer's program_output.pre_states - // [2] recipient — first seen in simple_balance_transfer's program_output.pre_states - // - // Victim is marked Public: the attacker has no nsk for the victim's private account, - // so PrivateAuthorizedUpdate is not an option. - let account_identities = vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: attacker_epk, - view_tag: EncryptedAccountData::compute_view_tag( - &attacker_keys.npk(), - &attacker_keys.vpk(), - ), - ssk: attacker_ssk, - nsk: attacker_keys.nsk, - membership_proof, - identifier: 0, - }, - InputAccountIdentity::Public, // victim — attacker lacks victim's nsk - InputAccountIdentity::Public, // recipient - ]; - - // execute_and_prove succeeds: simple_balance_transfer runs against the injected - // victim(balance=5000, is_authorized=true) and produces valid inner receipts. - // The outer circuit commits victim(is_authorized=true) to public_pre_states. - let (circuit_output, proof) = execute_and_prove( - vec![attacker_pre], - instruction_data, - account_identities, - &program_with_deps, - ) - .expect("execute_and_prove should succeed \u{2014} the programs execute correctly"); - - // public_account_ids lists the Public entries from account_identities, in order. - // The single ciphertext belongs to attacker's private account update. - let message = Message::try_from_circuit_output( - vec![victim_id, recipient_id], - vec![], // no public signers, no nonces - circuit_output, - ) - .unwrap(); - - let witness_set = WitnessSet::for_message(&message, proof, &[]); // no signatures - let tx = PrivacyPreservingTransaction::new(message, witness_set); - - let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0); - - assert!( - matches!(result, Err(LeeError::InvalidPrivacyPreservingProof)), - "attack on private victim should be rejected with InvalidPrivacyPreservingProof" - ); - // Victim has no public balance to check; confirming the recipient received nothing - // is sufficient to show no funds moved. - assert_eq!(state.get_account_by_id(recipient_id).balance, 0); - } - - /// Two malicious programs (injector + launderer) attempt to drain a victim's balance - /// without the victim signing anything. The test passes when the attack is rejected - /// and the victim's balance is left untouched. - /// - /// Attack flow: - /// Transaction (attacker signs) → P1 (`malicious_injector`) - /// → injects `victim(is_authorized=true)` into chained-call `pre_states` for P2 - /// P2 (`malicious_launderer`) - /// → outputs empty pre/post states, forwarding the forged flag to `simple_balance_transfer` - /// → if `authorized_accounts` were built from the injected `pre_states`, - /// `{victim}.contains(victim)` would pass and the transfer would execute. - /// - /// The validator must reject this: `authorized_accounts` must be derived from the - /// parent program's own validated `program_output.pre_states`, not from the chained-call - /// input, so a forged `is_authorized=true` flag is never trusted. - #[test] - fn malicious_programs_cannot_drain_victim_without_signature() { - // p2_id, simple_balance_transfer_id, victim_id_raw, victim_balance, victim_nonce, - // victim_program_owner, recipient_id_raw, amount. - // Primitives only — AccountId/Account cannot round-trip through instruction_data - // via risc0_zkvm::serde (SerializeDisplay issue). - type InjectorInstruction = ( - lee_core::program::ProgramId, // p2_id - lee_core::program::ProgramId, // simple_balance_transfer_id - [u8; 32], // victim_id_raw - u128, // victim_balance - u128, // victim_nonce - lee_core::program::ProgramId, // victim_program_owner - [u8; 32], // recipient_id_raw - u128, // amount - ); - - let attacker_key = PrivateKey::try_new([10; 32]).unwrap(); - let attacker_id = AccountId::from(&PublicKey::new_from_private_key(&attacker_key)); - - let victim_key = PrivateKey::try_new([20; 32]).unwrap(); - let victim_id = AccountId::from(&PublicKey::new_from_private_key(&victim_key)); - - let recipient_id = AccountId::new([42; 32]); - - let victim_balance = 5_000_u128; - let state = V03State::new() - .with_public_accounts(public_state_from_balances(&[ - (attacker_id, 100), - (victim_id, victim_balance), - (recipient_id, 0), - ])) - .with_programs([ - crate::test_methods::simple_balance_transfer(), - crate::test_methods::malicious_injector(), - crate::test_methods::malicious_launderer(), - ]); - - // Read victim state from chain, exactly as the attacker would. - let victim_account = state.get_account_by_id(victim_id); - - let instruction: InjectorInstruction = ( - crate::test_methods::malicious_launderer().id(), - crate::test_methods::simple_balance_transfer().id(), - *victim_id.value(), - victim_account.balance, - victim_account.nonce.0, - victim_account.program_owner, - *recipient_id.value(), - victim_balance, - ); - - let message = Message::try_new( - crate::test_methods::malicious_injector().id(), - vec![attacker_id], - vec![Nonce(0)], - instruction, - ) - .unwrap(); - - let witness_set = WitnessSet::for_message(&message, &[&attacker_key]); - let tx = crate::PublicTransaction::new(message, witness_set); - - let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0); - - assert!( - matches!( - result, - Err(LeeError::InvalidProgramBehavior( - InvalidProgramBehaviorError::InvalidAccountAuthorization { account_id } - )) if account_id == victim_id - ), - "attack transaction should be rejected with InvalidAccountAuthorization for the victim" - ); - - // Confirm the victim's balance is untouched. - let victim_balance_after = state.get_account_by_id(victim_id).balance; - let recipient_balance_after = state.get_account_by_id(recipient_id).balance; - - assert_eq!( - victim_balance_after, victim_balance, - "victim balance should be unchanged" - ); - assert_eq!( - recipient_balance_after, 0, - "recipient should receive nothing" - ); - } - - /// Regression test: a `PrivacyPreservingTransaction` carrying a structurally invalid - /// proof must be rejected with a clean `Err`. - #[test] - fn privacy_garbage_proof_is_rejected() { - use lee_core::{ - Commitment, - account::Account, - program::{BlockValidityWindow, TimestampValidityWindow}, - }; - - use crate::{ - PrivacyPreservingTransaction, - privacy_preserving_transaction::{ - circuit::Proof, message::Message, witness_set::WitnessSet, - }, - }; - - let state = V03State::new(); - - // Minimal message that passes every check up to proof verification: a single - // commitment satisfies the non-empty requirement, no signers makes the - // nonce/signature checks vacuously true, and unbounded validity windows are valid - // for any block/timestamp. - let account_id = AccountId::from(&PublicKey::new_from_private_key( - &PrivateKey::try_new([1_u8; 32]).unwrap(), - )); - let commitment = Commitment::new(&account_id, &Account::default()); - let message = Message { - public_account_ids: vec![], - nonces: vec![], - public_post_states: vec![], - encrypted_private_post_states: vec![], - new_commitments: vec![commitment], - new_nullifiers: vec![], - block_validity_window: BlockValidityWindow::new_unbounded(), - timestamp_validity_window: TimestampValidityWindow::new_unbounded(), - }; - - // Garbage proof bytes: not a valid borsh-encoded `InnerReceipt`. - let garbage_proof = Proof::from_inner(vec![0xff_u8; 64]); - let witness_set = WitnessSet::for_message(&message, garbage_proof, &[]); - let tx = PrivacyPreservingTransaction::new(message, witness_set); - - let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0); - - match result { - Err(LeeError::InvalidPrivacyPreservingProof) => {} - Err(other) => panic!("expected InvalidPrivacyPreservingProof, got {other:?}"), - Ok(_) => panic!("garbage proof was accepted instead of rejected"), - } - } -} diff --git a/lee/state_machine/src/validated_state_diff/mod.rs b/lee/state_machine/src/validated_state_diff/mod.rs new file mode 100644 index 00000000..ad80f63f --- /dev/null +++ b/lee/state_machine/src/validated_state_diff/mod.rs @@ -0,0 +1,546 @@ +use std::{ + collections::{HashMap, HashSet, VecDeque}, + hash::Hash, +}; + +use lee_core::{ + BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, Timestamp, + account::{Account, AccountId, AccountWithMetadata}, + program::{ + ChainedCall, Claim, DEFAULT_PROGRAM_ID, ProgramId, compute_public_authorized_pdas, + validate_execution, + }, +}; +use log::debug; + +use crate::{ + V03State, ensure, + error::{InvalidProgramBehaviorError, LeeError}, + privacy_preserving_transaction::{ + PrivacyPreservingTransaction, circuit::Proof, message::Message, + }, + program::Program, + program_deployment_transaction::ProgramDeploymentTransaction, + public_transaction::PublicTransaction, + state::MAX_NUMBER_CHAINED_CALLS, +}; + +pub struct StateDiff { + pub signer_account_ids: Vec, + pub public_diff: HashMap, + pub new_commitments: Vec, + pub new_nullifiers: Vec, + pub program: Option, +} + +/// The validated output of executing or verifying a transaction, ready to be applied to the state. +/// +/// It can only be constructed by the transaction validation functions inside this crate, ensuring +/// the diff has been checked before any state mutation occurs. Under the `test-utils` feature the +/// [`crate::test_utils`] module additionally exposes a hand-rolled constructor for unit-testing +/// downstream validation logic; that feature must never be enabled in a production build. +pub struct ValidatedStateDiff(StateDiff); + +#[cfg(feature = "test-utils")] +impl ValidatedStateDiff { + /// Test-only constructor that wraps an already-built [`StateDiff`] **without validating it**. + /// + /// Kept in this module so the wrapped field can stay private: in a normal build (feature off) + /// the only ways to obtain a `ValidatedStateDiff` remain the `from_*_transaction` validators. + #[must_use] + pub const fn new_unchecked(state_diff: StateDiff) -> Self { + Self(state_diff) + } +} + +impl ValidatedStateDiff { + pub fn from_public_transaction( + tx: &PublicTransaction, + state: &V03State, + block_id: BlockId, + timestamp: Timestamp, + ) -> Result { + let signer_account_ids = authenticate_public_transaction_signers(tx, state)?; + let message = tx.message(); + + ensure!( + !message.account_ids.is_empty(), + LeeError::InvalidInput("Public transaction must have at least one account".into()) + ); + + // All account_ids must be different + ensure!( + message.account_ids.iter().collect::>().len() == message.account_ids.len(), + LeeError::InvalidInput("Duplicate account_ids found in message".into(),) + ); + + // Build pre_states for execution + let input_pre_states: Vec<_> = message + .account_ids + .iter() + .map(|account_id| { + AccountWithMetadata::new( + state.get_account_by_id(*account_id), + signer_account_ids.contains(account_id), + *account_id, + ) + }) + .collect(); + + let mut state_diff: HashMap = HashMap::new(); + + let initial_call = ChainedCall { + program_id: message.program_id, + instruction_data: message.instruction_data.clone(), + pre_states: input_pre_states, + pda_seeds: vec![], + }; + + let initial_caller_data = CallerData { + program_id: None, + authorized_accounts: signer_account_ids.iter().copied().collect(), + }; + + let mut chained_calls = + VecDeque::<(ChainedCall, CallerData)>::from_iter([(initial_call, initial_caller_data)]); + let mut chain_calls_counter = 0; + + while let Some((chained_call, caller_data)) = chained_calls.pop_front() { + ensure!( + chain_calls_counter <= MAX_NUMBER_CHAINED_CALLS, + LeeError::MaxChainedCallsDepthExceeded + ); + + // Check that the `program_id` corresponds to a deployed program + let Some(program) = state.programs().get(&chained_call.program_id) else { + return Err(LeeError::InvalidInput("Unknown program".into())); + }; + + debug!( + "Program {:?} pre_states: {:?}, instruction_data: {:?}", + chained_call.program_id, chained_call.pre_states, chained_call.instruction_data + ); + let mut program_output = program.execute( + caller_data.program_id, + &chained_call.pre_states, + &chained_call.instruction_data, + )?; + debug!( + "Program {:?} output: {:?}", + chained_call.program_id, program_output + ); + + let authorized_pdas = + compute_public_authorized_pdas(caller_data.program_id, &chained_call.pda_seeds); + + // Account is authorized if it is either in the caller's authorized accounts or in the + // list of PDAs the caller has authorized. + let is_authorized = |account_id: &AccountId| { + authorized_pdas.contains(account_id) + || caller_data.authorized_accounts.contains(account_id) + }; + + for pre in &program_output.pre_states { + let account_id = pre.account_id; + // Check that the program output pre_states coincide with the values in the public + // state or with any modifications to those values during the chain of calls. + let expected_pre = state_diff + .get(&account_id) + .cloned() + .unwrap_or_else(|| state.get_account_by_id(account_id)); + ensure!( + pre.account == expected_pre, + InvalidProgramBehaviorError::InconsistentAccountPreState { + account_id, + expected: Box::new(expected_pre), + actual: Box::new(pre.account.clone()) + } + ); + + // Check that the program output pre_states marked as authorized are indeed + // authorized, and vice-versa. + let is_indeed_authorized = is_authorized(&account_id); + ensure!( + !pre.is_authorized || is_indeed_authorized, + InvalidProgramBehaviorError::InvalidAccountAuthorization { account_id } + ); + ensure!( + pre.is_authorized || !is_indeed_authorized, + InvalidProgramBehaviorError::AuthorizedAccountMarkedAsNotAuthorized { + account_id + } + ); + } + + // Verify that the program output's self_program_id matches the expected program ID. + ensure!( + program_output.self_program_id == chained_call.program_id, + InvalidProgramBehaviorError::MismatchedProgramId { + expected: chained_call.program_id, + actual: program_output.self_program_id + } + ); + + // Verify that the program output's caller_program_id matches the actual caller. + ensure!( + program_output.caller_program_id == caller_data.program_id, + InvalidProgramBehaviorError::MismatchedCallerProgramId { + expected: caller_data.program_id, + actual: program_output.caller_program_id, + } + ); + + // Verify execution corresponds to a well-behaved program. + // See the # Programs section for the definition of the `validate_execution` method. + validate_execution( + &program_output.pre_states, + &program_output.post_states, + chained_call.program_id, + ) + .map_err(InvalidProgramBehaviorError::ExecutionValidationFailed)?; + + // Verify validity window + ensure!( + program_output.block_validity_window.is_valid_for(block_id) + && program_output + .timestamp_validity_window + .is_valid_for(timestamp), + LeeError::OutOfValidityWindow + ); + + for (i, post) in program_output.post_states.iter_mut().enumerate() { + let Some(claim) = post.required_claim() else { + continue; + }; + let pre = &program_output.pre_states[i]; + let account_id = pre.account_id; + + // The invoked program can only claim accounts with default program id. + ensure!( + post.account().program_owner == DEFAULT_PROGRAM_ID, + InvalidProgramBehaviorError::ClaimedNonDefaultAccount { account_id } + ); + + match claim { + Claim::Authorized => { + // The program can only claim accounts that were authorized by the signer. + ensure!( + pre.is_authorized, + InvalidProgramBehaviorError::ClaimedUnauthorizedAccount { account_id } + ); + } + Claim::Pda(seed) => { + // The program can only claim accounts that correspond to the PDAs it is + // authorized to claim. The public-execution path only sees public + // accounts, so the public-PDA derivation is the correct formula here. + let pda = AccountId::for_public_pda(&chained_call.program_id, &seed); + ensure!( + account_id == pda, + InvalidProgramBehaviorError::MismatchedPdaClaim { + expected: pda, + actual: account_id + } + ); + } + } + + post.account_mut().program_owner = chained_call.program_id; + } + + // Update the state diff + for (pre, post) in program_output + .pre_states + .iter() + .zip(program_output.post_states.iter()) + { + state_diff.insert(pre.account_id, post.account().clone()); + } + + // Source from `program_output.pre_states`, not `chained_call.pre_states`: + // the loop above already gates program_output's `is_authorized` via the + // `!pre.is_authorized || is_indeed_authorized` check, while `chained_call. + // pre_states` is caller-controlled and can be forged (audit-issue 91). + // + // Union with the caller's authorized set so that authorization is monotonically + // growing: once an account is authorized at any point in the chain it remains + // authorized for all subsequent calls. + let authorized_accounts: HashSet<_> = caller_data + .authorized_accounts + .into_iter() + .chain( + program_output + .pre_states + .iter() + .filter(|pre| pre.is_authorized) + .map(|pre| pre.account_id), + ) + .collect(); + for new_call in program_output.chained_calls.into_iter().rev() { + chained_calls.push_front(( + new_call, + CallerData { + program_id: Some(chained_call.program_id), + authorized_accounts: authorized_accounts.clone(), + }, + )); + } + + chain_calls_counter = chain_calls_counter + .checked_add(1) + .expect("we check the max depth at the beginning of the loop"); + } + + // Check that all modified uninitialized accounts where claimed + for (account_id, post) in state_diff.iter().filter_map(|(account_id, post)| { + let pre = state.get_account_by_id(*account_id); + if pre.program_owner != DEFAULT_PROGRAM_ID { + return None; + } + if pre == *post { + return None; + } + Some((*account_id, post)) + }) { + ensure!( + post.program_owner != DEFAULT_PROGRAM_ID, + InvalidProgramBehaviorError::DefaultAccountModifiedWithoutClaim { account_id } + ); + } + + // Every account the caller declared as part of the transaction must appear in the final + // diff. + for account_id in &message.account_ids { + ensure!( + state_diff.contains_key(account_id), + InvalidProgramBehaviorError::DeclaredAccountMissingFromOutput { + account_id: *account_id + } + ); + } + + Ok(Self(StateDiff { + signer_account_ids, + public_diff: state_diff, + new_commitments: vec![], + new_nullifiers: vec![], + program: None, + })) + } + + pub fn from_privacy_preserving_transaction( + tx: &PrivacyPreservingTransaction, + state: &V03State, + block_id: BlockId, + timestamp: Timestamp, + ) -> Result { + let message = &tx.message; + let witness_set = &tx.witness_set; + + // 1. Commitments or nullifiers are non empty + ensure!( + !message.new_commitments.is_empty() || !message.new_nullifiers.is_empty(), + LeeError::InvalidInput( + "Empty commitments and empty nullifiers found in message".into(), + ) + ); + + // 2. Check there are no duplicate account_ids in the public_account_ids list. + ensure!( + n_unique(&message.public_account_ids) == message.public_account_ids.len(), + LeeError::InvalidInput("Duplicate account_ids found in message".into()) + ); + + // Check there are no duplicate nullifiers in the new_nullifiers list + ensure!( + n_unique( + &message + .new_nullifiers + .iter() + .map(|(n, _)| n) + .collect::>() + ) == message.new_nullifiers.len(), + LeeError::InvalidInput("Duplicate nullifiers found in message".into()) + ); + + // Check there are no duplicate commitments in the new_commitments list + ensure!( + n_unique(&message.new_commitments) == message.new_commitments.len(), + LeeError::InvalidInput("Duplicate commitments found in message".into()) + ); + + // 3. Nonce checks and Valid signatures + // Check exactly one nonce is provided for each signature + ensure!( + message.nonces.len() == witness_set.signatures_and_public_keys.len(), + LeeError::InvalidInput( + "Mismatch between number of nonces and signatures/public keys".into(), + ) + ); + + // Check the signatures are valid + ensure!( + witness_set.signatures_are_valid_for(message), + LeeError::InvalidInput("Invalid signature for given message and public key".into()) + ); + + let signer_account_ids = tx.signer_account_ids(); + // Check nonces corresponds to the current nonces on the public state. + for (account_id, nonce) in signer_account_ids.iter().zip(&message.nonces) { + let current_nonce = state.get_account_by_id(*account_id).nonce; + ensure!( + current_nonce == *nonce, + LeeError::InvalidInput("Nonce mismatch".into()) + ); + } + + // Verify validity window + ensure!( + message.block_validity_window.is_valid_for(block_id) + && message.timestamp_validity_window.is_valid_for(timestamp), + LeeError::OutOfValidityWindow + ); + + // Build pre_states for proof verification + let public_pre_states: Vec<_> = message + .public_account_ids + .iter() + .map(|account_id| { + AccountWithMetadata::new( + state.get_account_by_id(*account_id), + signer_account_ids.contains(account_id), + *account_id, + ) + }) + .collect(); + + // 4. Proof verification + check_privacy_preserving_circuit_proof_is_valid( + &witness_set.proof, + &public_pre_states, + message, + )?; + + // 5. Commitment freshness + state.check_commitments_are_new(&message.new_commitments)?; + + // 6. Nullifier uniqueness + state.check_nullifiers_are_valid(&message.new_nullifiers)?; + + let public_diff = message + .public_account_ids + .iter() + .copied() + .zip(message.public_post_states.clone()) + .collect(); + let new_nullifiers = message + .new_nullifiers + .iter() + .copied() + .map(|(nullifier, _)| nullifier) + .collect(); + + Ok(Self(StateDiff { + signer_account_ids, + public_diff, + new_commitments: message.new_commitments.clone(), + new_nullifiers, + program: None, + })) + } + + pub fn from_program_deployment_transaction( + tx: &ProgramDeploymentTransaction, + state: &V03State, + ) -> Result { + // TODO: remove clone + let program = Program::new(tx.message.bytecode.clone().into())?; + if state.programs().contains_key(&program.id()) { + return Err(LeeError::ProgramAlreadyExists); + } + Ok(Self(StateDiff { + signer_account_ids: vec![], + public_diff: HashMap::new(), + new_commitments: vec![], + new_nullifiers: vec![], + program: Some(program), + })) + } + + /// Returns the public account changes produced by this transaction. + /// + /// Used by callers (e.g. the sequencer) to inspect the diff before committing it, for example + /// to enforce that system accounts are not modified by user transactions. + #[must_use] + pub fn public_diff(&self) -> HashMap { + self.0.public_diff.clone() + } + + pub(crate) fn into_state_diff(self) -> StateDiff { + self.0 + } +} + +#[derive(Debug)] +struct CallerData { + program_id: Option, + authorized_accounts: HashSet, +} + +fn authenticate_public_transaction_signers( + tx: &PublicTransaction, + state: &V03State, +) -> Result, LeeError> { + let message = tx.message(); + let witness_set = tx.witness_set(); + + ensure!( + message.nonces.len() == witness_set.signatures_and_public_keys.len(), + LeeError::InvalidInput( + "Mismatch between number of nonces and signatures/public keys".into(), + ) + ); + + ensure!( + witness_set.is_valid_for(message), + LeeError::InvalidInput("Invalid signature for given message and public key".into()) + ); + + let signer_account_ids = tx.signer_account_ids(); + for (account_id, nonce) in signer_account_ids.iter().zip(&message.nonces) { + let current_nonce = state.get_account_by_id(*account_id).nonce; + ensure!( + current_nonce == *nonce, + LeeError::InvalidInput("Nonce mismatch".into()) + ); + } + + Ok(signer_account_ids) +} + +fn check_privacy_preserving_circuit_proof_is_valid( + proof: &Proof, + public_pre_states: &[AccountWithMetadata], + message: &Message, +) -> Result<(), LeeError> { + let output = PrivacyPreservingCircuitOutput { + public_pre_states: public_pre_states.to_vec(), + public_post_states: message.public_post_states.clone(), + encrypted_private_post_states: message.encrypted_private_post_states.clone(), + new_commitments: message.new_commitments.clone(), + new_nullifiers: message.new_nullifiers.clone(), + block_validity_window: message.block_validity_window, + timestamp_validity_window: message.timestamp_validity_window, + }; + proof + .is_valid_for(&output) + .then_some(()) + .ok_or(LeeError::InvalidPrivacyPreservingProof) +} + +fn n_unique(data: &[T]) -> usize { + let set: HashSet<&T> = data.iter().collect(); + set.len() +} + +#[cfg(test)] +mod tests; diff --git a/lee/state_machine/src/validated_state_diff/tests.rs b/lee/state_machine/src/validated_state_diff/tests.rs new file mode 100644 index 00000000..e8bb3f14 --- /dev/null +++ b/lee/state_machine/src/validated_state_diff/tests.rs @@ -0,0 +1,528 @@ +use std::collections::HashMap; + +use lee_core::account::{Account, AccountId, Nonce}; + +use crate::{ + PrivateKey, PublicKey, V03State, + error::{InvalidProgramBehaviorError, LeeError}, + program::Program, + public_transaction::{Message, WitnessSet}, + validated_state_diff::ValidatedStateDiff, +}; + +fn public_state_from_balances(initial_data: &[(AccountId, u128)]) -> HashMap { + initial_data + .iter() + .copied() + .map(|(account_id, balance)| { + ( + account_id, + Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance, + ..Account::default() + }, + ) + }) + .collect() +} + +#[test] +fn public_diff_reflects_a_successful_transfer() { + // A successful native transfer must record the debited sender in + // `public_diff()`. Catches the mutation that replaces `public_diff` with + // `HashMap::new()` (which would hide every account change). + let from_key = PrivateKey::try_new([1_u8; 32]).unwrap(); + let from = AccountId::from(&PublicKey::new_from_private_key(&from_key)); + let to_key = PrivateKey::try_new([2_u8; 32]).unwrap(); + let to = AccountId::from(&PublicKey::new_from_private_key(&to_key)); + + let state = V03State::new() + .with_public_accounts(public_state_from_balances(&[(from, 100)])) + .with_programs(std::iter::once( + crate::test_methods::simple_balance_transfer(), + )); + let program_id = crate::test_methods::simple_balance_transfer().id(); + let message = + Message::try_new(program_id, vec![from, to], vec![Nonce(0), Nonce(0)], 5_u128).unwrap(); + let witness_set = WitnessSet::for_message(&message, &[&from_key, &to_key]); + let tx = crate::PublicTransaction::new(message, witness_set); + + let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) + .expect("a valid native transfer must validate"); + let public_diff = diff.public_diff(); + + assert!( + public_diff.contains_key(&from), + "public_diff must contain the debited sender", + ); + assert_eq!( + public_diff[&from].balance, 95, + "sender balance in the diff must reflect the debit", + ); +} + +/// Privacy-path version of the authorization-injection attack. The test passes when the +/// attack is rejected and the victim's balance is left untouched. +/// +/// `execute_and_prove` succeeds because each inner receipt is individually valid and the +/// outer circuit faithfully commits whatever the attacker's program output says, including +/// `victim(is_authorized=true)`. The circuit has no access to chain state and cannot know +/// the victim never signed. +/// +/// The host-side validator is what catches the attack: it independently reconstructs +/// `public_pre_states` from chain state using `signer_account_ids.contains(victim_id) = false`, +/// so it expects `victim(is_authorized=false)`. The committed journal and the reconstructed +/// expected output diverge, `receipt.verify` fails, and `from_privacy_preserving_transaction` +/// returns an error before any state is applied. +#[test] +fn privacy_malicious_programs_cannot_drain_public_victim() { + use lee_core::{ + Commitment, InputAccountIdentity, + account::{Account, AccountWithMetadata}, + }; + + use crate::{ + PrivacyPreservingTransaction, + privacy_preserving_transaction::{ + circuit::{ProgramWithDependencies, execute_and_prove}, + message::Message, + witness_set::WitnessSet, + }, + state::{CommitmentSet, tests::test_private_account_keys_1}, + }; + + type InjectorInstruction = ( + lee_core::program::ProgramId, // p2_id + lee_core::program::ProgramId, // simple_balance_transfer_id + [u8; 32], // victim_id_raw + u128, // victim_balance + u128, // victim_nonce + lee_core::program::ProgramId, // victim_program_owner + [u8; 32], // recipient_id_raw + u128, // amount + ); + + // Attacker controls a private account. + let attacker_keys = test_private_account_keys_1(); + let attacker_id = + AccountId::for_regular_private_account(&attacker_keys.npk(), &attacker_keys.vpk(), 0); + + let victim_id = AccountId::new([20_u8; 32]); + let recipient_id = AccountId::new([42_u8; 32]); + let victim_balance = 5_000_u128; + + // genesis sets program_owner = simple_balance_transfer_program.id() on all accounts. + let state = V03State::new() + .with_public_accounts(public_state_from_balances(&[ + (victim_id, victim_balance), + (recipient_id, 0), + ])) + .with_programs([ + crate::test_methods::simple_balance_transfer(), + crate::test_methods::malicious_injector(), + crate::test_methods::malicious_launderer(), + ]); + + // Build attacker's private account and its local commitment tree. + let attacker_account = Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance: 100, + ..Account::default() + }; + let attacker_commitment = Commitment::new(&attacker_id, &attacker_account); + let mut commitment_set = CommitmentSet::with_capacity(1); + commitment_set.extend(std::slice::from_ref(&attacker_commitment)); + let membership_proof = commitment_set + .get_proof_for(&attacker_commitment) + .expect("attacker commitment must be in the set"); + + let attacker_pre = AccountWithMetadata::new(attacker_account, true, attacker_id); + + let victim_account = state.get_account_by_id(victim_id); + let instruction: InjectorInstruction = ( + crate::test_methods::malicious_launderer().id(), + crate::test_methods::simple_balance_transfer().id(), + *victim_id.value(), + victim_account.balance, + victim_account.nonce.0, + victim_account.program_owner, + *recipient_id.value(), + victim_balance, + ); + let instruction_data = Program::serialize_instruction(instruction).unwrap(); + + let p2 = crate::test_methods::malicious_launderer(); + let at = crate::test_methods::simple_balance_transfer(); + let program_with_deps = ProgramWithDependencies::new( + crate::test_methods::malicious_injector(), + [(p2.id(), p2), (at.id(), at)].into(), + ); + + // account_identities order must match self.pre_states as built by the circuit: + // [0] attacker — first seen in P1's program_output.pre_states + // [1] victim — first seen in simple_balance_transfer's program_output.pre_states + // [2] recipient — first seen in simple_balance_transfer's program_output.pre_states + let account_identities = vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: attacker_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: attacker_keys.nsk, + membership_proof, + identifier: 0, + }, + InputAccountIdentity::Public, // victim + InputAccountIdentity::Public, // recipient + ]; + + // execute_and_prove succeeds: all inner receipts are valid. + // The outer circuit commits victim(is_authorized=true) to its journal. + let (circuit_output, proof) = execute_and_prove( + vec![attacker_pre], + instruction_data, + account_identities, + &program_with_deps, + ) + .expect("execute_and_prove should succeed \u{2014} the programs execute correctly"); + + // public_account_ids lists the Public entries from account_identities, in order. + // The single ciphertext belongs to attacker's private account update. + let message = Message::try_from_circuit_output( + vec![victim_id, recipient_id], + vec![], // no public signers, no nonces + circuit_output, + ) + .unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[]); // no signatures + let tx = PrivacyPreservingTransaction::new(message, witness_set); + + let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0); + + assert!( + matches!(result, Err(LeeError::InvalidPrivacyPreservingProof)), + "attack privacy transaction should be rejected with InvalidPrivacyPreservingProof" + ); + assert_eq!(state.get_account_by_id(victim_id).balance, victim_balance); + assert_eq!(state.get_account_by_id(recipient_id).balance, 0); +} + +/// Private-victim variant of the authorization-injection attack. The test passes when the +/// attack is rejected and the recipient's balance remains zero. +/// +/// After the circuit's Vacant branch accepts the injected `victim(is_authorized=true)` +/// verbatim, the attacker must choose how to declare the victim in `account_identities`. +/// There are two routes, both closed: +/// +/// - **mask=1 (`PrivateAuthorizedUpdate`)**: the circuit derives `account_id = +/// AccountId::for_regular_private_account(&npk_from(nsk), identifier)` and asserts it matches +/// `pre_state.account_id`. Passing this check requires the victim's `nsk`, which the attacker +/// does not have. `execute_and_prove` panics inside the ZKVM and no proof is produced. +/// +/// - **mask=0 (`Public`)**: the circuit places the account in `public_pre_states` and +/// `execute_and_prove` succeeds. The host-side validator then reconstructs `public_pre_states` +/// from chain state; `state.get_account_by_id(victim_id)` returns the default account (balance=0) +/// because the victim has no public state entry. The committed journal and the reconstructed +/// expected output diverge, `receipt.verify` fails, and `from_privacy_preserving_transaction` +/// returns an error before any state is applied. This test exercises this route. +#[test] +fn privacy_malicious_programs_cannot_drain_private_victim() { + use lee_core::{ + Commitment, InputAccountIdentity, + account::{Account, AccountWithMetadata}, + }; + + use crate::{ + PrivacyPreservingTransaction, + privacy_preserving_transaction::{ + circuit::{ProgramWithDependencies, execute_and_prove}, + message::Message, + witness_set::WitnessSet, + }, + state::{ + CommitmentSet, + tests::{test_private_account_keys_1, test_private_account_keys_2}, + }, + }; + + type InjectorInstruction = ( + lee_core::program::ProgramId, // p2_id + lee_core::program::ProgramId, // simple_balance_transfer_id + [u8; 32], // victim_id_raw + u128, // victim_balance + u128, // victim_nonce + lee_core::program::ProgramId, // victim_program_owner + [u8; 32], // recipient_id_raw + u128, // amount + ); + + // Attacker controls a private account. + let attacker_keys = test_private_account_keys_1(); + let attacker_id = + AccountId::for_regular_private_account(&attacker_keys.npk(), &attacker_keys.vpk(), 0); + + // Victim is a private account — not registered in public chain state. + let victim_keys = test_private_account_keys_2(); + let victim_id = + AccountId::for_regular_private_account(&victim_keys.npk(), &victim_keys.vpk(), 0); + let victim_balance = 5_000_u128; + + let recipient_id = AccountId::new([42_u8; 32]); + + // Victim has no public state entry; only recipient is registered at genesis. + let state = V03State::new() + .with_public_accounts(public_state_from_balances(&[(recipient_id, 0)])) + .with_programs([ + crate::test_methods::simple_balance_transfer(), + crate::test_methods::malicious_injector(), + crate::test_methods::malicious_launderer(), + ]); + + // Build attacker's private account and its local commitment tree. + let attacker_account = Account { + program_owner: crate::test_methods::simple_balance_transfer().id(), + balance: 100, + ..Account::default() + }; + let attacker_commitment = Commitment::new(&attacker_id, &attacker_account); + let mut commitment_set = CommitmentSet::with_capacity(1); + commitment_set.extend(std::slice::from_ref(&attacker_commitment)); + let membership_proof = commitment_set + .get_proof_for(&attacker_commitment) + .expect("attacker commitment must be in the set"); + + let attacker_pre = AccountWithMetadata::new(attacker_account, true, attacker_id); + + // The attacker supplies the victim's account data directly — it cannot be read from + // public state. The injected balance and program_owner allow simple_balance_transfer + // to succeed inside the circuit, which has no access to chain state and cannot detect + // that these values are fabricated. + let instruction: InjectorInstruction = ( + crate::test_methods::malicious_launderer().id(), + crate::test_methods::simple_balance_transfer().id(), + *victim_id.value(), + victim_balance, + 0_u128, // nonce + crate::test_methods::simple_balance_transfer().id(), // program_owner + *recipient_id.value(), + victim_balance, + ); + let instruction_data = Program::serialize_instruction(instruction).unwrap(); + + let p2 = crate::test_methods::malicious_launderer(); + let at = crate::test_methods::simple_balance_transfer(); + let program_with_deps = ProgramWithDependencies::new( + crate::test_methods::malicious_injector(), + [(p2.id(), p2), (at.id(), at)].into(), + ); + + // account_identities order must match self.pre_states as built by the circuit: + // [0] attacker — first seen in P1's program_output.pre_states + // [1] victim — first seen in simple_balance_transfer's program_output.pre_states + // [2] recipient — first seen in simple_balance_transfer's program_output.pre_states + // + // Victim is marked Public: the attacker has no nsk for the victim's private account, + // so PrivateAuthorizedUpdate is not an option. + let account_identities = vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + vpk: attacker_keys.vpk(), + random_seed: [0; 32], + view_tag: 0, + nsk: attacker_keys.nsk, + membership_proof, + identifier: 0, + }, + InputAccountIdentity::Public, // victim — attacker lacks victim's nsk + InputAccountIdentity::Public, // recipient + ]; + + // execute_and_prove succeeds: simple_balance_transfer runs against the injected + // victim(balance=5000, is_authorized=true) and produces valid inner receipts. + // The outer circuit commits victim(is_authorized=true) to public_pre_states. + let (circuit_output, proof) = execute_and_prove( + vec![attacker_pre], + instruction_data, + account_identities, + &program_with_deps, + ) + .expect("execute_and_prove should succeed \u{2014} the programs execute correctly"); + + // public_account_ids lists the Public entries from account_identities, in order. + // The single ciphertext belongs to attacker's private account update. + let message = Message::try_from_circuit_output( + vec![victim_id, recipient_id], + vec![], // no public signers, no nonces + circuit_output, + ) + .unwrap(); + + let witness_set = WitnessSet::for_message(&message, proof, &[]); // no signatures + let tx = PrivacyPreservingTransaction::new(message, witness_set); + + let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0); + + assert!( + matches!(result, Err(LeeError::InvalidPrivacyPreservingProof)), + "attack on private victim should be rejected with InvalidPrivacyPreservingProof" + ); + // Victim has no public balance to check; confirming the recipient received nothing + // is sufficient to show no funds moved. + assert_eq!(state.get_account_by_id(recipient_id).balance, 0); +} + +/// Two malicious programs (injector + launderer) attempt to drain a victim's balance +/// without the victim signing anything. The test passes when the attack is rejected +/// and the victim's balance is left untouched. +/// +/// Attack flow: +/// Transaction (attacker signs) → P1 (`malicious_injector`) +/// → injects `victim(is_authorized=true)` into chained-call `pre_states` for P2 +/// P2 (`malicious_launderer`) +/// → outputs empty pre/post states, forwarding the forged flag to `simple_balance_transfer` +/// → if `authorized_accounts` were built from the injected `pre_states`, +/// `{victim}.contains(victim)` would pass and the transfer would execute. +/// +/// The validator must reject this: `authorized_accounts` must be derived from the +/// parent program's own validated `program_output.pre_states`, not from the chained-call +/// input, so a forged `is_authorized=true` flag is never trusted. +#[test] +fn malicious_programs_cannot_drain_victim_without_signature() { + // p2_id, simple_balance_transfer_id, victim_id_raw, victim_balance, victim_nonce, + // victim_program_owner, recipient_id_raw, amount. + // Primitives only — AccountId/Account cannot round-trip through instruction_data + // via risc0_zkvm::serde (SerializeDisplay issue). + type InjectorInstruction = ( + lee_core::program::ProgramId, // p2_id + lee_core::program::ProgramId, // simple_balance_transfer_id + [u8; 32], // victim_id_raw + u128, // victim_balance + u128, // victim_nonce + lee_core::program::ProgramId, // victim_program_owner + [u8; 32], // recipient_id_raw + u128, // amount + ); + + let attacker_key = PrivateKey::try_new([10; 32]).unwrap(); + let attacker_id = AccountId::from(&PublicKey::new_from_private_key(&attacker_key)); + + let victim_key = PrivateKey::try_new([20; 32]).unwrap(); + let victim_id = AccountId::from(&PublicKey::new_from_private_key(&victim_key)); + + let recipient_id = AccountId::new([42; 32]); + + let victim_balance = 5_000_u128; + let state = V03State::new() + .with_public_accounts(public_state_from_balances(&[ + (attacker_id, 100), + (victim_id, victim_balance), + (recipient_id, 0), + ])) + .with_programs([ + crate::test_methods::simple_balance_transfer(), + crate::test_methods::malicious_injector(), + crate::test_methods::malicious_launderer(), + ]); + + // Read victim state from chain, exactly as the attacker would. + let victim_account = state.get_account_by_id(victim_id); + + let instruction: InjectorInstruction = ( + crate::test_methods::malicious_launderer().id(), + crate::test_methods::simple_balance_transfer().id(), + *victim_id.value(), + victim_account.balance, + victim_account.nonce.0, + victim_account.program_owner, + *recipient_id.value(), + victim_balance, + ); + + let message = Message::try_new( + crate::test_methods::malicious_injector().id(), + vec![attacker_id], + vec![Nonce(0)], + instruction, + ) + .unwrap(); + + let witness_set = WitnessSet::for_message(&message, &[&attacker_key]); + let tx = crate::PublicTransaction::new(message, witness_set); + + let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0); + + assert!( + matches!( + result, + Err(LeeError::InvalidProgramBehavior( + InvalidProgramBehaviorError::InvalidAccountAuthorization { account_id } + )) if account_id == victim_id + ), + "attack transaction should be rejected with InvalidAccountAuthorization for the victim" + ); + + // Confirm the victim's balance is untouched. + let victim_balance_after = state.get_account_by_id(victim_id).balance; + let recipient_balance_after = state.get_account_by_id(recipient_id).balance; + + assert_eq!( + victim_balance_after, victim_balance, + "victim balance should be unchanged" + ); + assert_eq!( + recipient_balance_after, 0, + "recipient should receive nothing" + ); +} + +/// Regression test: a `PrivacyPreservingTransaction` carrying a structurally invalid +/// proof must be rejected with a clean `Err`. +#[test] +fn privacy_garbage_proof_is_rejected() { + use lee_core::{ + Commitment, + account::Account, + program::{BlockValidityWindow, TimestampValidityWindow}, + }; + + use crate::{ + PrivacyPreservingTransaction, + privacy_preserving_transaction::{ + circuit::Proof, message::Message, witness_set::WitnessSet, + }, + }; + + let state = V03State::new(); + + // Minimal message that passes every check up to proof verification: a single + // commitment satisfies the non-empty requirement, no signers makes the + // nonce/signature checks vacuously true, and unbounded validity windows are valid + // for any block/timestamp. + let account_id = AccountId::from(&PublicKey::new_from_private_key( + &PrivateKey::try_new([1_u8; 32]).unwrap(), + )); + let commitment = Commitment::new(&account_id, &Account::default()); + let message = Message { + public_account_ids: vec![], + nonces: vec![], + public_post_states: vec![], + encrypted_private_post_states: vec![], + new_commitments: vec![commitment], + new_nullifiers: vec![], + block_validity_window: BlockValidityWindow::new_unbounded(), + timestamp_validity_window: TimestampValidityWindow::new_unbounded(), + }; + + // Garbage proof bytes: not a valid borsh-encoded `InnerReceipt`. + let garbage_proof = Proof::from_inner(vec![0xff_u8; 64]); + let witness_set = WitnessSet::for_message(&message, garbage_proof, &[]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + + let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0); + + match result { + Err(LeeError::InvalidPrivacyPreservingProof) => {} + Err(other) => panic!("expected InvalidPrivacyPreservingProof, got {other:?}"), + Ok(_) => panic!("garbage proof was accepted instead of rejected"), + } +} diff --git a/lee/state_machine/test_methods/guest/src/bin/dropped_account.rs b/lee/state_machine/test_methods/guest/src/bin/dropped_account.rs new file mode 100644 index 00000000..348eefa8 --- /dev/null +++ b/lee/state_machine/test_methods/guest/src/bin/dropped_account.rs @@ -0,0 +1,35 @@ +use lee_core::program::{AccountPostState, ProgramInput, ProgramOutput, read_lee_inputs}; + +type Instruction = (); + +/// Silently drops the second account entirely from its own output: given two `pre_states`, it +/// returns only one `(pre, post)` pair, echoing the first account back unchanged. +/// +/// Differs from `missing_output` because the `pre_state` and `post_states` lengths match. We +/// simply drop the account from both before returning them as part of the program's output. +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + .. + }, + instruction_words, + ) = read_lee_inputs::(); + + let Ok([pre1, _pre2]) = <[_; 2]>::try_from(pre_states) else { + return; + }; + + let account_pre1 = pre1.account.clone(); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pre1], + vec![AccountPostState::new(account_pre1)], + ) + .write(); +} diff --git a/lez/chain_state/Cargo.toml b/lez/chain_state/Cargo.toml new file mode 100644 index 00000000..0a0610a5 --- /dev/null +++ b/lez/chain_state/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "chain_state" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +common.workspace = true +lee.workspace = true +lee_core.workspace = true +logos-blockchain-core.workspace = true +logos-blockchain-zone-sdk.workspace = true + +anyhow.workspace = true +borsh.workspace = true +futures.workspace = true +log.workspace = true +serde.workspace = true +thiserror.workspace = true +tokio.workspace = true + +[dev-dependencies] +testnet_initial_state.workspace = true +serde_json.workspace = true diff --git a/lez/chain_state/src/apply.rs b/lez/chain_state/src/apply.rs new file mode 100644 index 00000000..05134b88 --- /dev/null +++ b/lez/chain_state/src/apply.rs @@ -0,0 +1,308 @@ +//! The single validate-then-apply entry point shared by the sequencer and the +//! indexer. Pure and storage-free: callers apply on a scratch clone of state and +//! commit only on `Ok`. + +use common::{ + HashType, + block::{Block, BlockMeta}, + transaction::{LeeTransaction, clock_invocation}, +}; +use lee::{GENESIS_BLOCK_ID, V03State}; + +use crate::ingest_error::BlockIngestError; + +/// The parent the next block must chain on. +// `l1_slot` will be added here when the `ChainState` anchor layer lands. +#[derive(Debug, Clone)] +pub struct Tip { + pub block_id: u64, + pub hash: HashType, +} + +impl From<&Block> for Tip { + fn from(block: &Block) -> Self { + Self { + block_id: block.header.block_id, + hash: block.header.hash, + } + } +} + +impl From for Tip { + fn from(meta: BlockMeta) -> Self { + Self { + block_id: meta.id, + hash: meta.hash, + } + } +} + +impl From<&Tip> for BlockMeta { + fn from(tip: &Tip) -> Self { + Self { + id: tip.block_id, + hash: tip.hash, + } + } +} + +/// Outcome of feeding a parsed L2 block to a validated tip. +pub enum AcceptOutcome { + /// Chained and applied; the tip advances. + Applied, + /// A duplicate re-delivery of an already-applied block. No state change. + AlreadyApplied, + /// Did not chain or failed to apply; the tip stays frozen. + Parked(BlockIngestError), + /// Chained but failed to apply, possibly transiently + /// ([`BlockIngestError::is_retryable`]); nothing recorded, tip and state + /// untouched. The caller retries and parks once it gives up. + /// + /// TODO: Only the indexer's `accept_block` emits this today; the sequencer's + /// `ChainState` parks on all failures without retrying (see `on_follow`). + RetryableFailure(BlockIngestError), +} + +/// Validates `block` against `tip`, then applies it to `state`. +/// +/// Mutates `state` in place, so callers pass a scratch clone and commit on `Ok`. +pub fn apply_block( + tip: Option<&Tip>, + block: &Block, + state: &mut V03State, +) -> Result<(), BlockIngestError> { + validate_against_tip(tip, block)?; + apply_block_to_state(block, state)?; + Ok(()) +} + +/// Checks that `block` is the valid continuation of `tip`: hash integrity, +/// then block-id continuity, then `prev_block_hash` linkage. A `None` tip +/// (cold state) expects the genesis block. +pub fn validate_against_tip(tip: Option<&Tip>, block: &Block) -> Result<(), BlockIngestError> { + let computed = block.recompute_hash(); + if computed != block.header.hash { + return Err(BlockIngestError::HashMismatch { + computed, + header: block.header.hash, + }); + } + + match tip { + None => { + if block.header.block_id != GENESIS_BLOCK_ID { + return Err(BlockIngestError::UnexpectedBlockId { + expected: GENESIS_BLOCK_ID, + got: block.header.block_id, + }); + } + } + Some(tip) => { + let expected = tip + .block_id + .checked_add(1) + .expect("block id should not overflow"); + if block.header.block_id != expected { + return Err(BlockIngestError::UnexpectedBlockId { + expected, + got: block.header.block_id, + }); + } + if block.header.prev_block_hash != tip.hash { + return Err(BlockIngestError::BrokenChainLink { + expected_prev: tip.hash, + got_prev: block.header.prev_block_hash, + }); + } + } + } + Ok(()) +} + +/// Applies a block's transactions to `state`, mapping every failure to a +/// [`BlockIngestError`] so the caller can park rather than crash. Operates in +/// place; the caller commits only on `Ok`. +pub fn apply_block_to_state(block: &Block, state: &mut V03State) -> Result<(), BlockIngestError> { + let (clock_tx, user_txs) = block + .body + .transactions + .split_last() + .ok_or(BlockIngestError::EmptyBlock)?; + + let LeeTransaction::Public(clock_tx) = clock_tx else { + return Err(BlockIngestError::InvalidClockTransaction); + }; + if *clock_tx != clock_invocation(block.header.timestamp) { + return Err(BlockIngestError::InvalidClockTransaction); + } + + let is_genesis = block.header.block_id == GENESIS_BLOCK_ID; + for (tx_index, transaction) in user_txs.iter().enumerate() { + let state_transition = |err: anyhow::Error| BlockIngestError::StateTransition { + tx_index: tx_index.try_into().expect("tx index fits in u64"), + reason: format!("{err:#}"), + }; + if is_genesis { + let LeeTransaction::Public(public_tx) = transaction else { + return Err(BlockIngestError::NonPublicGenesisTransaction); + }; + state + .transition_from_public_transaction( + public_tx, + block.header.block_id, + block.header.timestamp, + ) + .map_err(|err| state_transition(err.into()))?; + } else { + transaction + .clone() + .execute_on_state(state, block.header.block_id, block.header.timestamp) + .map_err(|err| state_transition(err.into()))?; + } + } + + state + .transition_from_public_transaction(clock_tx, block.header.block_id, block.header.timestamp) + .map_err(|err| BlockIngestError::StateTransition { + tx_index: user_txs.len().try_into().expect("tx index fits in u64"), + reason: format!("{:#}", anyhow::Error::from(err)), + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use common::{ + block::HashableBlockData, + test_utils::{ + create_transaction_native_token_transfer, produce_dummy_block, + produce_dummy_empty_transaction, sequencer_sign_key_for_testing, + }, + }; + use testnet_initial_state::{initial_pub_accounts_private_keys, initial_state}; + + use super::*; + + fn tip_of(block: &Block) -> Tip { + Tip::from(block) + } + + #[test] + fn genesis_applies_on_empty_tip() { + let mut state = initial_state(); + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + } + + #[test] + fn non_genesis_first_block_is_unexpected_id() { + let mut state = initial_state(); + let block = produce_dummy_block(2, None, vec![]); + let err = apply_block(None, &block, &mut state).expect_err("should reject"); + assert!(matches!( + err, + BlockIngestError::UnexpectedBlockId { + expected: 1, + got: 2 + } + )); + } + + #[test] + fn skip_ahead_block_is_unexpected_id() { + let mut state = initial_state(); + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + + // Tip is at 1; a block with id 3 skips ahead. + let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]); + let err = + apply_block(Some(&tip_of(&genesis)), &bad, &mut state).expect_err("should reject"); + assert!(matches!( + err, + BlockIngestError::UnexpectedBlockId { + expected: 2, + got: 3 + } + )); + } + + #[test] + fn broken_chain_link_detected() { + let mut state = initial_state(); + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + + // Correct id (2), wrong parent hash. + let block2 = produce_dummy_block(2, Some(HashType([9_u8; 32])), vec![]); + let err = + apply_block(Some(&tip_of(&genesis)), &block2, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::BrokenChainLink { .. })); + } + + #[test] + fn hash_mismatch_detected() { + let mut state = initial_state(); + let mut genesis = produce_dummy_block(1, None, vec![]); + // Tampering with the header invalidates the stored hash. + genesis.header.timestamp = 999; + let err = apply_block(None, &genesis, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::HashMismatch { .. })); + } + + #[test] + fn empty_block_rejected() { + let mut state = initial_state(); + // A block with no transactions at all (not even the mandatory clock tx). + let block = HashableBlockData { + block_id: 1, + prev_block_hash: HashType([0_u8; 32]), + timestamp: 0, + transactions: vec![], + } + .into_pending_block(&sequencer_sign_key_for_testing()); + let err = apply_block(None, &block, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::EmptyBlock)); + } + + #[test] + fn missing_clock_tail_is_invalid_clock() { + let mut state = initial_state(); + // Last tx is not the expected clock invocation for the timestamp. + let block = HashableBlockData { + block_id: 1, + prev_block_hash: HashType([0_u8; 32]), + timestamp: 50, + transactions: vec![produce_dummy_empty_transaction()], + } + .into_pending_block(&sequencer_sign_key_for_testing()); + let err = apply_block(None, &block, &mut state).expect_err("should reject"); + assert!(matches!(err, BlockIngestError::InvalidClockTransaction)); + } + + #[test] + fn applies_transfers_and_advances_state() { + let mut state = initial_state(); + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + // Genesis (block 1): clock-only. + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + let mut tip = tip_of(&genesis); + + // Blocks 2..=11: one native transfer of 10 each (nonces 0..=9). + for i in 0..10_u64 { + let tx = create_transaction_native_token_transfer(from, i.into(), to, 10, &sign_key); + let block = produce_dummy_block(i + 2, Some(tip.hash), vec![tx]); + apply_block(Some(&tip), &block, &mut state).expect("transfer applies"); + tip = tip_of(&block); + } + + assert_eq!(state.get_account_by_id(from).balance, 9900); + assert_eq!(state.get_account_by_id(to).balance, 20100); + } +} diff --git a/lez/chain_state/src/chain.rs b/lez/chain_state/src/chain.rs new file mode 100644 index 00000000..39e1efba --- /dev/null +++ b/lez/chain_state/src/chain.rs @@ -0,0 +1,1034 @@ +//! Two-tier chain state: a reorg-able `head` the sequencer builds on, plus an +//! irreversible `final` tier. + +use common::block::Block; +use lee::V03State; +use log::warn; +use logos_blockchain_core::mantle::ops::channel::MsgId; +use logos_blockchain_zone_sdk::Slot; + +use crate::{ + AcceptOutcome, BlockIngestError, StallReason, + apply::{Tip, apply_block}, +}; + +/// A head block plus the channel message that carried it. +pub struct HeadEntry { + pub this_msg: MsgId, + pub block: Block, +} + +/// The head tier (reorg-able, from `adopted`/`orphaned`) over the final tier +/// (irreversible, from `finalized`). +/// +/// `head_state` is given by `final_state` replayed through `head_blocks`. +/// +/// Only the final tier stalls: an invalid `adopted` block just freezes the +/// head tip and self-heals via reorg or finalization. +pub struct ChainState { + final_state: V03State, + final_tip: Option, + final_stall: Option, + + head_state: V03State, + head_blocks: Vec, +} + +impl ChainState { + /// Fresh state anchored at the genesis/initial state, no blocks applied. + #[must_use] + pub fn new(initial_state: V03State) -> Self { + Self::from_final(initial_state, None) + } + + /// State restored from a persisted final tier; head mirrors final. + #[must_use] + pub fn from_final(final_state: V03State, final_tip: Option) -> Self { + Self { + head_state: final_state.clone(), + final_state, + final_tip, + head_blocks: Vec::new(), + final_stall: None, + } + } + + /// State the sequencer builds its next block on. + #[must_use] + pub const fn head_state(&self) -> &V03State { + &self.head_state + } + + /// Mutable access to the head state. Bypasses the `head_blocks` invariant, so + /// it is meant for tests and low-level callers. + #[must_use] + pub const fn head_state_mut(&mut self) -> &mut V03State { + &mut self.head_state + } + + #[must_use] + pub const fn final_state(&self) -> &V03State { + &self.final_state + } + + /// Parent the next produced block must chain on. + #[must_use] + pub fn head_tip(&self) -> Option { + self.head_blocks + .last() + .map(|entry| Tip::from(&entry.block)) + .or_else(|| self.final_tip.clone()) + } + + #[must_use] + pub fn final_tip(&self) -> Option { + self.final_tip.clone() + } + + #[must_use] + pub const fn final_stall(&self) -> Option<&StallReason> { + self.final_stall.as_ref() + } + + /// Position of a head entry, matched by `MsgId` or block hash (restored + /// entries carry sentinel `MsgId`s; re-inscriptions arrive under fresh ones) + /// — always at the same claimed height: a hash or `MsgId` collision with a + /// different `block_id` is malformed and must fall through to validation. + fn head_position_of(&self, this_msg: MsgId, block: &Block) -> Option { + self.head_blocks.iter().position(|entry| { + entry.block.header.block_id == block.header.block_id + && (entry.this_msg == this_msg || entry.block.header.hash == block.header.hash) + }) + } + + /// Applies an adopted head block. + /// + /// The adopted stream is authoritative: a competitor at a height + /// the head already holds reorgs the head back to that height with + /// no orphan event required. + /// + /// On failure the head stays unchanged and no stall is recorded. + pub fn apply_adopted(&mut self, this_msg: MsgId, block: &Block) -> AcceptOutcome { + if self.head_position_of(this_msg, block).is_some() { + return AcceptOutcome::AlreadyApplied; + } + + // If we receive a pre-final adoption, its an SDK fault; just log and ignore it + if let Some(final_tip) = &self.final_tip + && block.header.block_id <= final_tip.block_id + { + // The final tier is irreversible: a matching block here is a stale + // re-delivery, a conflicting one an SDK contract breach. + if block.header.block_id == final_tip.block_id && block.header.hash != final_tip.hash { + warn!( + "Ignoring adopted block {} with hash {} conflicting with the \ + finalized block ({}) at this height", + block.header.block_id, block.header.hash, final_tip.hash + ); + } + return AcceptOutcome::AlreadyApplied; + } + + // A tip extension applies on the current head state, a lower-or-equal + // id rebuilds the state at the competitor's parent instead. + // + // If adoptions are over the current head, `reorg_at` is None. + let reorg_at = self + .head_blocks + .iter() + .position(|entry| entry.block.header.block_id >= block.header.block_id); + let (mut scratch, tip) = match reorg_at { + // continue from the tip + None => (self.head_state.clone(), self.head_tip()), + // reorg upto `idx` + Some(idx) => self.replay_head_prefix(idx), + }; + + match apply_block(tip.as_ref(), block, &mut scratch) { + Ok(()) => { + // now that `apply_block` succeeded, actually reorg the head + if let Some(idx) = reorg_at { + self.head_blocks.truncate(idx); + } + self.head_state = scratch; + self.head_blocks.push(HeadEntry { + this_msg, + block: block.to_owned(), + }); + AcceptOutcome::Applied + } + Err(err) => AcceptOutcome::Parked(err), + } + } + + /// Applies a block we produced ourselves. + /// + /// Unlike [`Self::apply_adopted`] this never reorgs: our block is not on + /// the channel yet, so it may only *extend* the head. A head already at + /// (or past) this height means a peer's block won the race on the + /// channel — ours is stale and the caller drops it. + pub fn apply_produced(&mut self, this_msg: MsgId, block: &Block) -> AcceptOutcome { + if self + .head_tip() + .is_some_and(|tip| block.header.block_id <= tip.block_id) + { + return AcceptOutcome::AlreadyApplied; + } + self.apply_adopted(this_msg, block) + } + + /// Reverts an orphaned head block and everything after it, then re-derives head. + pub fn revert_orphan(&mut self, this_msg: MsgId, block: &Block) { + if let Some(idx) = self.head_position_of(this_msg, block) { + self.head_blocks.truncate(idx); + self.rederive_head(); + } + } + + /// One channel update: revert every `orphaned` (one truncate + re-derive), + /// then apply every `adopted` in order. Outcomes align with `adopted`. + pub fn apply_channel_update( + &mut self, + orphaned: &[(MsgId, Block)], + adopted: &[(MsgId, Block)], + ) -> Vec { + let earliest = orphaned + .iter() + .filter_map(|(msg, block)| self.head_position_of(*msg, block)) + .min(); + if let Some(idx) = earliest { + self.head_blocks.truncate(idx); + self.rederive_head(); + } + adopted + .iter() + .map(|(msg, block)| self.apply_adopted(*msg, block)) + .collect() + } + + /// Rebuilds one head entry from a persisted block, applying it in place (the + /// caller treats `Err` as fatal). + /// + /// The entry gets a hash-derived sentinel `MsgId` (the real one is not + /// persisted — that would need a sidecar `block_id -> MsgId` cell); later + /// orphan/finalize events correlate by block hash. + pub fn restore_head_block(&mut self, block: Block) -> Result<(), BlockIngestError> { + apply_block(self.head_tip().as_ref(), &block, &mut self.head_state)?; + let this_msg = MsgId::from(block.header.hash.0); + self.head_blocks.push(HeadEntry { this_msg, block }); + Ok(()) + } + + /// A finalized inscription. In steady state the block is already in head and is + /// moved into `final`; on backfill (not in head) it is applied directly and may + /// set `final_stall`. + pub fn apply_finalized( + &mut self, + this_msg: MsgId, + block: &Block, + l1_slot: Slot, + ) -> AcceptOutcome { + // Match by `MsgId` or block hash (re-inscriptions, restored entries). + if let Some(idx) = self.head_position_of(this_msg, block) { + self.finalize_through(idx); + return AcceptOutcome::Applied; + } + + // Finality is prefix-monotone: a finalized block chaining on an + // unfinalized head entry finalizes that prefix too. + if let Some(idx) = self + .head_blocks + .iter() + .position(|entry| entry.block.header.hash == block.header.prev_block_hash) + { + self.finalize_through(idx); + } + self.apply_finalized_direct(block, l1_slot) + } + + /// Moves `head_blocks[0..=idx]` into the final tier (already validated in head). + fn finalize_through(&mut self, idx: usize) { + let finalized: Vec = self.head_blocks.drain(0..=idx).collect(); + for entry in finalized { + apply_block(self.final_tip.as_ref(), &entry.block, &mut self.final_state) + .expect("validated head block must apply to the final tier"); + self.final_tip = Some(Tip::from(&entry.block)); + } + self.final_stall = None; + } + + /// Applies a finalized block straight to the final tier. On success the + /// finalized chain is authoritative, so head rebases onto it. + fn apply_finalized_direct(&mut self, block: &Block, l1_slot: Slot) -> AcceptOutcome { + // A finalized block at or below the final tip is a re-delivery: + // idempotent. A *different* block at the tip height falls through + // to validation and parks. + if let Some(tip) = &self.final_tip + && (block.header.block_id < tip.block_id + || (block.header.block_id == tip.block_id && block.header.hash == tip.hash)) + { + return AcceptOutcome::AlreadyApplied; + } + + let mut scratch = self.final_state.clone(); + match apply_block(self.final_tip.as_ref(), block, &mut scratch) { + Ok(()) => { + self.final_state = scratch; + self.final_tip = Some(Tip::from(block)); + self.final_stall = None; + // Any head suffix dropped here was already reverted as + // `orphaned` earlier in the same channel update (the sdk + // orders orphans before their finalized replacement), so its + // txs are back in the caller's mempool. + self.head_blocks.clear(); + self.head_state = self.final_state.clone(); + AcceptOutcome::Applied + } + Err(err) => { + self.record_final_stall(block, l1_slot, err.clone()); + AcceptOutcome::Parked(err) + } + } + } + + /// Rebuilds `head_state` from the final tier plus the current `head_blocks`. + fn rederive_head(&mut self) { + self.head_state = self.replay_head_prefix(self.head_blocks.len()).0; + } + + /// State and tip after replaying `head_blocks[..count]` on the final tier. + fn replay_head_prefix(&self, count: usize) -> (V03State, Option) { + let mut state = self.final_state.clone(); + let mut tip = self.final_tip.clone(); + for entry in &self.head_blocks[..count] { + apply_block(tip.as_ref(), &entry.block, &mut state) + .expect("validated head blocks must replay"); + tip = Some(Tip::from(&entry.block)); + } + (state, tip) + } + + /// First stall is stored verbatim; later ones only bump `orphans_since`. + fn record_final_stall(&mut self, block: &Block, l1_slot: Slot, error: BlockIngestError) { + self.final_stall = Some(self.final_stall.take().map_or_else( + || StallReason::new(Some(&block.header), l1_slot, error), + StallReason::escalate, + )); + } +} + +#[cfg(test)] +mod tests { + use common::{ + HashType, + test_utils::{create_transaction_native_token_transfer, produce_dummy_block}, + }; + use testnet_initial_state::{initial_pub_accounts_private_keys, initial_state}; + + use super::*; + + fn msg(n: u8) -> MsgId { + MsgId::from([n; 32]) + } + + fn slot(n: u64) -> Slot { + Slot::from(n) + } + + /// `head_state` equals `final_state` replayed through `head_blocks`. + fn assert_head_matches_replay(chain: &ChainState) { + let mut state = chain.final_state.clone(); + let mut tip = chain.final_tip.clone(); + for entry in &chain.head_blocks { + apply_block(tip.as_ref(), &entry.block, &mut state).expect("head blocks must replay"); + tip = Some(Tip::from(&entry.block)); + } + assert_eq!( + borsh::to_vec(&state).expect("state serializes"), + borsh::to_vec(chain.head_state()).expect("state serializes"), + "head_state must equal final_state replayed through head_blocks" + ); + } + + #[test] + fn adopted_blocks_advance_head() { + let mut chain = ChainState::new(initial_state()); + + let genesis = produce_dummy_block(1, None, vec![]); + assert!(matches!( + chain.apply_adopted(msg(1), &genesis), + AcceptOutcome::Applied + )); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(2), &block2), + AcceptOutcome::Applied + )); + + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + // Nothing finalized yet. + assert!(chain.final_tip().is_none()); + } + + #[test] + fn adopted_bad_block_freezes_head_without_stall() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + // Skips ahead (id 3 while head tip is 1). + let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(3), &bad), + AcceptOutcome::Parked(BlockIngestError::UnexpectedBlockId { + expected: 2, + got: 3 + }) + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 1); + assert!( + chain.final_stall().is_none(), + "head freeze records no stall" + ); + } + + #[test] + fn adopted_is_idempotent() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + assert!(matches!( + chain.apply_adopted(msg(1), &genesis), + AcceptOutcome::AlreadyApplied + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 1); + } + + #[test] + fn orphan_reverts_head() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(msg(3), &block3); + + chain.revert_orphan(msg(3), &block3); + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + + // A competing block 3 now applies cleanly on block 2. + let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(13), &block3_prime), + AcceptOutcome::Applied + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + } + + #[test] + fn channel_update_reverts_then_applies() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(msg(3), &block3); + + let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![]); + let outcomes = chain.apply_channel_update(&[(msg(3), block3)], &[(msg(13), block3_prime)]); + assert!(matches!(outcomes.as_slice(), [AcceptOutcome::Applied])); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + } + + #[test] + fn finalize_moves_head_into_final() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(msg(3), &block3); + + // Finalize through block 2. + assert!(matches!( + chain.apply_finalized(msg(2), &block2, slot(100)), + AcceptOutcome::Applied + )); + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + // Head tip unchanged; head still ends at 3. + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + } + + #[test] + fn backfill_applies_directly_to_final() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + assert!(matches!( + chain.apply_finalized(msg(1), &genesis, slot(10)), + AcceptOutcome::Applied + )); + assert_eq!(chain.final_tip().expect("final tip").block_id, 1); + // Head mirrors final during backfill. + assert_eq!(chain.head_tip().expect("head tip").block_id, 1); + } + + #[test] + fn invalid_finalized_block_sets_final_stall() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + + // Skip-ahead finalized block, not in head: parks the final tier. + let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_finalized(msg(3), &bad, slot(20)), + AcceptOutcome::Parked(_) + )); + let stall = chain.final_stall().expect("final stall recorded"); + assert_eq!(stall.block_id, Some(3)); + } + + #[test] + fn orphaning_a_suffix_rederives_head_state() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + chain.apply_adopted(msg(2), &block2); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + chain.apply_adopted(msg(3), &block3); + let tx4 = create_transaction_native_token_transfer(from, 2, to, 10, &sign_key); + let block4 = produce_dummy_block(4, Some(block3.header.hash), vec![tx4]); + chain.apply_adopted(msg(4), &block4); + + // Orphaning block 3 drops the whole suffix (3 and 4). + chain.revert_orphan(msg(3), &block3); + + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + assert_eq!(chain.head_state().get_account_by_id(from).balance, 9990); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010); + assert_head_matches_replay(&chain); + } + + #[test] + fn channel_update_replaces_multi_block_suffix() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + chain.apply_adopted(msg(2), &block2); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + chain.apply_adopted(msg(3), &block3); + let tx4 = create_transaction_native_token_transfer(from, 2, to, 10, &sign_key); + let block4 = produce_dummy_block(4, Some(block3.header.hash), vec![tx4]); + chain.apply_adopted(msg(4), &block4); + + // A competing branch replaces blocks 3 and 4; orphans arrive unordered. + let tx3_prime = create_transaction_native_token_transfer(from, 1, to, 20, &sign_key); + let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![tx3_prime]); + let tx4_prime = create_transaction_native_token_transfer(from, 2, to, 30, &sign_key); + let block4_prime = produce_dummy_block(4, Some(block3_prime.header.hash), vec![tx4_prime]); + + let outcomes = chain.apply_channel_update( + &[(msg(4), block4), (msg(3), block3)], + &[(msg(13), block3_prime), (msg(14), block4_prime)], + ); + + assert!(matches!( + outcomes.as_slice(), + [AcceptOutcome::Applied, AcceptOutcome::Applied] + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 4); + assert_eq!(chain.head_state().get_account_by_id(from).balance, 9940); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20060); + assert_head_matches_replay(&chain); + } + + #[test] + fn adopted_only_channel_update_replaces_suffix() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + chain.apply_adopted(msg(2), &block2); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + chain.apply_adopted(msg(3), &block3); + + // The replacement branch arrives with no orphan events: the adopted + // list alone reorgs the head. + let tx2_prime = create_transaction_native_token_transfer(from, 0, to, 20, &sign_key); + let block2_prime = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2_prime]); + let tx3_prime = create_transaction_native_token_transfer(from, 1, to, 30, &sign_key); + let block3_prime = produce_dummy_block(3, Some(block2_prime.header.hash), vec![tx3_prime]); + + let outcomes = + chain.apply_channel_update(&[], &[(msg(12), block2_prime), (msg(13), block3_prime)]); + + assert!(matches!( + outcomes.as_slice(), + [AcceptOutcome::Applied, AcceptOutcome::Applied] + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20050); + assert_head_matches_replay(&chain); + } + + #[test] + fn channel_update_ignores_unknown_orphan() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + let unknown = produce_dummy_block(9, Some(HashType([7; 32])), vec![]); + let outcomes = chain.apply_channel_update(&[(msg(99), unknown)], &[(msg(3), block3)]); + + assert!(matches!(outcomes.as_slice(), [AcceptOutcome::Applied])); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert_head_matches_replay(&chain); + } + + #[test] + fn adopted_competitor_reorgs_head_without_orphan_event() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + chain.apply_adopted(msg(2), &block2); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + chain.apply_adopted(msg(3), &block3); + + // A valid competitor at height 2, no orphan events: the head reorgs + // back onto it, dropping the old 2..=3 suffix and its transfers. + let block2_prime = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(12), &block2_prime), + AcceptOutcome::Applied + )); + let tip = chain.head_tip().expect("head tip"); + assert_eq!(tip.block_id, 2); + assert_eq!(tip.hash, block2_prime.header.hash); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20000); + assert_head_matches_replay(&chain); + } + + #[test] + fn produced_block_losing_a_race_does_not_reorg_the_head() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + // A peer's block wins height 2 on the channel. + let peer = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_adopted(msg(2), &peer); + + // Our own block at that height is not on the channel, so — unlike an + // adopted competitor — it must not reorg the head onto itself. + let tx = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let ours = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]); + assert!(matches!( + chain.apply_produced(msg(12), &ours), + AcceptOutcome::AlreadyApplied + )); + assert_eq!(chain.head_tip().expect("head tip").hash, peer.header.hash); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20000); + assert_head_matches_replay(&chain); + } + + #[test] + fn produced_block_extending_the_head_applies() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + let ours = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_produced(msg(2), &ours), + AcceptOutcome::Applied + )); + assert_eq!(chain.head_tip().expect("head tip").hash, ours.header.hash); + assert_head_matches_replay(&chain); + } + + #[test] + fn invalid_adopted_competitor_leaves_head_intact() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(msg(3), &block3); + + // A competitor at height 2 with a bogus parent parks; the truncation + // is not committed, so the 2..=3 suffix survives. + let bad = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + assert!(matches!( + chain.apply_adopted(msg(12), &bad), + AcceptOutcome::Parked(BlockIngestError::BrokenChainLink { .. }) + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert_head_matches_replay(&chain); + } + + #[test] + fn adopted_conflicting_with_final_tip_is_ignored() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + chain.apply_finalized(msg(2), &block2, slot(20)); + + // Finalized is irreversible: an adopted competitor at (or below) the + // final tip is ignored, not reorged onto. + let block2_prime = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(12), &block2_prime), + AcceptOutcome::AlreadyApplied + )); + assert_eq!( + chain.final_tip().expect("final tip").hash, + block2.header.hash + ); + assert_eq!(chain.head_tip().expect("head tip").hash, block2.header.hash); + assert_head_matches_replay(&chain); + } + + #[test] + fn restore_head_block_rebuilds_head_and_correlates_by_hash() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + // Restart shape: final tier from a persisted snapshot, head rebuilt from + // stored blocks under hash-derived sentinel MsgIds. + let mut state = initial_state(); + let genesis = produce_dummy_block(1, None, vec![]); + apply_block(None, &genesis, &mut state).expect("genesis applies"); + let mut chain = ChainState::from_final(state, Some(Tip::from(&genesis))); + + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + for block in [&block2, &block3] { + chain + .restore_head_block(block.clone()) + .expect("stored blocks must replay"); + } + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert_head_matches_replay(&chain); + + // The L1 orphans restored block 3 under its real (unknown-to-us) MsgId: + // correlated by hash, the revert works and a competitor applies. + chain.revert_orphan(msg(33), &block3); + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + + let block3_prime = produce_dummy_block(3, Some(block2.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(13), &block3_prime), + AcceptOutcome::Applied + )); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010); + assert_head_matches_replay(&chain); + } + + #[test] + fn restore_head_block_rejects_non_chaining_block() { + let mut chain = ChainState::new(initial_state()); + let skipped = produce_dummy_block(3, Some(HashType([9; 32])), vec![]); + assert!(chain.restore_head_block(skipped).is_err()); + } + + #[test] + fn finalized_hash_alias_with_wrong_id_is_not_absorbed() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + // A malformed message reusing genesis's hash under a different claimed + // id must not match the held entry as a re-delivery; it falls through + // to validation and parks. + let mut alias = genesis.clone(); + alias.header.block_id = 6; + assert!(matches!( + chain.apply_finalized(msg(66), &alias, slot(10)), + AcceptOutcome::Parked(_) + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 1); + assert!(chain.final_tip().is_none()); + assert_head_matches_replay(&chain); + } + + #[test] + fn finalized_reinscription_matches_by_block_hash() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + chain.apply_adopted(msg(3), &block3); + + // Block 2 finalizes re-inscribed under a fresh MsgId: matched by hash, + // finalized through, and the head above it survives. + assert!(matches!( + chain.apply_finalized(msg(42), &block2, slot(5)), + AcceptOutcome::Applied + )); + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert!(chain.final_stall().is_none()); + assert_head_matches_replay(&chain); + } + + #[test] + fn finalize_through_preserves_head_state_and_advances_final_state() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + let tx2 = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + chain.apply_adopted(msg(2), &block2); + let tx3 = create_transaction_native_token_transfer(from, 1, to, 10, &sign_key); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + chain.apply_adopted(msg(3), &block3); + + chain.apply_finalized(msg(2), &block2, slot(10)); + + // Head still reflects both transfers + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20020); + // ...while final reflects only the finalized prefix. + assert_eq!(chain.final_state().get_account_by_id(to).balance, 20010); + assert_head_matches_replay(&chain); + } + + #[test] + fn head_self_heals_with_valid_competitor_after_park() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + // Correct id, wrong parent: parked, head frozen at 1, no stall. + let bad = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + assert!(matches!( + chain.apply_adopted(msg(2), &bad), + AcceptOutcome::Parked(BlockIngestError::BrokenChainLink { .. }) + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 1); + assert!(chain.final_stall().is_none()); + + // A valid competitor at the same height applies without any reorg event. + let good = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_adopted(msg(12), &good), + AcceptOutcome::Applied + )); + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + assert_head_matches_replay(&chain); + } + + #[test] + fn repeated_invalid_finalized_bumps_orphans_since() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + + let bad3 = produce_dummy_block(3, Some(genesis.header.hash), vec![]); + chain.apply_finalized(msg(3), &bad3, slot(20)); + let bad5 = produce_dummy_block(5, Some(bad3.header.hash), vec![]); + assert!(matches!( + chain.apply_finalized(msg(5), &bad5, slot(30)), + AcceptOutcome::Parked(_) + )); + + let stall = chain.final_stall().expect("final stall recorded"); + assert_eq!(stall.block_id, Some(3), "first stall reason is preserved"); + assert_eq!(stall.orphans_since, 1); + } + + #[test] + fn valid_finalized_successor_clears_final_stall() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + + let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]); + chain.apply_finalized(msg(3), &bad, slot(20)); + assert!(chain.final_stall().is_some()); + + // The valid successor of the frozen final tip finalizes: stall clears. + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + chain.apply_finalized(msg(2), &block2, slot(30)), + AcceptOutcome::Applied + )); + assert!(chain.final_stall().is_none()); + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + assert_head_matches_replay(&chain); + } + + #[test] + fn finalized_successor_of_head_entry_finalizes_the_prefix() { + // Head holds unfinalized blocks 1..=2 (e.g. restored after a restart); + // a peer block 3 we never saw adopted arrives finalized. Its ancestry + // finalizes our prefix implicitly, then 3 applies to final directly. + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_adopted(msg(2), &block2); + assert!(chain.final_tip().is_none()); + + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + assert!(matches!( + chain.apply_finalized(msg(3), &block3, slot(10)), + AcceptOutcome::Applied + )); + assert_eq!(chain.final_tip().expect("final tip").block_id, 3); + assert_eq!(chain.head_tip().expect("head tip").block_id, 3); + assert!(chain.final_stall().is_none()); + assert_head_matches_replay(&chain); + } + + #[test] + fn finalized_redelivery_at_or_below_final_tip_is_already_applied() { + // Restart shape: the store's tip (incl. not-yet-finalized blocks) is + // restored as the final tier, so their later finalization arrives for + // blocks that were never in `head_blocks`. + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + chain.apply_finalized(msg(2), &block2, slot(20)); + + // Below the tip, and at the tip with a matching hash: idempotent. + assert!(matches!( + chain.apply_finalized(msg(41), &genesis, slot(30)), + AcceptOutcome::AlreadyApplied + )); + assert!(matches!( + chain.apply_finalized(msg(42), &block2, slot(30)), + AcceptOutcome::AlreadyApplied + )); + assert!(chain.final_stall().is_none()); + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + assert_head_matches_replay(&chain); + } + + #[test] + fn conflicting_finalized_at_final_tip_parks() { + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_finalized(msg(1), &genesis, slot(10)); + chain.apply_finalized(msg(2), &block2, slot(20)); + + // A different finalized block at the final height: finalized is + // irreversible, so this is a genuine stall, not a re-delivery. + let block2_prime = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + assert!(matches!( + chain.apply_finalized(msg(22), &block2_prime, slot(30)), + AcceptOutcome::Parked(_) + )); + assert!(chain.final_stall().is_some()); + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + } + + #[test] + fn finalized_unknown_block_rebases_head() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + chain.apply_finalized(msg(1), &genesis, slot(10)); + + // Head advances on a competing branch… + let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + chain.apply_adopted(msg(2), &block2a); + + // …but a different block 2 finalizes. The finalized chain is + // authoritative, so head rebases onto it. + let tx = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2b = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]); + assert!(matches!( + chain.apply_finalized(msg(22), &block2b, slot(20)), + AcceptOutcome::Applied + )); + + assert_eq!(chain.final_tip().expect("final tip").block_id, 2); + assert_eq!(chain.head_tip().expect("head tip").block_id, 2); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010); + assert_head_matches_replay(&chain); + } + + #[test] + fn head_state_reflects_applied_transfers() { + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let mut chain = ChainState::new(initial_state()); + let genesis = produce_dummy_block(1, None, vec![]); + chain.apply_adopted(msg(1), &genesis); + + let tx = create_transaction_native_token_transfer(from, 0, to, 10, &sign_key); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]); + chain.apply_adopted(msg(2), &block2); + + assert_eq!(chain.head_state().get_account_by_id(from).balance, 9990); + assert_eq!(chain.head_state().get_account_by_id(to).balance, 20010); + } +} diff --git a/lez/chain_state/src/consistency.rs b/lez/chain_state/src/consistency.rs new file mode 100644 index 00000000..5c87fcad --- /dev/null +++ b/lez/chain_state/src/consistency.rs @@ -0,0 +1,453 @@ +//! Startup check that a local store still belongs to the chain the connected +//! channel serves. + +use anyhow::Result; +use common::{HashType, block::Block}; +use futures::StreamExt as _; +use lee_core::BlockId; +use log::warn; +use logos_blockchain_core::mantle::ops::channel::ChannelId; +use logos_blockchain_zone_sdk::{Slot, ZoneMessage, adapter, indexer::ZoneIndexer}; + +/// Upper bound on the channel reads of the startup consistency check. +const CHANNEL_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + +/// Result of comparing a caller's stored chain against the channel. +pub enum ChainConsistency { + /// Channel still serves our anchor block (the stored tip position, or the + /// parked block while stalled). + Consistent, + /// We could not determine the outcome due to one of: + /// + /// - cold store (no anchor to compare at) + /// - the channel served only blocks newer than the anchor + /// - or the channel read was inconclusive (timeout / error / empty stream) + /// + /// NOTE: None of these prove a reset, so the caller proceeds. + /// A genuine divergence is still caught later when applying the channel history. + Inconclusive, + /// Positive evidence that the channel is a different chain than the store. + /// + /// Details in [`ChainMismatch`], and impl's Display trait. + Inconsistent(ChainMismatch), +} + +/// The evidence behind a [`ChainConsistency::Inconsistent`]. +pub enum ChainMismatch { + /// The channel serves a different block at the anchor's id. + Block { + ours: (BlockId, HashType), + channel: (BlockId, HashType), + }, + /// The channel serves a block at/below the anchor's id past the anchor + /// slot; on the same chain those ids live at earlier slots. + ReinscribedBlock { + channel: (BlockId, HashType), + slot: Slot, + anchor_slot: Slot, + }, + /// The channel has content past the anchor slot but no longer the + /// inscription we anchored on. + AnchorSlotChanged { anchor_slot: Slot }, + /// The channel does not exist on the connected chain. + ChannelMissing, + /// The channel's history ends before the anchor slot. + ChannelBehindAnchor { tip_slot: Slot, anchor_slot: Slot }, +} + +impl std::fmt::Display for ChainMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Block { ours, channel } => write!( + f, + "stored block {} {} != channel block {} {}", + ours.0, ours.1, channel.0, channel.1 + ), + Self::ReinscribedBlock { + channel, + slot, + anchor_slot, + } => write!( + f, + "channel re-serves block {} {} at slot {} past our anchor slot {}", + channel.0, + channel.1, + slot.into_inner(), + anchor_slot.into_inner() + ), + Self::AnchorSlotChanged { anchor_slot } => write!( + f, + "channel content at slot {} no longer includes the inscription we parked on", + anchor_slot.into_inner() + ), + Self::ChannelMissing => write!(f, "channel does not exist on the connected chain"), + Self::ChannelBehindAnchor { + tip_slot, + anchor_slot, + } => write!( + f, + "channel tip slot {} is behind our anchor slot {}", + tip_slot.into_inner(), + anchor_slot.into_inner() + ), + } + } +} + +/// A block that must still be inscribed at `slot` if the channel is the chain +/// the store was built from: the tip at the read cursor, or the recorded +/// parked block while stalled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Anchor { + slot: Slot, + /// The anchor block's `(id, hash)`. + /// + /// `None` when anchored on an undeserializable inscription (no header was recorded). + block: Option<(BlockId, HashType)>, +} + +impl Anchor { + /// Builds an anchor at `slot` on the block `(id, hash)`, or a headerless + /// anchor (`None`) when only the slot is known. + #[must_use] + pub const fn new(slot: Slot, block: Option<(BlockId, HashType)>) -> Self { + Self { slot, block } + } + + /// Probes a channel message read at/after the anchor slot. + /// See [`verify_chain_consistency`]. + fn probe_anchor_slot(&self, msg: &ZoneMessage, slot: Slot) -> AnchorProbe { + if slot < self.slot { + return AnchorProbe::KeepLooking; + } + let Some((anchor_id, anchor_hash)) = self.block else { + // Anchored on an undeserializable inscription: any message still + // present at that slot means the history is intact. + return if slot == self.slot { + AnchorProbe::SameChain + } else { + AnchorProbe::Mismatch(ChainMismatch::AnchorSlotChanged { + anchor_slot: self.slot, + }) + }; + }; + let ZoneMessage::Block(zone_block) = msg else { + return AnchorProbe::KeepLooking; + }; + let Ok(block) = borsh::from_slice::(&zone_block.data) else { + return AnchorProbe::KeepLooking; + }; + let (id, hash) = (block.header.block_id, block.header.hash); + if id == anchor_id { + return if hash == anchor_hash { + AnchorProbe::SameChain + } else { + AnchorProbe::Mismatch(ChainMismatch::Block { + ours: (anchor_id, anchor_hash), + channel: (id, hash), + }) + }; + } + if id > anchor_id { + return AnchorProbe::Bail; + } + if slot == self.slot { + // Older ids can share the anchor's slot on the same chain. + return AnchorProbe::KeepLooking; + } + // An id below the anchor served past the anchor slot is impossible on the + // same chain, even if the content is identical (deterministic genesis). + AnchorProbe::Mismatch(ChainMismatch::ReinscribedBlock { + channel: (id, hash), + slot, + anchor_slot: self.slot, + }) + } +} + +/// Classifies a stored chain against the channel one message at a time. +/// +/// The shared driver behind both consistency consumers: +/// [`verify_chain_consistency`] runs it over a channel it reads itself, while a +/// caller that already streams the channel history (e.g. a reconstructing +/// sequencer) feeds it the same messages it replays. Run [`Self::check_frontier`] +/// once the channel tip is known, then feed messages in slot order via +/// [`Self::observe`] until a verdict is reached. +pub struct AnchorConsistencyCheck { + anchor: Anchor, + verdict: Option, +} + +impl AnchorConsistencyCheck { + /// New checker for `anchor` with an undetermined verdict. + #[must_use] + pub const fn new(anchor: Anchor) -> Self { + Self { + anchor, + verdict: None, + } + } + + /// Applies the frontier check against a known channel tip. Skip it when the + /// tip could not be read, leaving the verdict to the message scan. + pub fn check_frontier(&mut self, channel_tip_slot: Option) { + if self.verdict.is_none() { + self.verdict = frontier_verdict(self.anchor.slot, channel_tip_slot) + .map(ChainConsistency::Inconsistent); + } + } + + /// Feeds the next channel message in slot order. Returns the verdict once it + /// is known so the caller can stop, or `None` while still undetermined. + pub fn observe(&mut self, msg: &ZoneMessage, slot: Slot) -> Option<&ChainConsistency> { + if self.verdict.is_none() { + self.verdict = match self.anchor.probe_anchor_slot(msg, slot) { + AnchorProbe::SameChain => Some(ChainConsistency::Consistent), + AnchorProbe::Mismatch(mismatch) => Some(ChainConsistency::Inconsistent(mismatch)), + AnchorProbe::Bail => Some(ChainConsistency::Inconclusive), + AnchorProbe::KeepLooking => None, + }; + } + self.verdict.as_ref() + } + + /// The verdict reached so far, or `None` while undetermined. + #[must_use] + pub const fn verdict(&self) -> Option<&ChainConsistency> { + self.verdict.as_ref() + } + + /// Consumes the checker, defaulting an undetermined verdict (the anchor slot + /// was never observed) to [`ChainConsistency::Inconclusive`]. + #[must_use] + pub fn finish(self) -> ChainConsistency { + self.verdict.unwrap_or(ChainConsistency::Inconclusive) + } +} + +/// What a single channel message tells the anchored consistency check. +enum AnchorProbe { + /// The anchor is still in place: same chain. + SameChain, + Mismatch(ChainMismatch), + /// Only newer ids past the anchor: plausible on the same chain, so stop + /// scanning without a verdict. + Bail, + KeepLooking, +} + +/// Detects when a local store belongs to a different chain than the connected +/// L1 (e.g. a wiped/restarted Bedrock) so startup can react instead of silently +/// diverging. +/// +/// Verifies the channel still carries the anchor block at its slot. The anchor +/// was finalized at `anchor.slot`, so the same chain must still serve it there, +/// while a reset chain re-inscribes its content only at later wall-clock slots. +/// Only positive evidence of a different chain yields +/// [`ChainConsistency::Inconsistent`]; absence of data stays +/// [`ChainConsistency::Inconclusive`]. +/// +/// `node` need only implement the zone-sdk [`adapter::Node`] trait; a throwaway +/// [`ZoneIndexer`] is built internally for the channel read. +pub async fn verify_chain_consistency( + node: &N, + channel_id: ChannelId, + anchor: &Anchor, +) -> Result +where + N: adapter::Node + Clone + Sync, +{ + let mut check = AnchorConsistencyCheck::new(anchor.clone()); + match node.channel_state(channel_id).await { + Ok(state) => check.check_frontier(state.map(|s| s.tip_slot)), + Err(err) => warn!("Failed to read channel state for the consistency check: {err:#}"), + } + if check.verdict().is_some() { + return Ok(check.finish()); + } + + // `next_messages` is exclusive, so `slot - 1` includes the anchor slot. + let Some(from_slot) = anchor.slot.into_inner().checked_sub(1) else { + return Ok(ChainConsistency::Inconclusive); + }; + + let zone_indexer = ZoneIndexer::new(channel_id, node.clone()); + let scan = async { + let stream = zone_indexer + .next_messages(Some(Slot::from(from_slot))) + .await?; + let mut stream = std::pin::pin!(stream); + + while let Some((msg, slot)) = stream.next().await { + if check.observe(&msg, slot).is_some() { + break; + } + } + Ok::<_, anyhow::Error>(()) + }; + + match tokio::time::timeout(CHANNEL_READ_TIMEOUT, scan).await { + Ok(Ok(())) => Ok(check.finish()), + Ok(Err(err)) => { + warn!("Failed to read the anchor slot for the consistency check; proceeding: {err:#}"); + Ok(ChainConsistency::Inconclusive) + } + Err(_elapsed) => { + warn!("Timed out reading the anchor slot for the consistency check; proceeding"); + Ok(ChainConsistency::Inconclusive) + } + } +} + +/// Checks the channel frontier against the anchor slot. +/// +/// The anchor block was finalized at `anchor_slot`, so on the same chain the +/// channel tip can never be behind it, and the channel must exist. +fn frontier_verdict(anchor_slot: Slot, channel_tip_slot: Option) -> Option { + match channel_tip_slot { + None => Some(ChainMismatch::ChannelMissing), + Some(tip_slot) if tip_slot < anchor_slot => Some(ChainMismatch::ChannelBehindAnchor { + tip_slot, + anchor_slot, + }), + Some(_) => None, + } +} + +#[cfg(test)] +mod tests { + use common::block::HashableBlockData; + use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription}; + use logos_blockchain_zone_sdk::ZoneBlock; + + use super::*; + + fn test_block(block_id: BlockId, timestamp: u64) -> Block { + HashableBlockData { + block_id, + prev_block_hash: HashType([0; 32]), + timestamp, + transactions: vec![], + } + .into_pending_block(&lee::PrivateKey::try_new([7; 32]).expect("valid key")) + } + + fn block_msg(block: &Block) -> ZoneMessage { + let bytes = borsh::to_vec(block).expect("serialize"); + ZoneMessage::Block(ZoneBlock { + id: MsgId::from([0_u8; 32]), + data: Inscription::try_from(bytes.as_slice()).expect("inscription"), + }) + } + + fn anchor_for(block: &Block, slot: Slot) -> Anchor { + Anchor::new(slot, Some((block.header.block_id, block.header.hash))) + } + + #[test] + fn probe_finds_anchor_block_at_slot() { + let tip = test_block(5, 42); + let anchor = anchor_for(&tip, Slot::from(1_000)); + assert!(matches!( + anchor.probe_anchor_slot(&block_msg(&tip), Slot::from(1_000)), + AnchorProbe::SameChain + )); + } + + #[test] + fn probe_flags_different_block_at_anchor_id() { + let tip = test_block(5, 42); + let anchor = anchor_for(&tip, Slot::from(1_000)); + // Same id, different content (timestamp) => different hash. + let other = test_block(5, 43); + assert!(matches!( + anchor.probe_anchor_slot(&block_msg(&other), Slot::from(1_000)), + AnchorProbe::Mismatch(ChainMismatch::Block { .. }) + )); + } + + #[test] + fn probe_flags_old_id_reinscribed_past_the_anchor_slot() { + // A reset chain re-inscribes from genesis at later slots. Even if the + // content is byte-identical (deterministic genesis), an id at/below + // the anchor past the anchor slot is impossible on the same chain. + let tip = test_block(5, 42); + let anchor = anchor_for(&tip, Slot::from(1_000)); + let genesis = test_block(1, 0); + assert!(matches!( + anchor.probe_anchor_slot(&block_msg(&genesis), Slot::from(1_001)), + AnchorProbe::Mismatch(ChainMismatch::ReinscribedBlock { .. }) + )); + } + + #[test] + fn probe_skips_older_blocks_sharing_the_anchor_slot() { + let tip = test_block(5, 42); + let anchor = anchor_for(&tip, Slot::from(1_000)); + let earlier = test_block(4, 41); + assert!(matches!( + anchor.probe_anchor_slot(&block_msg(&earlier), Slot::from(1_000)), + AnchorProbe::KeepLooking + )); + } + + #[test] + fn probe_bails_on_newer_ids_past_the_anchor() { + // Blocks newer than the anchor are plausible on the same chain (e.g. + // published while we were down), so they must never count as evidence. + let tip = test_block(5, 42); + let anchor = anchor_for(&tip, Slot::from(1_000)); + let newer = test_block(6, 43); + assert!(matches!( + anchor.probe_anchor_slot(&block_msg(&newer), Slot::from(1_001)), + AnchorProbe::Bail + )); + } + + #[test] + fn probe_skips_undeserializable_inscriptions() { + let tip = test_block(5, 42); + let anchor = anchor_for(&tip, Slot::from(1_000)); + let garbage = ZoneMessage::Block(ZoneBlock { + id: MsgId::from([0_u8; 32]), + data: Inscription::try_from(&[1_u8, 2, 3][..]).expect("inscription"), + }); + assert!(matches!( + anchor.probe_anchor_slot(&garbage, Slot::from(1_000)), + AnchorProbe::KeepLooking + )); + } + + #[test] + fn probe_accepts_any_message_for_a_headerless_anchor() { + // A deserialize park records no header: any message still present at + // the anchor slot means the history is intact. + let anchor = Anchor::new(Slot::from(1_000), None); + let garbage = ZoneMessage::Block(ZoneBlock { + id: MsgId::from([0_u8; 32]), + data: Inscription::try_from(&[1_u8, 2, 3][..]).expect("inscription"), + }); + assert!(matches!( + anchor.probe_anchor_slot(&garbage, Slot::from(1_000)), + AnchorProbe::SameChain + )); + assert!(matches!( + anchor.probe_anchor_slot(&garbage, Slot::from(1_001)), + AnchorProbe::Mismatch(ChainMismatch::AnchorSlotChanged { .. }) + )); + } + + #[test] + fn frontier_flags_missing_channel_and_short_history() { + assert!(matches!( + frontier_verdict(Slot::from(1_000), None), + Some(ChainMismatch::ChannelMissing) + )); + assert!(matches!( + frontier_verdict(Slot::from(1_000), Some(Slot::from(999))), + Some(ChainMismatch::ChannelBehindAnchor { .. }) + )); + assert!(frontier_verdict(Slot::from(1_000), Some(Slot::from(1_000))).is_none()); + assert!(frontier_verdict(Slot::from(1_000), Some(Slot::from(2_000))).is_none()); + } +} diff --git a/lez/chain_state/src/ingest_error.rs b/lez/chain_state/src/ingest_error.rs new file mode 100644 index 00000000..d259b5f7 --- /dev/null +++ b/lez/chain_state/src/ingest_error.rs @@ -0,0 +1,80 @@ +use common::HashType; +use serde::{Deserialize, Serialize}; + +/// Why an L2 block from the channel could not be applied. +/// +/// Persisted in `RocksDB` (as part of [`crate::StallReason`]), so every variant +/// must be `Clone + Serialize + Deserialize`. +#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)] +pub enum BlockIngestError { + #[error("Failed to deserialize L2 block: {0}")] + /// Here we store the error string that is derived from [`borsh::from_slice`]'s [`Err`]. + Deserialize(String), + #[error("Unexpected block id: expected {expected}, got {got}")] + UnexpectedBlockId { expected: u64, got: u64 }, + #[error("Broken chain link: expected prev {expected_prev}, got {got_prev}")] + BrokenChainLink { + expected_prev: HashType, + got_prev: HashType, + }, + #[error("Block hash mismatch: computed {computed}, header {header}")] + HashMismatch { + computed: HashType, + header: HashType, + }, + #[error("Block has no transactions")] + EmptyBlock, + #[error("Last transaction must be the public clock invocation for the block timestamp")] + InvalidClockTransaction, + #[error("Genesis block must contain only public transactions")] + NonPublicGenesisTransaction, + #[error("State transition failed at transaction {tx_index}: {reason}")] + StateTransition { + /// Index of the failing transaction within the block body. + tx_index: u64, + /// Reason string from `lee::Error` to `anyhow::Error` to `{:#}`. + /// + /// This is required because `lee::Error` is not `Clone + Serialize + Deserialize`, so we + /// cannot store it directly. + reason: String, + }, +} + +impl BlockIngestError { + /// Whether the failure may be transient rather than a property of the block. + /// + /// FIXME: `StateTransition` is too coarse — its `reason` string mixes genuine + /// state-transition rejections with infra failures (risc0 executor teardown, + /// storage errors). Once it carries a structured cause, narrow this so only + /// infra failures retry. + #[must_use] + pub const fn is_retryable(&self) -> bool { + matches!(self, Self::StateTransition { .. }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serializes_and_round_trips_externally_tagged() { + let err = BlockIngestError::UnexpectedBlockId { + expected: 5, + got: 7, + }; + let value = serde_json::to_value(&err).expect("serialize"); + assert_eq!( + value, + serde_json::json!({ "UnexpectedBlockId": { "expected": 5, "got": 7 } }) + ); + let back: BlockIngestError = serde_json::from_value(value).expect("deserialize"); + assert!(matches!( + back, + BlockIngestError::UnexpectedBlockId { + expected: 5, + got: 7 + } + )); + } +} diff --git a/lez/chain_state/src/lib.rs b/lez/chain_state/src/lib.rs new file mode 100644 index 00000000..89357af0 --- /dev/null +++ b/lez/chain_state/src/lib.rs @@ -0,0 +1,17 @@ +//! Storage-free chain-state core shared by the LEZ sequencer and indexer: +//! the [`apply_block`] entry point plus [`BlockIngestError`], [`StallReason`], +//! [`Tip`], and [`AcceptOutcome`]. See [`ChainState`] for the two-tier model. + +pub use apply::{AcceptOutcome, Tip, apply_block, apply_block_to_state, validate_against_tip}; +pub use chain::{ChainState, HeadEntry}; +pub use consistency::{ + Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, verify_chain_consistency, +}; +pub use ingest_error::BlockIngestError; +pub use stall_reason::StallReason; + +pub mod apply; +pub mod chain; +pub mod consistency; +pub mod ingest_error; +pub mod stall_reason; diff --git a/lez/chain_state/src/stall_reason.rs b/lez/chain_state/src/stall_reason.rs new file mode 100644 index 00000000..e1d9b3ac --- /dev/null +++ b/lez/chain_state/src/stall_reason.rs @@ -0,0 +1,50 @@ +use common::{HashType, block::BlockHeader}; +use logos_blockchain_zone_sdk::Slot; +use serde::{Deserialize, Serialize}; + +use crate::ingest_error::BlockIngestError; + +/// Diagnostic record of the first block that broke the L2 chain. +/// +/// The block-derived fields are `None` for a deserialize break (no header was +/// ever parsed). `l1_slot` is the L1 slot the breaking inscription was read at. +/// `first_seen` is the breaking block's L2 timestamp (`None` for a deserialize break). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StallReason { + pub block_id: Option, + pub block_hash: Option, + pub prev_block_hash: Option, + pub l1_slot: Slot, + pub error: BlockIngestError, + pub first_seen: Option, + /// Number of later non-chaining blocks (orphans, since the tip is frozen). + /// + /// TODO: We could store a different "branch" of blocks following this break, but for now we + /// just count them. + pub orphans_since: u64, +} + +impl StallReason { + /// First stall for a break, built from the breaking block's header + /// (`None` for a deserialize break). + #[must_use] + pub fn new(header: Option<&BlockHeader>, l1_slot: Slot, error: BlockIngestError) -> Self { + Self { + block_id: header.map(|header| header.block_id), + block_hash: header.map(|header| header.hash), + prev_block_hash: header.map(|header| header.prev_block_hash), + first_seen: header.map(|header| header.timestamp), + l1_slot, + error, + orphans_since: 0, + } + } + + /// A later stall on the same break: bumps `orphans_since`, preserving the + /// original cause. + #[must_use] + pub const fn escalate(mut self) -> Self { + self.orphans_since = self.orphans_since.saturating_add(1); + self + } +} diff --git a/lez/common/Cargo.toml b/lez/common/Cargo.toml index 8b2aa322..7582e885 100644 --- a/lez/common/Cargo.toml +++ b/lez/common/Cargo.toml @@ -25,3 +25,6 @@ log.workspace = true hex.workspace = true borsh.workspace = true logos-blockchain-common-http-client.workspace = true + +[dev-dependencies] +lee = { workspace = true, features = ["test-utils"] } diff --git a/lez/common/src/block.rs b/lez/common/src/block.rs index 6e956f9f..53a2f033 100644 --- a/lez/common/src/block.rs +++ b/lez/common/src/block.rs @@ -13,6 +13,15 @@ pub struct BlockMeta { pub hash: BlockHash, } +impl From<&Block> for BlockMeta { + fn from(block: &Block) -> Self { + Self { + id: block.header.block_id, + hash: block.header.hash, + } + } +} + #[derive(Debug, Clone)] /// Our own hasher. /// Currently it is SHA256 hasher wrapper. May change in a future. @@ -55,6 +64,31 @@ pub struct Block { pub bedrock_status: BedrockStatus, } +impl Block { + /// Recomputes the hash from this block's contents, for integrity verification + /// against the value stored in `header.hash`. + #[must_use] + pub fn recompute_hash(&self) -> BlockHash { + HashableBlockData { + block_id: self.header.block_id, + prev_block_hash: self.header.prev_block_hash, + timestamp: self.header.timestamp, + transactions: self.body.transactions.clone(), + } + .compute_hash() + } + + /// Recomputes the signed hash from the block contents and checks the header + /// signature against `expected_pubkey`. Used to pin a peer zone's + /// block-signing key, so a block inscribed by anyone other than that zone's + /// sequencer is rejected even if it reached the channel. + #[must_use] + pub fn is_signed_by(&self, expected_pubkey: &lee::PublicKey) -> bool { + let hash = HashableBlockData::from(self.clone()).compute_hash(); + self.header.signature.is_valid_for(&hash.0, expected_pubkey) + } +} + impl Serialize for Block { fn serialize(&self, serializer: S) -> Result { crate::borsh_base64::serialize(self, serializer) @@ -76,11 +110,13 @@ pub struct HashableBlockData { } impl HashableBlockData { + /// Domain-separated hash of the block contents: `SHA256(PREFIX || borsh(self))`. + /// The single source of truth for both producing and verifying a block hash. #[must_use] - pub fn into_pending_block(self, signing_key: &lee::PrivateKey) -> Block { + pub fn compute_hash(&self) -> BlockHash { const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Block/\x00\x00\x00\x00\x00\x00\x00\x00"; - let data_bytes = borsh::to_vec(&self).unwrap(); + let data_bytes = borsh::to_vec(self).unwrap(); let mut bytes = Vec::with_capacity( PREFIX .len() @@ -89,8 +125,12 @@ impl HashableBlockData { ); bytes.extend_from_slice(PREFIX); bytes.extend_from_slice(&data_bytes); + OwnHasher::hash(&bytes) + } - let hash = OwnHasher::hash(&bytes); + #[must_use] + pub fn into_pending_block(self, signing_key: &lee::PrivateKey) -> Block { + let hash = self.compute_hash(); let signature = lee::Signature::new(signing_key, &hash.0); Block { header: BlockHeader { @@ -132,4 +172,33 @@ mod tests { let block_from_bytes = borsh::from_slice::(&bytes).unwrap(); assert_eq!(hashable, block_from_bytes); } + + #[test] + fn recompute_hash_matches_header_for_well_formed_block() { + let key = lee::PrivateKey::try_new([7_u8; 32]).expect("valid key"); + let block = HashableBlockData { + block_id: 5, + prev_block_hash: HashType([9_u8; 32]), + timestamp: 42, + transactions: vec![test_utils::produce_dummy_empty_transaction()], + } + .into_pending_block(&key); + assert_eq!(block.recompute_hash(), block.header.hash); + } + + #[test] + fn recompute_hash_detects_tampering() { + let key = lee::PrivateKey::try_new([7_u8; 32]).expect("valid key"); + let block = HashableBlockData { + block_id: 5, + prev_block_hash: HashType([9_u8; 32]), + timestamp: 42, + transactions: vec![test_utils::produce_dummy_empty_transaction()], + } + .into_pending_block(&key); + + let mut tampered = block; + tampered.header.timestamp = 99; // header changed; stale hash no longer matches + assert_ne!(tampered.recompute_hash(), tampered.header.hash); + } } diff --git a/lez/common/src/lib.rs b/lez/common/src/lib.rs index 3cca327b..f134ad99 100644 --- a/lez/common/src/lib.rs +++ b/lez/common/src/lib.rs @@ -9,6 +9,7 @@ pub mod config; pub mod transaction; // Module for tests utility functions +// // TODO: Compile only for tests pub mod test_utils; diff --git a/lez/common/src/test_utils.rs b/lez/common/src/test_utils.rs index 7afda3dd..4a9ab992 100644 --- a/lez/common/src/test_utils.rs +++ b/lez/common/src/test_utils.rs @@ -1,4 +1,12 @@ +// Backs the hand-built state/diff helpers below, which are compiled only for `common`'s own +// unit tests. They rely on `lee::test_utils`, gated behind `lee`'s `test-utils` feature and +// enabled here via dev-dependencies, so it never reaches a production build. +#[cfg(test)] +use std::collections::HashMap; + use lee::AccountId; +#[cfg(test)] +use lee::{Account, PrivateKey, PublicKey, V03State, ValidatedStateDiff}; use crate::{ HashType, @@ -13,6 +21,33 @@ pub fn sequencer_sign_key_for_testing() -> lee::PrivateKey { lee::PrivateKey::try_new([37; 32]).unwrap() } +/// A syntactically valid `Public` transaction. Its contents are irrelevant to the +/// bridge guard, which only branches on the transaction *variant* and the diff. +#[cfg(test)] +#[must_use] +pub fn any_public_transaction() -> LeeTransaction { + let sender_key = PrivateKey::try_new([9_u8; 32]).expect("valid key"); + let sender_id = AccountId::from(&PublicKey::new_from_private_key(&sender_key)); + let recipient_key = PrivateKey::try_new([8_u8; 32]).expect("valid key"); + let recipient_id = AccountId::from(&PublicKey::new_from_private_key(&recipient_key)); + create_transaction_native_token_transfer(sender_id, 0, recipient_id, 1, &sender_key) +} + +/// Builds a state whose only entry is `account_id` (set to `pre`) and a single-entry diff +/// that maps `account_id` to `post`, so the validation guards can be exercised in isolation. +#[cfg(test)] +#[must_use] +pub fn state_and_diff( + account_id: AccountId, + pre: Account, + post: Account, +) -> (V03State, ValidatedStateDiff) { + let state = V03State::new().with_public_accounts([(account_id, pre)]); + let diff = + lee::test_utils::validated_state_diff_from_public_diff(HashMap::from([(account_id, post)])); + (state, diff) +} + // Dummy producers /// Produce dummy block with provided transactions + clock transaction an the end. diff --git a/lez/common/src/transaction.rs b/lez/common/src/transaction.rs index b5aee648..13b2ada5 100644 --- a/lez/common/src/transaction.rs +++ b/lez/common/src/transaction.rs @@ -92,9 +92,8 @@ impl LeeTransaction { Ok(diff) } - /// Computes the validated state diff without enforcing the system-account - /// restriction. Shared by [`Self::validate_on_state`] and - /// [`Self::execute_without_system_accounts_check_on_state`]. + /// Computes the validated state diff. Shared by [`Self::validate_on_state`] + /// (which adds the system-account guards) and [`Self::execute_on_state`]. fn compute_state_diff( &self, state: &V03State, @@ -129,16 +128,12 @@ impl LeeTransaction { Ok(self) } - /// Similar to [`Self::execute_check_on_state`], but skips the system-account guard. + /// Executes the transaction against the current state and applies the resulting diff, + /// without the system-account guards enforced by [`Self::execute_check_on_state`]. /// - /// FIXME: HOT FIX (testnet v0.2): the indexer replays blocks the sequencer already - /// accepted, including sequencer-generated deposit transactions that - /// legitimately modify the bridge account. The `TransactionOrigin::Sequencer` - /// tag that lets the sequencer bypass the guard is not carried in the block, - /// so the indexer cannot yet distinguish deposit txs from user txs. - /// - /// REMOVE ME when the indexer can authenticate deposit transactions. - pub fn execute_without_system_accounts_check_on_state( + /// The indexer replays blocks the sequencer already validated and inscribed on Bedrock, + /// so it trusts those inscriptions and re-derives state without re-validating them. + pub fn execute_on_state( self, state: &mut V03State, block_id: BlockId, @@ -260,9 +255,112 @@ fn validate_doesnt_modify_account( #[cfg(test)] mod tests { - use lee::{AccountId, PrivateKey, PublicKey, V03State}; + use lee::{Account, AccountId, PrivateKey, PublicKey, V03State}; + use lee_core::account::Nonce; - use crate::test_utils::create_transaction_native_token_transfer; + use super::validate_doesnt_modify_account; + use crate::test_utils::{ + any_public_transaction, create_transaction_native_token_transfer, state_and_diff, + }; + + #[test] + fn bridge_guard_allows_balance_only_increase() { + // A diff that *only* increases the bridge balance (the legitimate deposit shape) + // must be accepted. + let bridge_id = system_accounts::bridge_account_id(); + let pre = Account { + balance: 500, + nonce: Nonce(7), + ..Account::default() + }; + let post = Account { + balance: 600, + ..pre.clone() + }; + let (state, diff) = state_and_diff(bridge_id, pre, post); + + let tx = any_public_transaction(); + assert!( + tx.validate_bridge_account_modification(&state, &diff) + .is_ok(), + "a balance-only increase of the bridge account must be allowed", + ); + } + + #[test] + fn bridge_guard_rejects_data_modification_even_when_balance_increases() { + // A diff that changes the bridge account's data (here: the nonce) while *also* + // increasing its balance must be rejected. + let bridge_id = system_accounts::bridge_account_id(); + let pre = Account { + balance: 500, + nonce: Nonce(7), + ..Account::default() + }; + let post = Account { + balance: 600, + nonce: Nonce(8), + ..pre.clone() + }; + let (state, diff) = state_and_diff(bridge_id, pre, post); + + let tx = any_public_transaction(); + assert!( + tx.validate_bridge_account_modification(&state, &diff) + .is_err(), + "modifying bridge account data must be rejected even if the balance increases", + ); + } + + #[test] + fn bridge_guard_rejects_zero_value_deposit() { + // A diff that touches the bridge account without *strictly* increasing its balance + // must be rejected — a zero-value deposit is not a real credit. + let bridge_id = system_accounts::bridge_account_id(); + let pre = Account { + balance: 500, + nonce: Nonce(7), + ..Account::default() + }; + let post = pre.clone(); + let (state, diff) = state_and_diff(bridge_id, pre, post); + + let tx = any_public_transaction(); + assert!( + tx.validate_bridge_account_modification(&state, &diff) + .is_err(), + "a bridge diff that does not strictly increase the balance must be rejected", + ); + } + + #[test] + fn validate_doesnt_modify_account_flags_a_changed_account() { + // Directly exercise the system-account guard with a diff that genuinely changes a + // clock account, then with one that leaves it untouched. The inverted comparison would + // treat a changed account as unchanged and wave it through (and would flag an *unchanged* + // account instead). + let clock_id = system_accounts::clock_account_ids()[0]; + let pre = Account { + balance: 1_000, + ..Account::default() + }; + + let changed = Account { + balance: 2_000, + ..Account::default() + }; + let (state, diff) = state_and_diff(clock_id, pre.clone(), changed); + assert!( + validate_doesnt_modify_account(&state, &diff, clock_id).is_err(), + "a diff that changes a system account must be rejected", + ); + + let (unchanged_state, unchanged_diff) = state_and_diff(clock_id, pre.clone(), pre); + assert!( + validate_doesnt_modify_account(&unchanged_state, &unchanged_diff, clock_id).is_ok(), + "a diff that leaves a system account unchanged must be accepted", + ); + } #[test] fn system_account_ids_are_distinct_and_non_default() { diff --git a/lez/configs/docker-all-in-one/indexer_config.json b/lez/configs/docker-all-in-one/indexer_config.json index c1ff65b0..5791f64a 100644 --- a/lez/configs/docker-all-in-one/indexer_config.json +++ b/lez/configs/docker-all-in-one/indexer_config.json @@ -3,5 +3,6 @@ "bedrock_config": { "addr": "http://logos-blockchain-node-0:18080" }, - "channel_id": "0101010101010101010101010101010101010101010101010101010101010101" + "channel_id": "0101010101010101010101010101010101010101010101010101010101010101", + "allow_chain_reset": true } diff --git a/lez/configs/docker-all-in-one/sequencer_config.json b/lez/configs/docker-all-in-one/sequencer_config.json index 90b5d5f3..cd94eea5 100644 --- a/lez/configs/docker-all-in-one/sequencer_config.json +++ b/lez/configs/docker-all-in-one/sequencer_config.json @@ -11,7 +11,8 @@ "max_retries": 5 }, "channel_id": "0101010101010101010101010101010101010101010101010101010101010101", - "node_url": "http://logos-blockchain-node-0:18080" + "node_url": "http://logos-blockchain-node-0:18080", + "funding_key": "2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26" }, "genesis": [ { diff --git a/lez/cross_zone/Cargo.toml b/lez/cross_zone/Cargo.toml new file mode 100644 index 00000000..25341bda --- /dev/null +++ b/lez/cross_zone/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "cross_zone" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee.workspace = true +lee_core.workspace = true +programs.workspace = true +cross_zone_inbox_core.workspace = true +bridge_lock_core.workspace = true +ping_core.workspace = true +wrapped_token_core.workspace = true +serde.workspace = true +risc0-zkvm.workspace = true diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs new file mode 100644 index 00000000..06ebd8a0 --- /dev/null +++ b/lez/cross_zone/src/lib.rs @@ -0,0 +1,220 @@ +//! Host-side cross-zone helpers that need program ids (`programs`) or the state +//! machine (`lee`), kept out of the guest-pure cores. Mirrors `system_accounts`: +//! it resolves builtin program ids and bakes them into transactions and genesis +//! accounts for the watcher (sequencer) and verifier (indexer). +//! +//! This crate is the reference LEZ-to-LEZ adapter: it re-derives each delivery +//! byte-for-byte from a peer LEZ zone's finalized blocks, valid only because the +//! peer runs identical LEZ code. A non-LEZ peer needs a separate adapter with its +//! own block-reading, emission-extraction, delivery-building, and trust model; a +//! shared trait is best lifted from that first real adapter, not from this one. + +use std::collections::BTreeMap; + +pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer}; +use cross_zone_inbox_core::{ + CrossZoneMessage, InboxConfig, Instruction, ZoneId, inbox_config_account_id, + inbox_seen_shard_account_id, +}; +use lee_core::{ + account::{Account, AccountId, Balance}, + program::ProgramId, +}; +use serde::Serialize; + +/// The cross-zone emission fields a watcher or verifier reads off a source +/// transaction, common to every emitter program. +pub struct Emission { + pub target_zone: ZoneId, + pub target_program_id: ProgramId, + pub target_accounts: Vec<[u8; 32]>, + pub payload: Vec, +} + +/// Whether a program may only be invoked by sequencer-origin transactions. +/// +/// The cross-zone inbox is injected solely by the watcher; a user-submitted call +/// must be rejected at ingress, since `TransactionOrigin` is not carried in the +/// block. +#[must_use] +pub fn is_sequencer_only_program(program_id: ProgramId) -> bool { + program_id == programs::cross_zone_inbox().id() +} + +/// Extracts the cross-zone emission from a source transaction. +/// +/// Recognizes the known emitter programs (`ping_sender`, `bridge_lock`). The +/// watcher and verifier both use this so they agree on what a given source tx +/// emits. +#[must_use] +pub fn extract_emission(program_id: ProgramId, instruction_data: &[u32]) -> Option { + if program_id == programs::ping_sender().id() { + let ping_core::SenderInstruction::Send { + target_zone, + target_program_id, + target_accounts, + payload, + .. + } = risc0_zkvm::serde::from_slice(instruction_data).ok()?; + Some(Emission { + target_zone, + target_program_id, + target_accounts, + payload, + }) + } else if program_id == programs::bridge_lock().id() { + let bridge_lock_core::Instruction::Lock { + target_zone, + target_program_id, + target_accounts, + payload, + .. + } = risc0_zkvm::serde::from_slice(instruction_data).ok()?; + Some(Emission { + target_zone, + target_program_id, + target_accounts, + payload, + }) + } else { + None + } +} + +/// Builds the sequencer-origin dispatch transaction. Pure for fixed inputs, so +/// the watcher's injected tx and the indexer's re-derived tx are byte-identical. +fn build_inbox_dispatch_tx( + inbox_id: ProgramId, + msg: &CrossZoneMessage, + target_account_ids: Vec, +) -> lee::PublicTransaction { + let mut account_ids = Vec::with_capacity(target_account_ids.len().saturating_add(2)); + account_ids.push(inbox_config_account_id(inbox_id)); + account_ids.push(inbox_seen_shard_account_id( + inbox_id, + &msg.src_zone, + msg.src_block_id, + )); + account_ids.extend(target_account_ids); + + let message = lee::public_transaction::Message::try_new( + inbox_id, + account_ids, + vec![], + Instruction::Dispatch(msg.clone()), + ) + .expect("inbox dispatch instruction must serialize"); + + lee::PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + ) +} + +/// Builds the dispatch transaction for one peer emission. +/// +/// Both the sequencer's watcher and the indexer's verifier go through this so +/// their transactions are byte-identical for the same emission (the basis of the +/// Option B check). +#[must_use] +pub fn build_dispatch_from_emission( + src_zone: ZoneId, + src_block_id: u64, + src_tx_index: u32, + src_program_id: ProgramId, + target_program_id: ProgramId, + target_accounts: &[[u8; 32]], + payload: Vec, +) -> lee::PublicTransaction { + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_tx_index, + src_program_id, + target_program_id, + payload, + l1_inclusion_witness: None, + }; + let target_ids = target_accounts + .iter() + .copied() + .map(AccountId::new) + .collect(); + build_inbox_dispatch_tx(programs::cross_zone_inbox().id(), &msg, target_ids) +} + +/// The inbox config a zone derives from its cross-zone config: the per-peer target +/// allowlists plus its own zone id. +fn inbox_config(self_zone: ZoneId, cross_zone: &CrossZoneConfig) -> InboxConfig { + let mut allowed_targets = BTreeMap::new(); + for peer in &cross_zone.peers { + allowed_targets.insert(peer.channel_id, peer.allowed_targets.clone()); + } + InboxConfig { + self_zone, + allowed_peers: BTreeMap::new(), + allowed_targets, + } +} + +/// The genesis transaction that initializes this zone's inbox config PDA. +/// +/// Lets the inbox guest authorize inbound peer messages; replaying it seeds the +/// same account on every node, keeping their state consistent. +#[must_use] +pub fn build_inbox_init_config_tx( + self_zone: ZoneId, + cross_zone: &CrossZoneConfig, +) -> lee::PublicTransaction { + let inbox_id = programs::cross_zone_inbox().id(); + genesis_public_tx( + inbox_id, + vec![inbox_config_account_id(inbox_id)], + Instruction::InitConfig(inbox_config(self_zone, cross_zone)), + ) +} + +/// Builds the genesis holding account funding a holder's bridgeable balance. +/// +/// A real native balance owned by `bridge_lock`, which can debit it on a lock; it +/// is conserved like any other balance. Not produced by any transaction, so the +/// sequencer and indexer both seed it through this one builder. +#[must_use] +pub fn build_holding_account(holder: AccountId, amount: Balance) -> (AccountId, Account) { + let account = Account { + program_owner: programs::bridge_lock().id(), + balance: amount, + ..Default::default() + }; + (holder, account) +} + +/// The genesis transaction that pins the cross-zone inbox as the wrapped-token +/// minter, without importing the inbox id into the guest. +#[must_use] +pub fn build_wrapped_token_init_config_tx() -> lee::PublicTransaction { + let wrapped_token_id = programs::wrapped_token().id(); + genesis_public_tx( + wrapped_token_id, + vec![wrapped_token_core::config_account_id(wrapped_token_id)], + wrapped_token_core::Instruction::InitConfig { + minter: programs::cross_zone_inbox().id(), + }, + ) +} + +/// Builds an unsigned, sequencer-origin genesis transaction invoking `instruction` +/// on `program_id` over `account_ids`. +fn genesis_public_tx( + program_id: ProgramId, + account_ids: Vec, + instruction: I, +) -> lee::PublicTransaction { + let message = + lee::public_transaction::Message::try_new(program_id, account_ids, vec![], instruction) + .expect("genesis instruction must serialize"); + lee::PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + ) +} diff --git a/lez/explorer_service/src/components/account_nonce_list.rs b/lez/explorer_service/src/components/account_nonce_list.rs new file mode 100644 index 00000000..d8b30aff --- /dev/null +++ b/lez/explorer_service/src/components/account_nonce_list.rs @@ -0,0 +1,58 @@ +use indexer_service_protocol::AccountId; +use itertools::{EitherOrBoth, Itertools as _}; +use leptos::prelude::*; +use leptos_router::components::A; + +#[component] +pub fn AccountNonceList(account_ids: Vec, nonces: Vec) -> impl IntoView { + view! { +
+ {account_ids + .into_iter() + .zip_longest(nonces.into_iter()) + .map(|maybe_pair| { + match maybe_pair { + EitherOrBoth::Both(account_id, nonce) => { + let account_id_str = account_id.to_string(); + view! { + + } + } + EitherOrBoth::Left(account_id) => { + let account_id_str = account_id.to_string(); + view! { + + } + } + EitherOrBoth::Right(_) => { + view! { + + } + } + } + }) + .collect::>()} +
+ } +} diff --git a/lez/explorer_service/src/components/mod.rs b/lez/explorer_service/src/components/mod.rs index 306c79a8..3d0a4dae 100644 --- a/lez/explorer_service/src/components/mod.rs +++ b/lez/explorer_service/src/components/mod.rs @@ -1,7 +1,15 @@ +pub use account_nonce_list::AccountNonceList; pub use account_preview::AccountPreview; pub use block_preview::BlockPreview; +pub use search_results::SearchResultsView; +pub use transaction_details::{ + PrivacyPreservingTxDetails, ProgramDeploymentTxDetails, PublicTxDetails, +}; pub use transaction_preview::TransactionPreview; +pub mod account_nonce_list; pub mod account_preview; pub mod block_preview; +pub mod search_results; +pub mod transaction_details; pub mod transaction_preview; diff --git a/lez/explorer_service/src/components/search_results.rs b/lez/explorer_service/src/components/search_results.rs new file mode 100644 index 00000000..1033e515 --- /dev/null +++ b/lez/explorer_service/src/components/search_results.rs @@ -0,0 +1,93 @@ +use leptos::prelude::*; + +use super::{AccountPreview, BlockPreview, TransactionPreview}; +use crate::api::SearchResults; + +/// Search results view component +#[component] +pub fn SearchResultsView(results: SearchResults) -> impl IntoView { + let SearchResults { + blocks, + transactions, + accounts, + } = results; + let has_results = !blocks.is_empty() || !transactions.is_empty() || !accounts.is_empty(); + + view! { +
+

"Search Results"

+ {if has_results { + view! { +
+ {if blocks.is_empty() { + ().into_any() + } else { + view! { +
+

"Blocks"

+
+ {blocks + .into_iter() + .map(|block| { + view! { } + }) + .collect::>()} +
+
+ } + .into_any() + }} + + {if transactions.is_empty() { + ().into_any() + } else { + view! { +
+

"Transactions"

+
+ {transactions + .into_iter() + .map(|tx| { + view! { } + }) + .collect::>()} +
+
+ } + .into_any() + }} + + {if accounts.is_empty() { + ().into_any() + } else { + view! { +
+

"Accounts"

+
+ {accounts + .into_iter() + .map(|(id, account)| { + view! { + + } + }) + .collect::>()} +
+
+ } + .into_any() + }} + +
+ } + .into_any() + } else { + view! {
"No results found"
} + .into_any() + }} +
+ } +} diff --git a/lez/explorer_service/src/components/transaction_details.rs b/lez/explorer_service/src/components/transaction_details.rs new file mode 100644 index 00000000..c82f7d80 --- /dev/null +++ b/lez/explorer_service/src/components/transaction_details.rs @@ -0,0 +1,150 @@ +use indexer_service_protocol::{ + PrivacyPreservingMessage, PrivacyPreservingTransaction, ProgramDeploymentMessage, + ProgramDeploymentTransaction, PublicMessage, PublicTransaction, WitnessSet, +}; +use leptos::prelude::*; + +use super::AccountNonceList; + +/// Public transaction details component +#[component] +pub fn PublicTxDetails(tx: PublicTransaction) -> impl IntoView { + let PublicTransaction { + hash: _, + message, + witness_set, + } = tx; + let PublicMessage { + program_id, + account_ids, + nonces, + instruction_data, + } = message; + let WitnessSet { + signatures_and_public_keys, + proof, + } = witness_set; + + let program_id_str = program_id.to_string(); + let proof_len = proof.map_or(0, |p| p.0.len()); + let signatures_count = signatures_and_public_keys.len(); + + view! { +
+

"Public Transaction Details"

+
+
+ "Program ID:" + {program_id_str} +
+
+ "Instruction Data:" + + {format!("{} u32 values", instruction_data.len())} + +
+
+ "Proof Size:" + {format!("{proof_len} bytes")} +
+
+ "Signatures:" + {signatures_count.to_string()} +
+
+ +

"Accounts"

+ +
+ } +} + +/// Privacy-preserving transaction details component +#[component] +pub fn PrivacyPreservingTxDetails(tx: PrivacyPreservingTransaction) -> impl IntoView { + let PrivacyPreservingTransaction { + hash: _, + message, + witness_set, + } = tx; + let PrivacyPreservingMessage { + public_account_ids, + nonces, + public_post_states: _, + encrypted_private_post_states, + new_commitments, + new_nullifiers, + block_validity_window, + timestamp_validity_window, + } = message; + let WitnessSet { + signatures_and_public_keys: _, + proof, + } = witness_set; + let proof_len = proof.map_or(0, |p| p.0.len()); + + view! { +
+

"Privacy-Preserving Transaction Details"

+
+
+ "Public Accounts:" + + {public_account_ids.len().to_string()} + +
+
+ "New Commitments:" + {new_commitments.len().to_string()} +
+
+ "Nullifiers:" + {new_nullifiers.len().to_string()} +
+
+ "Encrypted States:" + + {encrypted_private_post_states.len().to_string()} + +
+
+ "Proof Size:" + {format!("{proof_len} bytes")} +
+
+ "Block Validity Window:" + {block_validity_window.to_string()} +
+
+ "Timestamp Validity Window:" + {timestamp_validity_window.to_string()} +
+
+ +

"Public Accounts"

+ +
+ } +} + +/// Program deployment transaction details component +#[component] +pub fn ProgramDeploymentTxDetails(tx: ProgramDeploymentTransaction) -> impl IntoView { + let ProgramDeploymentTransaction { hash: _, message } = tx; + let ProgramDeploymentMessage { bytecode } = message; + + let bytecode_len = bytecode.len(); + view! { +
+

"Program Deployment Transaction Details"

+
+
+ "Bytecode Size:" + + {format!("{bytecode_len} bytes")} + +
+
+
+ } +} diff --git a/lez/explorer_service/src/pages/main_page.rs b/lez/explorer_service/src/pages/main_page.rs index 7e26e794..831182ce 100644 --- a/lez/explorer_service/src/pages/main_page.rs +++ b/lez/explorer_service/src/pages/main_page.rs @@ -6,8 +6,8 @@ use leptos_router::{ use web_sys::SubmitEvent; use crate::{ - api::{self, SearchResults}, - components::{AccountPreview, BlockPreview, TransactionPreview}, + api, + components::{BlockPreview, SearchResultsView}, }; const RECENT_BLOCKS_LIMIT: u64 = 10; @@ -138,93 +138,8 @@ pub fn MainPage() -> impl IntoView { .get() .and_then(|opt_results| opt_results) .map(|results| { - let SearchResults { - blocks, - transactions, - accounts, - } = results; - let has_results = !blocks.is_empty() - || !transactions.is_empty() - || !accounts.is_empty(); - view! { -
-

"Search Results"

- {if has_results { - view! { -
- {if blocks.is_empty() { - ().into_any() - } else { - view! { -
-

"Blocks"

-
- {blocks - .into_iter() - .map(|block| { - view! { } - }) - .collect::>()} -
-
- } - .into_any() - }} - - {if transactions.is_empty() { - ().into_any() - } else { - view! { -
-

"Transactions"

-
- {transactions - .into_iter() - .map(|tx| { - view! { } - }) - .collect::>()} -
-
- } - .into_any() - }} - - {if accounts.is_empty() { - ().into_any() - } else { - view! { -
-

"Accounts"

-
- {accounts - .into_iter() - .map(|(id, account)| { - view! { - - } - }) - .collect::>()} -
-
- } - .into_any() - }} - -
- } - .into_any() - } else { - view! {
"No results found"
} - .into_any() - }} -
- } - .into_any() - }) + view! { }.into_any() + }) }} diff --git a/lez/explorer_service/src/pages/transaction_page.rs b/lez/explorer_service/src/pages/transaction_page.rs index 0a3fc8e2..a99c269f 100644 --- a/lez/explorer_service/src/pages/transaction_page.rs +++ b/lez/explorer_service/src/pages/transaction_page.rs @@ -1,14 +1,13 @@ use std::str::FromStr as _; -use indexer_service_protocol::{ - HashType, PrivacyPreservingMessage, PrivacyPreservingTransaction, ProgramDeploymentMessage, - ProgramDeploymentTransaction, PublicMessage, PublicTransaction, Transaction, WitnessSet, -}; -use itertools::{EitherOrBoth, Itertools as _}; +use indexer_service_protocol::{HashType, Transaction}; use leptos::prelude::*; -use leptos_router::{components::A, hooks::use_params_map}; +use leptos_router::hooks::use_params_map; -use crate::api; +use crate::{ + api, + components::{PrivacyPreservingTxDetails, ProgramDeploymentTxDetails, PublicTxDetails}, +}; /// Transaction page component #[component] @@ -66,244 +65,21 @@ pub fn TransactionPage() -> impl IntoView { { match tx { - Transaction::Public(ptx) => { - let PublicTransaction { - hash: _, - message, - witness_set, - } = ptx; - let PublicMessage { - program_id, - account_ids, - nonces, - instruction_data, - } = message; - let WitnessSet { - signatures_and_public_keys, - proof, - } = witness_set; + Transaction::Public(ptx) => { + view! { }.into_any() + } + Transaction::PrivacyPreserving(pptx) => { + view! { }.into_any() + } + Transaction::ProgramDeployment(pdtx) => { + view! { }.into_any() + } + } + } - let program_id_str = program_id.to_string(); - let proof_len = proof.map_or(0, |p| p.0.len()); - let signatures_count = signatures_and_public_keys.len(); - - view! { -
-

"Public Transaction Details"

-
-
- "Program ID:" - {program_id_str} -
-
- "Instruction Data:" - - {format!("{} u32 values", instruction_data.len())} - -
-
- "Proof Size:" - {format!("{proof_len} bytes")} -
-
- "Signatures:" - {signatures_count.to_string()} -
-
- -

"Accounts"

-
- {account_ids - .into_iter() - .zip_longest(nonces.into_iter()) - .map(|maybe_pair| { - match maybe_pair { - EitherOrBoth::Both(account_id, nonce) => { - let account_id_str = account_id.to_string(); - view! { - - } - } - EitherOrBoth::Left(account_id) => { - let account_id_str = account_id.to_string(); - view! { - - } - } - EitherOrBoth::Right(_) => { - view! { - - } - } - } - }) - .collect::>()} -
-
- } - .into_any() + } - Transaction::PrivacyPreserving(pptx) => { - let PrivacyPreservingTransaction { - hash: _, - message, - witness_set, - } = pptx; - let PrivacyPreservingMessage { - public_account_ids, - nonces, - public_post_states: _, - encrypted_private_post_states, - new_commitments, - new_nullifiers, - block_validity_window, - timestamp_validity_window, - } = message; - let WitnessSet { - signatures_and_public_keys: _, - proof, - } = witness_set; - let proof_len = proof.map_or(0, |p| p.0.len()); - view! { -
-

"Privacy-Preserving Transaction Details"

-
-
- "Public Accounts:" - - {public_account_ids.len().to_string()} - -
-
- "New Commitments:" - {new_commitments.len().to_string()} -
-
- "Nullifiers:" - {new_nullifiers.len().to_string()} -
-
- "Encrypted States:" - - {encrypted_private_post_states.len().to_string()} - -
-
- "Proof Size:" - {format!("{proof_len} bytes")} -
-
- "Block Validity Window:" - {block_validity_window.to_string()} -
-
- "Timestamp Validity Window:" - {timestamp_validity_window.to_string()} -
-
- -

"Public Accounts"

-
- {public_account_ids - .into_iter() - .zip_longest(nonces.into_iter()) - .map(|maybe_pair| { - match maybe_pair { - EitherOrBoth::Both(account_id, nonce) => { - let account_id_str = account_id.to_string(); - view! { - - } - } - EitherOrBoth::Left(account_id) => { - let account_id_str = account_id.to_string(); - view! { - - } - } - EitherOrBoth::Right(_) => { - view! { - - } - } - } - }) - .collect::>()} -
-
- } - .into_any() - } - Transaction::ProgramDeployment(pdtx) => { - let ProgramDeploymentTransaction { - hash: _, - message, - } = pdtx; - let ProgramDeploymentMessage { bytecode } = message; - - let bytecode_len = bytecode.len(); - view! { -
-

"Program Deployment Transaction Details"

-
-
- "Bytecode Size:" - - {format!("{bytecode_len} bytes")} - -
-
-
- } - .into_any() - } - }} - - - } - .into_any() + .into_any() } Err(e) => { view! { diff --git a/lez/indexer/core/Cargo.toml b/lez/indexer/core/Cargo.toml index 758acdd6..c8c8590f 100644 --- a/lez/indexer/core/Cargo.toml +++ b/lez/indexer/core/Cargo.toml @@ -7,11 +7,19 @@ license = { workspace = true } [lints] workspace = true +[features] +default = [] +testnet = [] + [dependencies] +chain_state.workspace = true common.workspace = true logos-blockchain-zone-sdk.workspace = true lee.workspace = true lee_core.workspace = true +cross_zone.workspace = true +cross_zone_inbox_core.workspace = true +programs.workspace = true storage.workspace = true testnet_initial_state.workspace = true @@ -27,7 +35,10 @@ logos-blockchain-core.workspace = true serde_json.workspace = true async-stream.workspace = true tokio.workspace = true +risc0-zkvm.workspace = true +hex.workspace = true +thiserror.workspace = true [dev-dependencies] tempfile.workspace = true -authenticated_transfer_core.workspace = true +ping_core.workspace = true diff --git a/lez/indexer/core/src/block_store.rs b/lez/indexer/core/src/block_store.rs index f00c94c5..ce6cfc61 100644 --- a/lez/indexer/core/src/block_store.rs +++ b/lez/indexer/core/src/block_store.rs @@ -1,13 +1,16 @@ use std::{path::Path, sync::Arc}; use anyhow::{Context as _, Result}; +use chain_state::{ + AcceptOutcome, BlockIngestError, StallReason, Tip, apply_block_to_state, validate_against_tip, +}; use common::{ - block::{BedrockStatus, Block}, - transaction::{LeeTransaction, clock_invocation}, + block::{BedrockStatus, Block, BlockHeader}, + transaction::LeeTransaction, }; use lee::{Account, AccountId, V03State}; use lee_core::BlockId; -use log::info; +use log::warn; use logos_blockchain_core::header::HeaderId; use logos_blockchain_zone_sdk::Slot; use storage::indexer::RocksDBIO; @@ -22,8 +25,18 @@ pub struct IndexerStore { impl IndexerStore { /// Starting database at the start of new chain. /// Creates files if necessary. - pub fn open_db(location: &Path) -> Result { - let initial_state = testnet_initial_state::initial_state(); + pub fn open_db(location: &Path, genesis_seed: Vec<(AccountId, Account)>) -> Result { + #[cfg(not(feature = "testnet"))] + let base = testnet_initial_state::initial_state(); + + #[cfg(feature = "testnet")] + let base = testnet_initial_state::initial_state_testnet(); + + // Seed any zone-specific genesis accounts (the bridge-lock holdings) so the + // indexer's replayed state matches the sequencer's; none are produced by a + // transaction. Cross-zone programs are base builtins, and their config + // accounts are reconstructed by replaying the genesis block's InitConfig txs. + let initial_state = base.with_public_accounts(genesis_seed); let dbio = RocksDBIO::open_or_create(location, &initial_state)?; let current_state = dbio.final_state()?; @@ -113,6 +126,36 @@ impl IndexerStore { Ok(()) } + /// The L1 inscription slot of the validated tip, written atomically with it + /// by [`Self::accept_block`]. `None` on a cold store or one written before + /// the slot was recorded. + pub fn get_tip_slot(&self) -> Result> { + Ok(self.dbio.get_meta_tip_slot_in_db()?.map(Slot::from)) + } + + pub fn get_stall_reason(&self) -> Result> { + let Some(bytes) = self.dbio.get_stall_reason_bytes()? else { + return Ok(None); + }; + let stall: Option = + serde_json::from_slice(&bytes).context("Failed to deserialize stored stall reason")?; + Ok(stall) + } + + pub fn set_stall_reason(&self, stall: &Option) -> Result<()> { + let bytes = serde_json::to_vec(stall).context("Failed to serialize stall reason")?; + self.dbio.put_stall_reason_bytes(&bytes)?; + Ok(()) + } + + /// Clears a recorded stall marker if one is present, skipping the write otherwise. + fn clear_stall_if_present(&self) -> Result<()> { + if self.get_stall_reason()?.is_some() { + self.set_stall_reason(&None)?; + } + Ok(()) + } + /// Recalculation of final state directly from DB. /// /// Used for indexer healthcheck. @@ -134,78 +177,132 @@ impl IndexerStore { .get_account_by_id(*account_id)) } - pub async fn put_block(&self, mut block: Block, l1_header: HeaderId) -> Result<()> { - info!("Applying block {}", block.header.block_id); + /// The last successfully applied block, or `None` on a cold store. + /// Read fresh from the store each call. + fn validated_tip(&self) -> Result> { + let Some(block_id) = self.dbio.get_meta_last_block_id_in_db()? else { + return Ok(None); + }; + let Some(block) = self.dbio.get_block(block_id)? else { + return Ok(None); + }; + Ok(Some(Tip::from(&block))) + } + + /// Record the stall reason. + /// + /// - First stall is stored verbatim + /// - Subsequent stalls only bump `orphans_since`, preserving the original cause. + pub fn record_stall( + &self, + header: Option<&BlockHeader>, + l1_slot: Slot, + error: BlockIngestError, + ) -> Result<()> { + let stall = self.get_stall_reason()?.map_or_else( + || StallReason::new(header, l1_slot, error), + StallReason::escalate, + ); + self.set_stall_reason(&Some(stall)) + } + + /// Validates `block` against the tip and, if it chains, applies it atomically + /// (scratch clone, commit only on full success) and advances the tip. + /// Retryable apply failures return `RetryableFailure` without recording a stall + /// or touching state; other failures record the stall and return `Parked`. + pub async fn accept_block(&self, block: &Block, l1_slot: Slot) -> Result { + let tip = self.validated_tip()?; + + // Re-delivery of an already-applied block is idempotent, not a divergence + if let Some(tip) = &tip + && block.header.block_id <= tip.block_id + && let Some(stored) = self.get_block_at_id(block.header.block_id)? + && stored.header.hash == block.header.hash { - let mut state_guard = self.current_state.write().await; - - let (clock_tx, user_txs) = block - .body - .transactions - .split_last() - .ok_or_else(|| anyhow::anyhow!("Block has no transactions"))?; - - anyhow::ensure!( - *clock_tx == LeeTransaction::Public(clock_invocation(block.header.timestamp)), - "Last transaction in block must be the clock invocation for the block timestamp" - ); - - let is_genesis = block.header.block_id == 1; - for transaction in user_txs { - if is_genesis { - let genesis_tx = match transaction { - LeeTransaction::Public(public_tx) => public_tx, - LeeTransaction::PrivacyPreserving(_) - | LeeTransaction::ProgramDeployment(_) => { - anyhow::bail!("Genesis block should contain only public transactions") - } - }; - state_guard - .transition_from_public_transaction( - genesis_tx, - block.header.block_id, - block.header.timestamp, - ) - .context("Failed to execute genesis public transaction")?; - } else { - transaction - .clone() - .transaction_stateless_check()? - // FIXME: HOT FIX (testnet v0.2): does not check for system account updates due to - // sequencer-generated deposit tx'es; - // CHANGE ME back to `execute_check_on_state` when the indexer can authenticate deposit transactions - .execute_without_system_accounts_check_on_state( - &mut state_guard, - block.header.block_id, - block.header.timestamp, - )?; - } - } - - // Apply the clock invocation directly (it is expected to modify clock accounts). - let LeeTransaction::Public(clock_public_tx) = clock_tx else { - anyhow::bail!("Clock invocation must be a public transaction"); - }; - state_guard.transition_from_public_transaction( - clock_public_tx, - block.header.block_id, - block.header.timestamp, - )?; + return Ok(AcceptOutcome::AlreadyApplied); } - // ToDo: Currently we are fetching only finalized blocks - // if it changes, the following lines need to be updated - // to represent correct block finality - block.bedrock_status = BedrockStatus::Finalized; + // Validate before paying for the scratch clone; validation failures + // are never retryable, so parking immediately is exact. + if let Err(err) = validate_against_tip(tip.as_ref(), block) { + self.record_stall(Some(&block.header), l1_slot, err.clone())?; + return Ok(AcceptOutcome::Parked(err)); + } - info!("Putting block {} into DB", block.header.block_id); - Ok(self.dbio.put_block(&block, l1_header.into())?) + // TODO: we use scratch state to be atomic, but need to revisit how expensive a clone is + let mut scratch = self.current_state.read().await.clone(); + if let Err(err) = apply_block_to_state(block, &mut scratch) { + if err.is_retryable() { + return Ok(AcceptOutcome::RetryableFailure(err)); + } + self.record_stall(Some(&block.header), l1_slot, err.clone())?; + return Ok(AcceptOutcome::Parked(err)); + } + + let mut stored = block.clone(); + stored.bedrock_status = BedrockStatus::Finalized; + self.dbio + .put_block(&stored, [0_u8; 32], l1_slot.into_inner(), &scratch) + .context("Failed to persist accepted block")?; + + // Commit in-memory state (infallible) only after the DB write succeeded. + *self.current_state.write().await = scratch; + // Best-effort: the block is durably applied, so a failed stall clear must not + // fail the apply. It self-heals on the next clear. + if let Err(err) = self.clear_stall_if_present() { + warn!("Failed to clear stall marker after applying block: {err:#}"); + } + Ok(AcceptOutcome::Applied) + } +} + +#[cfg(test)] +mod stall_reason_tests { + use common::HashType; + + use super::*; + + #[tokio::test] + async fn stall_reason_roundtrips_and_clears() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); + + assert!(store.get_stall_reason().expect("get").is_none()); + + let stall = StallReason { + block_id: Some(7), + block_hash: Some(HashType([1_u8; 32])), + prev_block_hash: Some(HashType([2_u8; 32])), + l1_slot: Slot::from(42), + error: BlockIngestError::StateTransition { + tx_index: 0, + reason: "boom".to_owned(), + }, + first_seen: Some(99), + orphans_since: 3, + }; + store.set_stall_reason(&Some(stall)).expect("set stall"); + + let got = store.get_stall_reason().expect("get").expect("present"); + assert_eq!(got.block_id, Some(7)); + assert_eq!(got.orphans_since, 3); + assert!(matches!( + got.error, + BlockIngestError::StateTransition { .. } + )); + assert_eq!(got.block_hash, Some(HashType([1_u8; 32]))); + assert_eq!(got.prev_block_hash, Some(HashType([2_u8; 32]))); + assert_eq!(got.l1_slot, Slot::from(42)); + assert_eq!(got.first_seen, Some(99)); + + store.set_stall_reason(&None).expect("clear"); + assert!(store.get_stall_reason().expect("get").is_none()); } } #[cfg(test)] mod tests { - use common::{HashType, block::HashableBlockData}; + use common::test_utils::{create_transaction_native_token_transfer, produce_dummy_block}; use tempfile::tempdir; use testnet_initial_state::initial_pub_accounts_private_keys; @@ -215,7 +312,7 @@ mod tests { fn correct_startup() { let home = tempdir().unwrap(); - let storage = IndexerStore::open_db(home.as_ref()).unwrap(); + let storage = IndexerStore::open_db(home.as_ref(), Vec::new()).unwrap(); let final_id = storage.get_last_block_id().unwrap(); @@ -223,104 +320,473 @@ mod tests { } #[tokio::test] - async fn state_transition() { + async fn accept_block_applies_transfers_and_advances_tip() { let home = tempdir().unwrap(); - - let storage = IndexerStore::open_db(home.as_ref()).unwrap(); + let store = IndexerStore::open_db(home.as_ref(), Vec::new()).unwrap(); let initial_accounts = initial_pub_accounts_private_keys(); let from = initial_accounts[0].account_id; let to = initial_accounts[1].account_id; let sign_key = initial_accounts[0].pub_sign_key.clone(); - // Submit genesis block - let clock_tx = LeeTransaction::Public(clock_invocation(0)); - let genesis_block_data = HashableBlockData { - block_id: 1, - prev_block_hash: HashType::default(), - timestamp: 0, - transactions: vec![clock_tx], - }; - let genesis_block = genesis_block_data - .into_pending_block(&common::test_utils::sequencer_sign_key_for_testing()); - let mut prev_hash = Some(genesis_block.header.hash); - storage - .put_block(genesis_block, HeaderId::from([0_u8; 32])) - .await - .unwrap(); + // Genesis (block 1): clock-only. + let genesis = produce_dummy_block(1, None, vec![]); + let mut prev_hash = genesis.header.hash; + assert!(matches!( + store.accept_block(&genesis, Slot::from(0)).await.unwrap(), + AcceptOutcome::Applied + )); - for i in 0..10 { - let tx = common::test_utils::create_transaction_native_token_transfer( - from, i, to, 10, &sign_key, - ); - let block_id = u64::try_from(i + 1).unwrap(); - - let next_block = common::test_utils::produce_dummy_block(block_id, prev_hash, vec![tx]); - prev_hash = Some(next_block.header.hash); - - storage - .put_block( - next_block, - HeaderId::from([u8::try_from(i + 1).unwrap(); 32]), - ) - .await - .unwrap(); + // Blocks 2..=11: one native transfer of 10 each (nonces 0..=9). + for i in 0..10_u64 { + let tx = create_transaction_native_token_transfer(from, i.into(), to, 10, &sign_key); + let block = produce_dummy_block(i + 2, Some(prev_hash), vec![tx]); + prev_hash = block.header.hash; + assert!(matches!( + store.accept_block(&block, Slot::from(0)).await.unwrap(), + AcceptOutcome::Applied + )); } - let acc1_val = storage.account_current_state(&from).await.unwrap(); - let acc2_val = storage.account_current_state(&to).await.unwrap(); - - assert_eq!(acc1_val.balance, 9900); - assert_eq!(acc2_val.balance, 20100); + assert_eq!( + store.account_current_state(&from).await.unwrap().balance, + 9900 + ); + assert_eq!( + store.account_current_state(&to).await.unwrap().balance, + 20100 + ); + // Tip advanced to the last applied block; a clean run leaves no stall. + assert_eq!(store.get_last_block_id().unwrap(), Some(11)); + assert!(store.get_stall_reason().unwrap().is_none()); } #[tokio::test] - async fn account_state_at_block() { + async fn account_state_at_block_reflects_history() { let home = tempdir().unwrap(); - - let storage = IndexerStore::open_db(home.as_ref()).unwrap(); - - let mut prev_hash = None; + let store = IndexerStore::open_db(home.as_ref(), Vec::new()).unwrap(); let initial_accounts = initial_pub_accounts_private_keys(); let from = initial_accounts[0].account_id; let to = initial_accounts[1].account_id; let sign_key = initial_accounts[0].pub_sign_key.clone(); - for i in 0..10 { - let tx = common::test_utils::create_transaction_native_token_transfer( - from, i, to, 10, &sign_key, - ); - let block_id = u64::try_from(i + 1).unwrap(); + let genesis = produce_dummy_block(1, None, vec![]); + let mut prev_hash = genesis.header.hash; + store.accept_block(&genesis, Slot::from(0)).await.unwrap(); - let next_block = common::test_utils::produce_dummy_block(block_id, prev_hash, vec![tx]); - prev_hash = Some(next_block.header.hash); - - storage - .put_block( - next_block, - HeaderId::from([u8::try_from(i + 1).unwrap(); 32]), - ) - .await - .unwrap(); + for i in 0..10_u64 { + let tx = create_transaction_native_token_transfer(from, i.into(), to, 10, &sign_key); + let block = produce_dummy_block(i + 2, Some(prev_hash), vec![tx]); + prev_hash = block.header.hash; + store.accept_block(&block, Slot::from(0)).await.unwrap(); } - // Genesis block: no transfers applied yet. - let acc1_at_1 = storage.account_state_at_block(&from, 1).unwrap(); - let acc2_at_1 = storage.account_state_at_block(&to, 1).unwrap(); - assert_eq!(acc1_at_1.balance, 9990); - assert_eq!(acc2_at_1.balance, 20010); - - // After block 5: 4 transfers of 10 applied (one each in blocks 2..=5). - let acc1_at_5 = storage.account_state_at_block(&from, 5).unwrap(); - let acc2_at_5 = storage.account_state_at_block(&to, 5).unwrap(); - assert_eq!(acc1_at_5.balance, 9950); - assert_eq!(acc2_at_5.balance, 20050); - - // After final block 9: 8 transfers applied; should match current state. - let acc1_at_9 = storage.account_state_at_block(&from, 9).unwrap(); - let acc2_at_9 = storage.account_state_at_block(&to, 9).unwrap(); - assert_eq!(acc1_at_9.balance, 9910); - assert_eq!(acc2_at_9.balance, 20090); + // State at block N is inclusive of block N. + // Block 1 (genesis, clock-only): no transfers yet. + assert_eq!( + store.account_state_at_block(&from, 1).unwrap().balance, + 10000 + ); + assert_eq!(store.account_state_at_block(&to, 1).unwrap().balance, 20000); + // Through block 5: 4 transfers applied (blocks 2..=5). + assert_eq!( + store.account_state_at_block(&from, 5).unwrap().balance, + 9960 + ); + assert_eq!(store.account_state_at_block(&to, 5).unwrap().balance, 20040); + // Through block 9: 8 transfers applied (blocks 2..=9). + assert_eq!( + store.account_state_at_block(&from, 9).unwrap().balance, + 9920 + ); + assert_eq!(store.account_state_at_block(&to, 9).unwrap().balance, 20080); + } +} + +#[cfg(test)] +mod accept_tests { + use common::{HashType, block::HashableBlockData, test_utils::produce_dummy_block}; + + use super::*; + + fn signing_key() -> lee::PrivateKey { + lee::PrivateKey::try_new([7_u8; 32]).expect("valid key") + } + + // A block with a correct hash but empty body — enough to exercise the + // acceptance checks (id/link/hash), which run before any state application. + fn valid_hash_block(block_id: u64, prev: HashType) -> common::block::Block { + HashableBlockData { + block_id, + prev_block_hash: prev, + timestamp: 0, + transactions: vec![], + } + .into_pending_block(&signing_key()) + } + + #[tokio::test] + async fn non_genesis_first_block_parks_with_unexpected_id() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); + + let block = valid_hash_block(2, HashType([0_u8; 32])); + let outcome = store + .accept_block(&block, Slot::from(0)) + .await + .expect("accept"); + + assert!(matches!( + outcome, + AcceptOutcome::Parked(BlockIngestError::UnexpectedBlockId { + expected: 1, + got: 2 + }) + )); + let stall = store.get_stall_reason().expect("get").expect("present"); + assert_eq!(stall.block_id, Some(2)); + assert_eq!(stall.orphans_since, 0); + } + + #[tokio::test] + async fn hash_mismatch_parks() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); + + let mut block = valid_hash_block(1, HashType([0_u8; 32])); + block.header.timestamp = 999; // invalidates the stored hash + + let outcome = store + .accept_block(&block, Slot::from(0)) + .await + .expect("accept"); + assert!(matches!( + outcome, + AcceptOutcome::Parked(BlockIngestError::HashMismatch { .. }) + )); + } + + #[tokio::test] + async fn second_break_bumps_orphan_count_and_keeps_first() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); + + let first = valid_hash_block(2, HashType([0_u8; 32])); + store + .accept_block(&first, Slot::from(0)) + .await + .expect("accept"); + let second = valid_hash_block(3, HashType([0_u8; 32])); + store + .accept_block(&second, Slot::from(0)) + .await + .expect("accept"); + + let stall = store.get_stall_reason().expect("get").expect("present"); + assert_eq!(stall.block_id, Some(2), "first stall preserved"); + assert_eq!(stall.orphans_since, 1, "second break counted as orphan"); + } + + #[tokio::test] + async fn deserialize_break_records_stall_without_header() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); + + store + .record_stall( + None, + Slot::from(0), + BlockIngestError::Deserialize("bad bytes".to_owned()), + ) + .expect("record"); + + let stall = store.get_stall_reason().expect("get").expect("present"); + assert_eq!(stall.block_id, None); + assert!(matches!(stall.error, BlockIngestError::Deserialize(_))); + } + + #[tokio::test] + async fn parks_then_recovers_on_valid_continuation() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); + + // Genesis (block 1, clock-only) applies and advances the tip. + let genesis = produce_dummy_block(1, None, vec![]); + assert!(matches!( + store.accept_block(&genesis, Slot::from(0)).await.unwrap(), + AcceptOutcome::Applied + )); + + // A block that skips ahead (id 3 while the tip is 1) parks the indexer. + let bad = produce_dummy_block(3, Some(genesis.header.hash), vec![]); + assert!(matches!( + store.accept_block(&bad, Slot::from(0)).await.unwrap(), + AcceptOutcome::Parked(BlockIngestError::UnexpectedBlockId { + expected: 2, + got: 3 + }) + )); + assert!( + store.get_stall_reason().unwrap().is_some(), + "indexer should be parked after the bad block" + ); + assert_eq!( + store.get_last_block_id().unwrap(), + Some(1), + "validated tip must stay frozen at genesis while parked" + ); + + // The valid continuation (block 2 chaining on genesis) recovers the chain. + let next = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + assert!(matches!( + store.accept_block(&next, Slot::from(0)).await.unwrap(), + AcceptOutcome::Applied + )); + assert!( + store.get_stall_reason().unwrap().is_none(), + "stall reason must clear on recovery" + ); + assert_eq!( + store.get_last_block_id().unwrap(), + Some(2), + "tip must advance to the recovered block" + ); + } + + #[tokio::test] + async fn accept_block_records_tip_inscription_slot() { + let dir = tempfile::tempdir().expect("tempdir"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); + + assert_eq!(store.get_tip_slot().expect("get"), None); + + let genesis = produce_dummy_block(1, None, vec![]); + store + .accept_block(&genesis, Slot::from(1_000)) + .await + .expect("accept"); + assert_eq!(store.get_tip_slot().expect("get"), Some(Slot::from(1_000))); + + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + store + .accept_block(&block2, Slot::from(1_005)) + .await + .expect("accept"); + assert_eq!(store.get_tip_slot().expect("get"), Some(Slot::from(1_005))); + + // A parked block freezes the tip, so its slot must not advance either. + let bad = produce_dummy_block(4, Some(block2.header.hash), vec![]); + assert!(matches!( + store.accept_block(&bad, Slot::from(1_010)).await.unwrap(), + AcceptOutcome::Parked(_) + )); + assert_eq!(store.get_tip_slot().expect("get"), Some(Slot::from(1_005))); + + // Neither must a re-delivered old block move it. + assert!(matches!( + store + .accept_block(&genesis, Slot::from(1_015)) + .await + .unwrap(), + AcceptOutcome::AlreadyApplied + )); + assert_eq!(store.get_tip_slot().expect("get"), Some(Slot::from(1_005))); + } + + #[tokio::test] + async fn redelivered_tip_block_is_idempotent_not_parked() { + use testnet_initial_state::initial_pub_accounts_private_keys; + + let dir = tempfile::tempdir().expect("tempdir"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); + + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let genesis = produce_dummy_block(1, None, vec![]); + store + .accept_block(&genesis, Slot::from(0)) + .await + .expect("accept genesis"); + + // Block 2: a single transfer of 10. + let tx = common::test_utils::create_transaction_native_token_transfer( + from, 0, to, 10, &sign_key, + ); + let block = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]); + assert!(matches!( + store.accept_block(&block, Slot::from(0)).await.unwrap(), + AcceptOutcome::Applied + )); + let balance_after = store.account_current_state(&from).await.unwrap().balance; + + // Re-deliver the exact same block: idempotent skip, no state change, no park. + assert!(matches!( + store.accept_block(&block, Slot::from(0)).await.unwrap(), + AcceptOutcome::AlreadyApplied + )); + assert_eq!( + store.account_current_state(&from).await.unwrap().balance, + balance_after, + "re-delivered block must not be applied twice" + ); + assert_eq!( + store.get_last_block_id().unwrap(), + Some(2), + "tip must stay at the already-applied block" + ); + assert!( + store.get_stall_reason().unwrap().is_none(), + "a benign duplicate must not park the indexer" + ); + } + + #[tokio::test] + async fn redelivered_block_below_tip_is_idempotent_not_parked() { + use testnet_initial_state::initial_pub_accounts_private_keys; + + let dir = tempfile::tempdir().expect("tempdir"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); + + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + // Build a short chain: genesis (1) -> block 2 -> block 3, so the tip is 3. + let genesis = produce_dummy_block(1, None, vec![]); + store + .accept_block(&genesis, Slot::from(0)) + .await + .expect("accept genesis"); + + let tx2 = common::test_utils::create_transaction_native_token_transfer( + from, 0, to, 10, &sign_key, + ); + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![tx2]); + assert!(matches!( + store.accept_block(&block2, Slot::from(0)).await.unwrap(), + AcceptOutcome::Applied + )); + + let tx3 = common::test_utils::create_transaction_native_token_transfer( + from, 1, to, 10, &sign_key, + ); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![tx3]); + assert!(matches!( + store.accept_block(&block3, Slot::from(0)).await.unwrap(), + AcceptOutcome::Applied + )); + + let balance_after = store.account_current_state(&from).await.unwrap().balance; + + // Re-deliver block 2 (id below the tip): a re-delivery, not a divergence. + assert!(matches!( + store.accept_block(&block2, Slot::from(0)).await.unwrap(), + AcceptOutcome::AlreadyApplied + )); + assert_eq!( + store.account_current_state(&from).await.unwrap().balance, + balance_after, + "re-delivered block below the tip must not be applied again" + ); + assert_eq!( + store.get_last_block_id().unwrap(), + Some(3), + "tip must stay at the current head" + ); + assert!( + store.get_stall_reason().unwrap().is_none(), + "a benign re-delivery must not park the indexer" + ); + } + + #[tokio::test] + async fn accept_block_snapshots_state_at_breakpoint_interval() { + use testnet_initial_state::initial_pub_accounts_private_keys; + + let dir = tempfile::tempdir().expect("tempdir"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); + + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let genesis = produce_dummy_block(1, None, vec![]); + assert!(matches!( + store.accept_block(&genesis, Slot::from(0)).await.unwrap(), + AcceptOutcome::Applied + )); + let mut prev_hash = genesis.header.hash; + + // Blocks 2..=101: one transfer of 1 each; block 100 crosses the interval. + for i in 0..100_u64 { + let tx = common::test_utils::create_transaction_native_token_transfer( + from, + i.into(), + to, + 1, + &sign_key, + ); + let block = produce_dummy_block(i + 2, Some(prev_hash), vec![tx]); + prev_hash = block.header.hash; + assert!(matches!( + store.accept_block(&block, Slot::from(0)).await.unwrap(), + AcceptOutcome::Applied + )); + } + + // Snapshot at block 100 = genesis + 99 transfers, written with the block. + let bp1 = store.dbio.get_breakpoint(1).expect("breakpoint 1 present"); + assert_eq!(bp1.get_account_by_id(from).balance, 10000 - 99); + + // The #605 restart: reopening past the boundary must work. + drop(store); + let reopened = IndexerStore::open_db(dir.path(), Vec::new()).expect("reopen"); + assert_eq!(reopened.last_block().unwrap(), Some(101)); + } + + #[tokio::test] + async fn transient_apply_failure_returns_retryable_failure_without_stall() { + use testnet_initial_state::initial_pub_accounts_private_keys; + + let dir = tempfile::tempdir().expect("tempdir"); + let store = IndexerStore::open_db(dir.path(), Vec::new()).expect("open store"); + + let accounts = initial_pub_accounts_private_keys(); + let from = accounts[0].account_id; + let to = accounts[1].account_id; + let sign_key = accounts[0].pub_sign_key.clone(); + + let genesis = produce_dummy_block(1, None, vec![]); + store + .accept_block(&genesis, Slot::from(0)) + .await + .expect("accept genesis"); + + // Overdraft: rejected during execution → StateTransition → retryable. + let tx = common::test_utils::create_transaction_native_token_transfer( + from, + 0, + to, + 1_000_000_000, + &sign_key, + ); + let block = produce_dummy_block(2, Some(genesis.header.hash), vec![tx]); + let outcome = store.accept_block(&block, Slot::from(0)).await.unwrap(); + + assert!(matches!( + outcome, + AcceptOutcome::RetryableFailure(BlockIngestError::StateTransition { .. }) + )); + assert!( + store.get_stall_reason().unwrap().is_none(), + "retryable failure must not persist a stall" + ); + assert_eq!(store.get_last_block_id().unwrap(), Some(1), "tip frozen"); } } diff --git a/lez/indexer/core/src/config.rs b/lez/indexer/core/src/config.rs index cb7f3dfe..159da23c 100644 --- a/lez/indexer/core/src/config.rs +++ b/lez/indexer/core/src/config.rs @@ -2,7 +2,9 @@ use std::{fs::File, io::BufReader, path::Path, time::Duration}; use anyhow::{Context as _, Result}; use common::config::BasicAuth; +use cross_zone_inbox_core::CrossZoneConfig; use humantime_serde; +use lee::AccountId; pub use logos_blockchain_core::mantle::ops::channel::ChannelId; use serde::{Deserialize, Serialize}; use url::Url; @@ -20,6 +22,29 @@ pub struct IndexerConfig { pub consensus_info_polling_interval: Duration, pub bedrock_config: ClientConfig, pub channel_id: ChannelId, + /// Cross-zone configuration. `None` disables the indexer's cross-zone handling. + #[serde(default)] + pub cross_zone: Option, + /// Bridge-lock holdings to seed into genesis, mirroring the sequencer's + /// `SupplyBridgeLockHolding` actions. They are not produced by any + /// transaction, so the indexer must seed them to match the sequencer's state. + #[serde(default)] + pub bridge_lock_holdings: Vec, + /// Whether to wipe the indexer store and re-index from scratch when the startup + /// chain-identity check finds the channel serving a different block than the one + /// stored at the same id. + /// + /// Defaults to `false`: on mismatch the indexer refuses to start. + #[serde(default)] + pub allow_chain_reset: bool, +} + +/// A genesis-funded bridge-lock holder balance, configured identically on the +/// sequencer (via `SupplyBridgeLockHolding`) and the indexer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BridgeLockHolding { + pub holder: AccountId, + pub amount: u128, } impl IndexerConfig { diff --git a/lez/indexer/core/src/cross_zone_verifier.rs b/lez/indexer/core/src/cross_zone_verifier.rs new file mode 100644 index 00000000..da568ab8 --- /dev/null +++ b/lez/indexer/core/src/cross_zone_verifier.rs @@ -0,0 +1,1032 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, + time::Duration, +}; + +use anyhow::anyhow; +use common::{block::Block, transaction::LeeTransaction}; +use cross_zone::{build_dispatch_from_emission, extract_emission}; +use cross_zone_inbox_core::{ + CrossZoneMessage, Instruction as InboxInstruction, MessageKey, ZoneId, message_key, +}; +use futures::{Stream, StreamExt as _}; +use lee::{GENESIS_BLOCK_ID, PublicKey}; +use log::{debug, error, info}; +use logos_blockchain_core::mantle::ops::channel::ChannelId; +use logos_blockchain_zone_sdk::{ + CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, +}; +use tokio::sync::RwLock; + +use crate::config::IndexerConfig; + +/// How often the verifier logs that it is still waiting on a lagging peer reader, +/// so a stuck wait is observable without rejecting a legitimate message. +const LAG_LOG_INTERVAL: Duration = Duration::from_secs(30); + +/// How long to wait for a referenced peer block before giving up on this pass. +/// Generous, since ordinary L1 finality lag delays a peer block by minutes. +/// Expiry is not a rejection: the caller retries the same block, so the cost of a +/// premature expiry is one repeated pass. +const PEER_BLOCK_WAIT_TIMEOUT: Duration = Duration::from_secs(300); + +/// How long each wait iteration sleeps. Also the unit the elapsed counter is +/// advanced by, so `waited` counts sleeps rather than wall time and, since a +/// sleep can overshoot, understates it. +const PEER_BLOCK_POLL_INTERVAL: Duration = Duration::from_secs(1); + +/// Consecutive passes a peer reader re-reads the same undecodable slot before +/// giving up and reading past it. +const DECODE_RETRY_LIMIT: u32 = 3; + +/// Why a cross-zone dispatch could not be verified. +/// +/// A forgery is terminal and must stop the block applying; an unavailable peer +/// block is transient and must be retried, or a lagging peer reader would +/// permanently halt ingestion. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum CrossZoneVerifyError { + /// The dispatch does not match the peer's finalized chain. + #[error("{0:#}")] + Forged(anyhow::Error), + /// The referenced peer block has not been read yet. + #[error( + "peer zone {} block {block_id} still unavailable after {waited:?}", + hex::encode(zone) + )] + PeerUnavailable { + zone: ZoneId, + block_id: u64, + waited: Duration, + }, +} + +/// One peer zone's cached blocks, plus how far this reader has read them as an +/// unbroken hash-linked run from the peer's genesis. +#[derive(Default)] +struct PeerChain { + blocks: HashMap, + /// Highest id such that every block from [`GENESIS_BLOCK_ID`] up to it has + /// been read and each links to its predecessor. `None` until genesis is read. + /// + /// This, not `max(blocks.keys())`, is what the forgery test gates on: a peer + /// picks its own `block_id`s, and an id that does not continue the run + /// cannot advance the run. It bounds how far this reader has read, not that + /// the chain is authentic: the link is self-asserted, since `header.hash` is + /// not recomputed on decode and the reader does not apply the pinned-key + /// check that [`crate::cross_zone_verifier::CrossZoneVerifier::rederive`] + /// applies to the block a dispatch actually names. + verified_prefix: Option, +} + +impl PeerChain { + /// The id that would extend the verified run. + const fn next_expected(&self) -> u64 { + match self.verified_prefix { + Some(prefix) => prefix.saturating_add(1), + None => GENESIS_BLOCK_ID, + } + } + + /// Extends the verified run as far as the cached blocks allow. + fn extend_prefix(&mut self) { + while let Some(next) = self.blocks.get(&self.next_expected()) { + let links = match self.verified_prefix { + Some(prefix) => self + .blocks + .get(&prefix) + .is_some_and(|prev| prev.header.hash == next.header.prev_block_hash), + // Genesis has no predecessor to link to. + None => true, + }; + if !links { + return; + } + self.verified_prefix = Some(next.header.block_id); + } + } +} + +/// What one consistent look at the peer cache says about a referenced block. +enum PeerLookup { + Cached(Box), + /// Inside the verified run but not held, so it is not on the peer chain. + InsideRun, + /// The reader has not verified this far yet. + Behind, +} + +/// Cache of finalized peer-zone blocks, filled by per-peer reader tasks and read +/// by the verifier to re-derive cross-zone dispatch transactions. +#[derive(Clone, Default)] +struct PeerBlocks { + chains: Arc>>, +} + +impl PeerBlocks { + async fn insert(&self, zone: ZoneId, block: Block) { + let mut chains = self.chains.write().await; + let chain = chains.entry(zone).or_default(); + chain.blocks.insert(block.header.block_id, block); + chain.extend_prefix(); + } + + /// Resolves `block_id` under a single read lock. + /// + /// Answering "is it cached?" and "is it inside the verified run?" under two + /// separate locks races with the peer reader: an insert landing between them + /// reads as absent-and-inside-the-run, which is the forgery signal, for a + /// block that is in fact cached. That is the normal steady state, a waiting + /// verifier and the block it waits for arriving, so it must be one look. + async fn resolve(&self, zone: ZoneId, block_id: u64) -> PeerLookup { + let chains = self.chains.read().await; + let Some(chain) = chains.get(&zone) else { + return PeerLookup::Behind; + }; + if let Some(block) = chain.blocks.get(&block_id) { + return PeerLookup::Cached(Box::new(block.clone())); + } + if chain + .verified_prefix + .is_some_and(|prefix| prefix >= block_id) + { + PeerLookup::InsideRun + } else { + PeerLookup::Behind + } + } + + #[cfg(test)] + async fn get(&self, zone: ZoneId, block_id: u64) -> Option { + self.chains + .read() + .await + .get(&zone) + .and_then(|chain| chain.blocks.get(&block_id).cloned()) + } + + /// How far this reader has read `zone` as an unbroken run from genesis, or + /// `None` if it has not read the peer's genesis block yet. + #[cfg(test)] + async fn verified_prefix(&self, zone: ZoneId) -> Option { + self.chains + .read() + .await + .get(&zone) + .and_then(|chain| chain.verified_prefix) + } +} + +/// The indexer-side Option B verifier. +/// +/// For every cross-zone dispatch in a block it re-derives the transaction from +/// the peer's finalized block and rejects it if the bytes differ (a forgery), so +/// delivery no longer relies on trusting the sequencer. A replay of an +/// already-delivered message is accepted, since the inbox no-ops it on chain. +#[derive(Clone)] +pub struct CrossZoneVerifier { + self_zone: ZoneId, + /// Pinned block-signing key per peer zone, enforced during re-derivation. + /// One key per peer is sufficient while a zone has a single sequencer; key + /// sets with rotation come in with decentralized sequencing. The pin is + /// largely redundant given Bedrock's turn-based write authorization, so it is + /// optional: a peer with no configured key is not signature-checked. + peer_pubkeys: HashMap, + peers: PeerBlocks, + seen: Arc>>, +} + +impl CrossZoneVerifier { + /// Builds the verifier and spawns one peer reader per configured peer. + /// Returns `None` when cross-zone messaging is disabled. + pub fn start(config: &IndexerConfig) -> Option { + let cross_zone = config.cross_zone.as_ref()?; + let self_zone: ZoneId = *config.channel_id.as_ref(); + let peers = PeerBlocks::default(); + let mut peer_pubkeys = HashMap::new(); + + for peer in &cross_zone.peers { + let node = NodeHttpClient::new( + CommonHttpClient::new(config.bedrock_config.auth.clone().map(Into::into)), + config.bedrock_config.addr.clone(), + ); + if let Some(bytes) = peer.expected_block_signing_pubkey { + let pubkey = PublicKey::try_new(bytes) + .expect("configured peer block-signing pubkey is a valid key"); + peer_pubkeys.insert(peer.channel_id, pubkey); + } + tokio::spawn(read_peer( + ZoneIndexer::new(ChannelId::from(peer.channel_id), node), + peer.channel_id, + peers.clone(), + config.consensus_info_polling_interval, + )); + } + + Some(Self { + self_zone, + peer_pubkeys, + peers, + seen: Arc::new(RwLock::new(HashSet::new())), + }) + } + + /// Verifies every cross-zone dispatch in a block, returning the keys to mark + /// seen, or the reason verification could not complete. + /// + /// [`CrossZoneVerifyError::Forged`] means the caller must halt ingestion; + /// [`CrossZoneVerifyError::PeerUnavailable`] means the caller must hold its + /// read cursor and retry, since the block is not yet judged either way. + /// + /// The caller MUST record the returned keys via [`Self::record_seen`] only + /// after the block applies, so the seen-set mirrors the inbox's on-chain + /// seen-shard. Marking a key from a block that never applies would let a later + /// forged dispatch reuse it to skip re-derivation while the inbox delivers the + /// forgery. A key already seen is a replay the inbox no-ops, so it is accepted + /// without re-derivation rather than halting on a legitimate re-delivery. + pub async fn verify_block( + &self, + block: &Block, + ) -> Result, CrossZoneVerifyError> { + let mut verified = Vec::new(); + for tx in &block.body.transactions { + let Some(msg) = Self::decode_dispatch(tx) else { + continue; + }; + + let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index); + if self.seen.read().await.contains(&key) { + debug!( + "Skipping already-seen cross-zone dispatch from zone {} block {} tx {} (replay no-op)", + hex::encode(msg.src_zone), + msg.src_block_id, + msg.src_tx_index + ); + continue; + } + + let expected = self.rederive(&msg).await?; + if LeeTransaction::Public(expected) != *tx { + return Err(CrossZoneVerifyError::Forged(anyhow!( + "forged cross-zone dispatch from zone {} block {} tx {}: re-derivation mismatch", + hex::encode(msg.src_zone), + msg.src_block_id, + msg.src_tx_index + ))); + } + + info!( + "Verified cross-zone dispatch from zone {} block {} tx {}", + hex::encode(msg.src_zone), + msg.src_block_id, + msg.src_tx_index + ); + verified.push(key); + } + Ok(verified) + } + + /// Marks the given dispatch keys seen, so a later replay of them is accepted + /// without re-derivation. Call only after the block that carried them has been + /// applied on chain (see [`Self::verify_block`]). + pub async fn record_seen(&self, keys: Vec) { + if keys.is_empty() { + return; + } + self.seen.write().await.extend(keys); + } + + /// Decodes a transaction into the cross-zone message it dispatches, or `None` + /// if it is not an inbox dispatch. + fn decode_dispatch(tx: &LeeTransaction) -> Option { + let LeeTransaction::Public(public_tx) = tx else { + return None; + }; + if public_tx.message().program_id != programs::cross_zone_inbox().id() { + return None; + } + match risc0_zkvm::serde::from_slice::( + &public_tx.message().instruction_data, + ) { + Ok(InboxInstruction::Dispatch(msg)) => Some(msg), + // Only a dispatch carries a cross-zone message to re-derive; a genesis + // `InitConfig` is not verifier-relevant. + Ok(InboxInstruction::InitConfig(_)) | Err(_) => None, + } + } + + /// Re-derives the dispatch transaction the watcher should have injected for + /// `msg`, reading the source emission from the peer's finalized block. + async fn rederive( + &self, + msg: &CrossZoneMessage, + ) -> Result { + let peer_block = self + .wait_for_peer_block(msg.src_zone, msg.src_block_id) + .await?; + + // Equivocation defense: the source block must be signed by the peer's + // pinned block-signing key, not merely inscribed on the channel. + if let Some(expected) = self.peer_pubkeys.get(&msg.src_zone) + && !peer_block.is_signed_by(expected) + { + return Err(CrossZoneVerifyError::Forged(anyhow!( + "forged cross-zone dispatch: peer zone {} block {} is not signed by the pinned block-signing key", + hex::encode(msg.src_zone), + msg.src_block_id + ))); + } + + // Everything below is a property of the peer block just read, so a + // mismatch is the dispatch lying about it, not a transient condition. + let emission_tx = peer_block + .body + .transactions + .get(usize::try_from(msg.src_tx_index).expect("u32 index fits in usize")) + .ok_or_else(|| { + CrossZoneVerifyError::Forged(anyhow!( + "src_tx_index {} out of range in peer block", + msg.src_tx_index + )) + })?; + + let LeeTransaction::Public(emission_tx) = emission_tx else { + return Err(CrossZoneVerifyError::Forged(anyhow!( + "peer emission transaction is not public" + ))); + }; + let message = emission_tx.message(); + let emission = + extract_emission(message.program_id, &message.instruction_data).ok_or_else(|| { + CrossZoneVerifyError::Forged(anyhow!( + "peer transaction at src_tx_index is not a recognized emitter" + )) + })?; + + if emission.target_zone != self.self_zone { + return Err(CrossZoneVerifyError::Forged(anyhow!( + "peer emission targets a different zone" + ))); + } + + Ok(build_dispatch_from_emission( + msg.src_zone, + msg.src_block_id, + msg.src_tx_index, + message.program_id, + emission.target_program_id, + &emission.target_accounts, + emission.payload, + )) + } + + /// Resolves the referenced peer block, distinguishing forgery from lag. + /// + /// A `block_id` inside the run verified from the peer's genesis (see + /// [`PeerChain::verified_prefix`]) that we do not hold does not exist on the + /// peer chain, so reject it. Otherwise the reader has not reached it, so + /// wait, and give up after [`PEER_BLOCK_WAIT_TIMEOUT`] rather than block + /// ingestion forever. A reference to a block the peer will never produce + /// stalls rather than being rejected, since that is indistinguishable from a + /// peer that has not produced it yet; either way it is never applied. + async fn wait_for_peer_block( + &self, + zone: ZoneId, + block_id: u64, + ) -> Result { + let mut waited = Duration::ZERO; + loop { + match self.peers.resolve(zone, block_id).await { + PeerLookup::Cached(block) => return Ok(*block), + // A backstop, not the live path: every id inside the run is + // cached by construction. Bounding the cache must preserve that + // or track a floor alongside the prefix, since an evicted block + // is not a forged one and reporting it as forged would halt a + // legitimate dispatch. + PeerLookup::InsideRun => { + return Err(CrossZoneVerifyError::Forged(anyhow!( + "forged cross-zone reference: peer zone {} chain is verified past block {} but it is absent", + hex::encode(zone), + block_id + ))); + } + PeerLookup::Behind => {} + } + if waited >= PEER_BLOCK_WAIT_TIMEOUT { + return Err(CrossZoneVerifyError::PeerUnavailable { + zone, + block_id, + waited, + }); + } + if !waited.is_zero() && waited.as_secs().is_multiple_of(LAG_LOG_INTERVAL.as_secs()) { + info!( + "Waiting for peer zone {} to finalize block {} ({}s); reader is behind", + hex::encode(zone), + block_id, + waited.as_secs() + ); + } + tokio::time::sleep(PEER_BLOCK_POLL_INTERVAL).await; + waited = waited.saturating_add(PEER_BLOCK_POLL_INTERVAL); + } + } +} + +/// The outcome of one pass over a peer's message stream. +#[derive(Debug, PartialEq, Eq)] +struct PeerPass { + /// Where the next pass resumes from. + cursor: Option, + /// Set when the pass ended early on a message that would not decode. + stalled_at: Option, +} + +/// Reads a peer zone's finalized blocks from Bedrock into the shared cache. +#[expect( + clippy::infinite_loop, + reason = "the peer reader runs for the lifetime of the indexer process" +)] +async fn read_peer( + zone_indexer: ZoneIndexer, + peer_zone: ZoneId, + peers: PeerBlocks, + poll_interval: Duration, +) { + info!( + "Cross-zone peer reader started for {}", + hex::encode(peer_zone) + ); + + let mut cursor = None; + // The slot the reader is stuck on and how many passes it has spent there. + // Keyed by slot: the retry budget is per slot, so a failure at a new slot + // must not inherit an older slot's count and be skipped on its first try. + let mut stalled: Option<(Slot, u32)> = None; + let mut skip_slot = None; + loop { + match zone_indexer.next_messages(cursor).await { + Ok(stream) => { + let pass = consume_peer_stream(stream, peer_zone, &peers, cursor, skip_slot).await; + cursor = pass.cursor; + if let Some(slot) = pass.stalled_at { + let attempts = match stalled { + Some((prev, attempts)) if prev == slot => attempts.saturating_add(1), + _ => 1, + }; + if attempts >= DECODE_RETRY_LIMIT { + // Reading on leaves a hole: dispatches referencing the + // skipped block can no longer be verified, but every + // later block stays readable. + error!( + "Peer reader for {} could not decode slot {slot:?} after {attempts} attempts; reading past it.", + hex::encode(peer_zone) + ); + skip_slot = Some(slot); + stalled = None; + } else { + stalled = Some((slot, attempts)); + } + } else { + stalled = None; + skip_slot = None; + } + } + Err(err) => error!( + "Peer reader next_messages failed for {}: {err}", + hex::encode(peer_zone) + ), + } + + tokio::time::sleep(poll_interval).await; + } +} + +/// Caches the finalized peer blocks carried by `stream`. +/// +/// A block that fails to deserialize ends the pass and holds the cursor at the +/// last fully-consumed slot, so the next poll re-reads it and a transient +/// failure heals itself. `skip_slot` names a slot the caller gave up on after +/// [`DECODE_RETRY_LIMIT`] attempts, which is read past instead so a permanently +/// undecodable inscription cannot wedge the reader. Skipping only leaves a hole, +/// which cannot advance [`PeerChain::verified_prefix`] past itself. +/// +/// The cursor advances only on a slot boundary, since one slot can carry several +/// messages and resuming mid-slot would skip the ones after the failure. This +/// relies on the stream never truncating mid-slot, which holds because the +/// zone-sdk materializes a whole batch before yielding and batches end on slot +/// boundaries. +async fn consume_peer_stream( + stream: S, + peer_zone: ZoneId, + peers: &PeerBlocks, + resume_from: Option, + skip_slot: Option, +) -> PeerPass +where + S: Stream, +{ + let mut stream = std::pin::pin!(stream); + let mut cursor = resume_from; + // The slot being consumed: cached so far, but there may be more to come. + let mut in_progress: Option = None; + + while let Some((msg, slot)) = stream.next().await { + if in_progress != Some(slot) { + cursor = in_progress.or(cursor); + in_progress = Some(slot); + } + + let ZoneMessage::Block(zone_block) = msg else { + continue; + }; + match borsh::from_slice::(&zone_block.data) { + Ok(block) => peers.insert(peer_zone, block).await, + Err(err) if skip_slot == Some(slot) => { + debug!( + "Peer reader skipping undecodable block from {} at slot {slot:?}: {err}", + hex::encode(peer_zone) + ); + } + Err(err) => { + error!( + "Peer reader failed to deserialize block from {} at slot {slot:?}: {err}. Holding the cursor and retrying.", + hex::encode(peer_zone) + ); + return PeerPass { + cursor, + stalled_at: Some(slot), + }; + } + } + } + + PeerPass { + cursor: in_progress.or(cursor), + stalled_at: None, + } +} + +#[cfg(test)] +mod tests { + use common::test_utils::produce_dummy_block; + use futures::stream; + use lee::{ + PrivateKey, PublicKey, PublicTransaction, + public_transaction::{Message, WitnessSet}, + }; + use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription}; + use logos_blockchain_zone_sdk::ZoneBlock; + use ping_core::{SenderInstruction, ping_record_pda}; + + use super::*; + + const SELF_ZONE: ZoneId = [1; 32]; + const PEER_ZONE: ZoneId = [2; 32]; + const PEER_BLOCK_ID: u64 = 5; + + fn verifier() -> CrossZoneVerifier { + verifier_with_pinned_keys(HashMap::new()) + } + + fn verifier_with_pinned_keys(peer_pubkeys: HashMap) -> CrossZoneVerifier { + CrossZoneVerifier { + self_zone: SELF_ZONE, + peer_pubkeys, + peers: PeerBlocks::default(), + seen: Arc::new(RwLock::new(HashSet::new())), + } + } + + /// A `ping_sender` emission addressed to `SELF_ZONE` carrying `payload`. + fn emission(payload: &[u8]) -> LeeTransaction { + let receiver_id = programs::ping_receiver().id(); + let send = SenderInstruction::Send { + outbox_program_id: programs::cross_zone_outbox().id(), + target_zone: SELF_ZONE, + target_program_id: receiver_id, + target_accounts: vec![ping_record_pda(receiver_id).into_value()], + payload: payload.to_vec(), + ordinal: 0, + }; + let message = Message::try_new(programs::ping_sender().id(), vec![], vec![], send) + .expect("emission serializes"); + LeeTransaction::Public(PublicTransaction::new( + message, + WitnessSet::from_raw_parts(vec![]), + )) + } + + /// A peer-stream item inscribing `data` at `slot`. + fn peer_msg(data: Vec, slot: u64) -> (ZoneMessage, Slot) { + ( + ZoneMessage::Block(ZoneBlock { + id: MsgId::from([0; 32]), + data: Inscription::try_from(data).expect("test inscription is within bounds"), + }), + Slot::from(slot), + ) + } + + /// A hash-linked chain of `len` blocks from genesis, each carrying a `b"hi"` + /// emission. Only a chain built this way advances the verified prefix. + fn linked_chain(len: u64) -> Vec { + let mut prev = None; + let mut blocks = Vec::new(); + for offset in 0..len { + let block = produce_dummy_block( + GENESIS_BLOCK_ID.saturating_add(offset), + prev, + vec![emission(b"hi")], + ); + prev = Some(block.header.hash); + blocks.push(block); + } + blocks + } + + /// A peer-stream item carrying `block`. + fn peer_block_msg(block: &Block, slot: u64) -> (ZoneMessage, Slot) { + peer_msg(borsh::to_vec(block).expect("block serializes"), slot) + } + + /// A peer-stream item whose inscription is not a decodable block. + fn undecodable_msg(slot: u64) -> (ZoneMessage, Slot) { + peer_msg(b"not a block".to_vec(), slot) + } + + /// The dispatch a watcher would inject for a `PEER_BLOCK_ID` emission of `payload`. + fn dispatch(payload: &[u8]) -> LeeTransaction { + let receiver_id = programs::ping_receiver().id(); + LeeTransaction::Public(build_dispatch_from_emission( + PEER_ZONE, + PEER_BLOCK_ID, + 0, + programs::ping_sender().id(), + receiver_id, + &[ping_record_pda(receiver_id).into_value()], + payload.to_vec(), + )) + } + + #[tokio::test] + async fn verifies_dispatch_matching_a_peer_emission() { + let verifier = verifier(); + verifier + .peers + .insert( + PEER_ZONE, + produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]), + ) + .await; + + let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + verifier + .verify_block(&block) + .await + .expect("dispatch matching the peer emission verifies"); + } + + #[tokio::test] + async fn rejects_dispatch_with_no_matching_emission() { + let verifier = verifier(); + // The peer block carries the real emission, but the block claims a + // different payload, so re-derivation does not reproduce it. + verifier + .peers + .insert( + PEER_ZONE, + produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"real")]), + ) + .await; + + let block = produce_dummy_block(9, None, vec![dispatch(b"forged")]); + let err = verifier.verify_block(&block).await.unwrap_err(); + assert!( + err.to_string().contains("forged"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn verifies_dispatch_signed_by_the_pinned_peer_key() { + // produce_dummy_block signs with PrivateKey([37; 32]); pin its pubkey. + let signer = PublicKey::new_from_private_key(&PrivateKey::try_new([37; 32]).unwrap()); + let mut keys = HashMap::new(); + keys.insert(PEER_ZONE, signer); + let verifier = verifier_with_pinned_keys(keys); + verifier + .peers + .insert( + PEER_ZONE, + produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]), + ) + .await; + + let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + verifier + .verify_block(&block) + .await + .expect("a dispatch from the pinned signer verifies"); + } + + #[tokio::test] + async fn rejects_dispatch_from_a_block_not_signed_by_the_pinned_key() { + // Pin a different key than the one that signed the peer block. + let mut keys = HashMap::new(); + keys.insert(PEER_ZONE, PublicKey::try_new([42; 32]).unwrap()); + let verifier = verifier_with_pinned_keys(keys); + verifier + .peers + .insert( + PEER_ZONE, + produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]), + ) + .await; + + let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + let err = verifier.verify_block(&block).await.unwrap_err(); + assert!( + err.to_string().contains("pinned"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn accepts_replayed_dispatch_as_noop() { + let verifier = verifier(); + verifier + .peers + .insert( + PEER_ZONE, + produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]), + ) + .await; + + let first = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + let keys = verifier + .verify_block(&first) + .await + .expect("first delivery verifies"); + // Mark the delivery seen, as the ingest loop does once the block applies. + verifier.record_seen(keys).await; + + // Replace the peer block with a different emission so re-deriving the + // replay would mismatch. The replay must still be accepted, proving it is + // the seen-key short-circuit (the inbox no-ops it on chain) and not a + // successful re-derivation. + verifier + .peers + .insert( + PEER_ZONE, + produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"different")]), + ) + .await; + + let replay = produce_dummy_block(10, None, vec![dispatch(b"hi")]); + verifier + .verify_block(&replay) + .await + .expect("a replay is accepted as an on-chain no-op"); + } + + #[tokio::test] + async fn unaccepted_dispatch_does_not_poison_seen() { + // A dispatch verified in a block that never applies (e.g. one that parks) + // must not be marked seen. Otherwise a later forged dispatch could reuse + // its key to skip re-derivation, while the inbox, never having recorded + // the key on chain, would deliver the forgery. + let verifier = verifier(); + verifier + .peers + .insert( + PEER_ZONE, + produce_dummy_block(PEER_BLOCK_ID, None, vec![emission(b"hi")]), + ) + .await; + + // The dispatch verifies, but the block is not applied, so record_seen is + // not called (the ingest loop records only after an Applied outcome). + let first = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + verifier + .verify_block(&first) + .await + .expect("dispatch verifies"); + + // A forged dispatch reusing the same key (same src zone, block, tx index) + // with a different payload must still be re-derived and rejected, since + // its key was never recorded as seen. + let forged = produce_dummy_block(10, None, vec![dispatch(b"forged")]); + let err = verifier.verify_block(&forged).await.unwrap_err(); + assert!( + err.to_string().contains("forged"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn peer_reader_advances_over_a_fully_decoded_stream() { + let peers = PeerBlocks::default(); + let chain = linked_chain(2); + let stream = stream::iter(vec![ + peer_block_msg(&chain[0], 0), + peer_block_msg(&chain[1], 1), + ]); + + let pass = consume_peer_stream(stream, PEER_ZONE, &peers, None, None).await; + + assert_eq!(pass.cursor, Some(Slot::from(1))); + assert_eq!(pass.stalled_at, None); + assert_eq!(peers.verified_prefix(PEER_ZONE).await, Some(2)); + } + + #[tokio::test] + async fn a_block_that_does_not_link_does_not_extend_the_verified_run() { + let peers = PeerBlocks::default(); + let genesis = linked_chain(1); + peers.insert(PEER_ZONE, genesis[0].clone()).await; + // Claims the next id, but not the predecessor it would have to follow. + peers + .insert( + PEER_ZONE, + produce_dummy_block(GENESIS_BLOCK_ID + 1, None, vec![emission(b"hi")]), + ) + .await; + + assert_eq!( + peers.verified_prefix(PEER_ZONE).await, + Some(GENESIS_BLOCK_ID) + ); + } + + #[tokio::test] + async fn a_block_arriving_between_the_two_halves_of_a_lookup_is_not_forged() { + // `resolve` answers "cached?" and "inside the verified run?" under one + // lock. Split across two, an insert landing between them reads as + // absent-and-inside-the-run, the forgery signal, for a cached block. + let peers = PeerBlocks::default(); + for block in linked_chain(2) { + peers.insert(PEER_ZONE, block).await; + } + + assert!(matches!( + peers.resolve(PEER_ZONE, 2).await, + PeerLookup::Cached(_) + )); + assert!(matches!( + peers.resolve(PEER_ZONE, 3).await, + PeerLookup::Behind + )); + } + + #[tokio::test] + async fn peer_reader_holds_its_cursor_on_an_undecodable_block() { + let peers = PeerBlocks::default(); + let chain = linked_chain(3); + let stream = stream::iter(vec![ + peer_block_msg(&chain[0], 0), + undecodable_msg(1), + peer_block_msg(&chain[2], 2), + ]); + + let pass = consume_peer_stream(stream, PEER_ZONE, &peers, None, None).await; + + assert_eq!(pass.cursor, Some(Slot::from(0))); + assert_eq!(pass.stalled_at, Some(Slot::from(1))); + assert_eq!(peers.verified_prefix(PEER_ZONE).await, Some(1)); + assert!(peers.get(PEER_ZONE, 3).await.is_none()); + } + + #[tokio::test] + async fn peer_reader_does_not_resume_inside_a_partially_failed_slot() { + let peers = PeerBlocks::default(); + let chain = linked_chain(1); + // One slot can carry several messages; the second one fails. + let stream = stream::iter(vec![peer_block_msg(&chain[0], 7), undecodable_msg(7)]); + + let pass = consume_peer_stream(stream, PEER_ZONE, &peers, Some(Slot::from(6)), None).await; + + // Slot 7 is re-read whole next pass, not resumed past the failure. + assert_eq!(pass.cursor, Some(Slot::from(6))); + } + + #[tokio::test] + async fn peer_reader_reads_past_a_slot_it_has_given_up_on() { + // After DECODE_RETRY_LIMIT attempts the caller nominates the slot to + // skip, so a permanently undecodable inscription cannot wedge the reader. + let peers = PeerBlocks::default(); + let chain = linked_chain(3); + let stream = stream::iter(vec![ + peer_block_msg(&chain[0], 0), + undecodable_msg(1), + peer_block_msg(&chain[2], 2), + ]); + + let pass = consume_peer_stream(stream, PEER_ZONE, &peers, None, Some(Slot::from(1))).await; + + assert_eq!(pass.cursor, Some(Slot::from(2)), "the pass drains"); + assert_eq!(pass.stalled_at, None); + // Block 3 is cached and servable, so dispatches referencing it verify. + assert!(peers.get(PEER_ZONE, 3).await.is_some()); + // But the hole stops the verified run, so block 2 is never called forged. + assert_eq!(peers.verified_prefix(PEER_ZONE).await, Some(1)); + } + + #[tokio::test(start_paused = true)] + async fn undecodable_peer_block_does_not_make_a_later_dispatch_look_forged() { + let verifier = verifier(); + let chain = linked_chain(3); + let stream = stream::iter(vec![ + peer_block_msg(&chain[0], 0), + undecodable_msg(1), + peer_block_msg(&chain[2], 2), + ]); + consume_peer_stream( + stream, + PEER_ZONE, + &verifier.peers, + None, + Some(Slot::from(1)), + ) + .await; + + // Regression: the reader cached block 3, so the old `max(cached ids)` + // high-water mark reached 3 and a dispatch referencing block 2 was + // rejected as forged, halting ingestion permanently. + let err = verifier + .wait_for_peer_block(PEER_ZONE, 2) + .await + .expect_err("block 2 was never read, so it cannot be resolved"); + assert!( + matches!(err, CrossZoneVerifyError::PeerUnavailable { .. }), + "a block outside the verified run is lag, not forgery: {err}" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_high_block_id_cannot_poison_the_forgery_test() { + // A peer picks its own block ids, so one inscribed block claiming a huge + // id would drive a `max(cached ids)` high-water mark past every real id + // and make each later dispatch look forged. It cannot extend the + // verified run, so it is inert. + let verifier = verifier(); + let chain = linked_chain(2); + verifier.peers.insert(PEER_ZONE, chain[0].clone()).await; + verifier.peers.insert(PEER_ZONE, chain[1].clone()).await; + verifier + .peers + .insert( + PEER_ZONE, + produce_dummy_block(u64::MAX, None, vec![emission(b"hi")]), + ) + .await; + + assert_eq!(verifier.peers.verified_prefix(PEER_ZONE).await, Some(2)); + let err = verifier + .wait_for_peer_block(PEER_ZONE, 3) + .await + .expect_err("block 3 has not been read yet"); + assert!( + matches!(err, CrossZoneVerifyError::PeerUnavailable { .. }), + "a block beyond the verified run is lag, not forgery: {err}" + ); + } + + #[tokio::test] + async fn a_transient_decode_failure_heals_on_the_next_pass() { + let verifier = verifier(); + let chain = linked_chain(PEER_BLOCK_ID); + + let pass = consume_peer_stream( + stream::iter(vec![undecodable_msg(0)]), + PEER_ZONE, + &verifier.peers, + None, + None, + ) + .await; + assert_eq!(pass.cursor, None, "the failed slot is not skipped"); + assert_eq!(pass.stalled_at, Some(Slot::from(0))); + + // The next pass re-reads the same slot, which now decodes. + let pass = consume_peer_stream( + stream::iter(chain.iter().enumerate().map(|(index, block)| { + peer_block_msg(block, u64::try_from(index).expect("test index fits in u64")) + })), + PEER_ZONE, + &verifier.peers, + pass.cursor, + None, + ) + .await; + assert_eq!(pass.stalled_at, None); + + let block = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + verifier + .verify_block(&block) + .await + .expect("the dispatch verifies once the peer block has been read"); + } +} diff --git a/lez/indexer/core/src/lib.rs b/lez/indexer/core/src/lib.rs index 0d595fc0..ccf8dbe7 100644 --- a/lez/indexer/core/src/lib.rs +++ b/lez/indexer/core/src/lib.rs @@ -2,36 +2,110 @@ use std::{path::Path, sync::Arc}; use anyhow::Result; use arc_swap::ArcSwap; +pub use chain_state::{AcceptOutcome, BlockIngestError, StallReason}; +use chain_state::{Anchor, ChainConsistency}; use common::block::Block; -// ToDo: Remove after testnet +// TODO: Remove after testnet use futures::StreamExt as _; use log::{error, info, warn}; -use logos_blockchain_core::header::HeaderId; use logos_blockchain_zone_sdk::{ - CommonHttpClient, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, + CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, }; +use retry::ApplyRetryGate; use crate::{ block_store::IndexerStore, config::IndexerConfig, + cross_zone_verifier::{CrossZoneVerifier, CrossZoneVerifyError}, status::{IndexerStatus, IndexerSyncStatus}, }; pub mod block_store; pub mod config; +pub mod cross_zone_verifier; +mod retry; pub mod status; +/// Consecutive failed apply attempts of the same block before parking. +const APPLY_RETRY_LIMIT: u32 = 3; + +/// Which slot the ingest loop is currently inside, so the read cursor only ever +/// moves on a slot boundary. +/// +/// One L1 slot can carry several L2 blocks, and the channel stream resumes +/// *after* the stored slot. Advancing the cursor as each block is handled would +/// therefore put a later block in the same slot beyond the cursor whenever a +/// pass ends early, and nothing would ever read it again. +#[derive(Default)] +struct SlotProgress(Option); + #[derive(Clone)] pub struct IndexerCore { pub zone_indexer: Arc>, + /// Direct node handle for queries outside `ZoneIndexer`'s streaming API. + pub node: NodeHttpClient, pub config: IndexerConfig, pub store: IndexerStore, /// Live ingestion status; updated by the ingest stream, read by `status`. pub status: Arc>, + /// Option B cross-zone verifier; `None` when cross-zone messaging is disabled. + pub verifier: Option, +} + +impl SlotProgress { + /// Records that a message from `slot` is being handled, returning the slot + /// that just completed, if this message begins a new one. + fn enter(&mut self, slot: Slot) -> Option { + if self.0 == Some(slot) { + return None; + } + self.0.replace(slot) + } + + /// The slot in progress when the stream drained cleanly, which is therefore + /// complete. Not called when a pass ends early: that slot must be re-read. + const fn drained(self) -> Option { + self.0 + } } impl IndexerCore { - pub fn new(config: IndexerConfig, storage_dir: &Path) -> Result { + /// Builds the core, then verifies the stored chain matches the channel's by + /// re-reading the channel at the stored tip's position. + /// + /// On mismatch: refuse (error) unless `config.allow_chain_reset` is set, in which case wipe the + /// store and re-index from scratch. + pub async fn new(config: IndexerConfig, storage_dir: &Path) -> Result { + let home = storage_dir.join(format!("rocksdb-{}", config.channel_id)); + let core = Self::open(config.clone(), storage_dir)?; + match core.verify_chain_consistency().await? { + // `Inconclusive` is deliberately treated the same as `Consistent`. + // + // We could not prove a reset, so proceed from the cursor without wiping + // a possibly-valid store. A genuinely divergent chain is still caught + // later when the ingest loop tries to apply and parks. + ChainConsistency::Consistent | ChainConsistency::Inconclusive => Ok(core), + ChainConsistency::Inconsistent(mismatch) if config.allow_chain_reset => { + warn!( + "Chain reset detected ({mismatch}). Wiping indexer store at {} and \ + re-indexing.", + home.display() + ); + drop(core); // sole owner before the ingest task is spawned → closes the DB + storage::indexer::RocksDBIO::destroy(&home)?; + Self::open(config, storage_dir) + } + ChainConsistency::Inconsistent(mismatch) => Err(anyhow::anyhow!( + "Indexer store at {} holds a different chain than the channel now serves \ + ({mismatch}). Delete the indexer storage directory, point at a fresh one, or \ + set `allow_chain_reset` in the indexer config.", + home.display() + )), + } + } + + /// Opens the store and builds the core without the chain-identity check. + fn open(config: IndexerConfig, storage_dir: &Path) -> Result { // Namespace the DB by channel so indexers on different channels can // share a storage dir without their RocksDB state colliding. let home = storage_dir.join(format!("rocksdb-{}", config.channel_id)); @@ -41,16 +115,78 @@ impl IndexerCore { CommonHttpClient::new(basic_auth), config.bedrock_config.addr.clone(), ); - let zone_indexer = ZoneIndexer::new(config.channel_id, node); + let zone_indexer = ZoneIndexer::new(config.channel_id, node.clone()); + + // Cross-zone programs are base builtins, and their config accounts are + // reconstructed by replaying the genesis block's InitConfig transactions; + // neither is seeded here. Only bridge-lock holdings (source side), not + // produced by any transaction, are still seeded directly. + let genesis_accounts: Vec<_> = config + .bridge_lock_holdings + .iter() + .map(|holding| cross_zone::build_holding_account(holding.holder, holding.amount)) + .collect(); + + // Option B verifier: re-derives each cross-zone dispatch from the peer's + // finalized blocks. `None` when cross-zone messaging is disabled. + let verifier = CrossZoneVerifier::start(&config); Ok(Self { zone_indexer: Arc::new(zone_indexer), + store: IndexerStore::open_db(&home, genesis_accounts)?, + node, config, - store: IndexerStore::open_db(&home)?, status: Arc::new(ArcSwap::from_pointee(IndexerSyncStatus::starting())), + verifier, }) } + /// Verifies whether the channel still serves the same chain the store was built from. + /// This may change frequently during development where we reset the chain from time to + /// time in devnet/testnet, but we do not expect [`ChainConsistency::Inconsistent`] in + /// production. + /// + /// To compare the chains, we use an [`Anchor`] block that is either the parked L2 block + /// while stalled, or the tip L2 block at its own inscription L1 slot. + pub(crate) async fn verify_chain_consistency(&self) -> Result { + let Some(anchor) = self.get_startup_anchor()? else { + // empty or cold store: nothing to compare + return Ok(ChainConsistency::Inconclusive); + }; + + chain_state::verify_chain_consistency(&self.node, self.config.channel_id, &anchor).await + } + + /// Builds the anchor for the startup check. + /// + /// - If stalled, returns the recorded _parked_ block + /// - If not stalled, returns the validated tip at its _own_ inscription slot. + /// - If the store is empty, returns `None`. + fn get_startup_anchor(&self) -> Result> { + if let Some(stall) = self.store.get_stall_reason()? { + return Ok(Some(Anchor::new( + stall.l1_slot, + stall.block_id.zip(stall.block_hash), + ))); + } + + // not stalled, so anchor on the tip at its own inscription slot + let Some(slot) = self + .store + .get_tip_slot()? + .map_or_else(|| self.store.get_zone_cursor(), |slot| Ok(Some(slot)))? + else { + return Ok(None); + }; + let Some(tip_id) = self.store.get_last_block_id()? else { + return Ok(None); + }; + let Some(tip) = self.store.get_block_at_id(tip_id)? else { + return Ok(None); + }; + Ok(Some(Anchor::new(slot, Some((tip_id, tip.header.hash))))) + } + /// Snapshot of the current ingestion status (sync state + indexed tip). /// /// Combines the ingest loop's live status with the L2 tip read fresh from the @@ -58,10 +194,27 @@ impl IndexerCore { #[must_use] pub fn status(&self) -> IndexerStatus { let sync = IndexerSyncStatus::clone(&self.status.load()); - let indexed_block_id = self.store.get_last_block_id().ok().flatten(); + // Log-and-fall-back rather than collapsing a store error into the same + // `None` as "legitimately absent": a DB read failure must not silently + // masquerade as "no tip yet" / "no stall recorded" in the snapshot. + let indexed_block_id = match self.store.get_last_block_id() { + Ok(id) => id, + Err(err) => { + warn!("Failed to read last indexed block id for status: {err:#}"); + None + } + }; + let stall_reason = match self.store.get_stall_reason() { + Ok(reason) => reason, + Err(err) => { + warn!("Failed to read stall reason for status: {err:#}"); + None + } + }; IndexerStatus { sync, indexed_block_id, + stall_reason, } } @@ -70,6 +223,41 @@ impl IndexerCore { self.status.store(Arc::new(status)); } + /// Advances the in-memory L1 read cursor past `slot` and persists it. + /// A persist failure is only logged: the worst case is re-reading a batch + /// after a restart, which ingestion handles idempotently. + fn advance_cursor(&self, cursor: &mut Option, slot: Slot) { + *cursor = Some(slot); + if let Err(err) = self.store.set_zone_cursor(&slot) { + warn!("Failed to persist indexer cursor: {err:#}"); + } + } + + /// Parks on an inscription that could not be parsed as an L2 block: + /// records the stall and flips the status. The validated tip stays frozen. + /// + /// Returns `false` if the stall could not be recorded durably; the caller + /// must then hold the cursor and retry instead of advancing past the slot. + fn park_undeserializable(&self, slot: Slot, error: std::io::Error) -> bool { + let error = anyhow::Error::new(error); + + // use `:#` to get the entire error chain + let reason = format!("{error:#}"); + error!("Failed to deserialize L2 block from zone-sdk: {reason}"); + if let Err(err) = + self.store + .record_stall(None, slot, BlockIngestError::Deserialize(reason.clone())) + { + error!("Failed to record stall reason: {err:#}"); + self.set_status(IndexerSyncStatus::error(format!("store error: {err:#}"))); + return false; + } + self.set_status(IndexerSyncStatus::stalled(format!( + "failed to deserialize L2 block: {reason}" + ))); + true + } + pub fn subscribe_parse_block_stream(&self) -> impl futures::Stream> + '_ { let poll_interval = self.config.consensus_info_polling_interval; let initial_cursor = self @@ -79,9 +267,10 @@ impl IndexerCore { async_stream::stream! { let mut cursor = initial_cursor; + let mut retry_gate = ApplyRetryGate::new(); - if cursor.is_some() { - info!("Resuming indexer from cursor {cursor:?}"); + if let Some(slot) = &cursor { + info!("Resuming indexer from cursor {slot:?}"); } else { info!("Starting indexer from beginning of channel"); } @@ -90,8 +279,6 @@ impl IndexerCore { let stream = match self.zone_indexer.next_messages(cursor).await { Ok(s) => s, Err(err) => { - // `next_messages` reads L1 consensus info internally, so - // this also covers an unreachable/misconfigured L1 node. error!("Failed to start zone-sdk next_messages stream: {err}"); self.set_status(IndexerSyncStatus::error(format!( "cannot reach L1 / read channel: {err}" @@ -102,13 +289,22 @@ impl IndexerCore { }; let mut stream = std::pin::pin!(stream); - // Flip to Syncing on the first message of this cycle (not merely on - // a successful poll) so the steady-state CaughtUp status doesn't - // flicker. Until then the state stays Starting (cold-start scan of - // empty L1 history) or CaughtUp (idle). let mut announced_syncing = false; + let mut had_cycle_error = false; + // The slot being consumed: every message of it seen so far is + // handled, but another may follow, so the cursor may not move + // onto it yet. One L1 slot can carry several L2 blocks, and the + // stream resumes *after* the stored slot, so advancing inside a + // slot would put a later message in it beyond the cursor + // for ever if this pass ends early. + let mut in_progress = SlotProgress::default(); while let Some((msg, slot)) = stream.next().await { + // A message from a later slot means the previous one is complete. + if let Some(done) = in_progress.enter(slot) { + self.advance_cursor(&mut cursor, done); + } + if !announced_syncing { self.set_status(IndexerSyncStatus::syncing()); announced_syncing = true; @@ -116,48 +312,330 @@ impl IndexerCore { let zone_block = match msg { ZoneMessage::Block(b) => b, - // Non-block messages don't carry a cursor position; the - // next ZoneBlock advances past them implicitly. + // FIXME: will be handled in prep of decentralized sequencers ZoneMessage::Deposit(_) | ZoneMessage::Withdraw(_) => continue, }; let block: Block = match borsh::from_slice(&zone_block.data) { Ok(b) => b, - Err(e) => { - error!("Failed to deserialize L2 block from zone-sdk: {e}"); - // Advance past the broken inscription so we don't - // re-process it on restart. - cursor = Some(slot); - if let Err(err) = self.store.set_zone_cursor(&slot) { - warn!("Failed to persist indexer cursor: {err:#}"); + Err(error) => { + // The stall must be durable before the cursor moves. + if !self.park_undeserializable(slot, error) { + had_cycle_error = true; + break; } + // L1 proceeds regardless continue; } }; - info!("Indexed L2 block {}", block.header.block_id); + // Re-derive and verify every cross-zone dispatch the block + // carries before applying it, so the destination never trusts + // a dispatch just because a sequencer signed the block: a + // forged one halts ingestion rather than persisting invalid + // state, while a replay is accepted since the inbox no-ops it + // on chain. The verified keys are marked seen only once the + // block applies (below), so a block that does not apply + // cannot poison the seen-set. + let verified_keys = match &self.verifier { + Some(verifier) => match verifier.verify_block(&block).await { + Ok(keys) => keys, + Err(err @ CrossZoneVerifyError::Forged(_)) => { + error!( + "Cross-zone verification failed for block {}: {err}. Halting indexer ingestion.", + block.header.block_id + ); + self.set_status(IndexerSyncStatus::error(format!( + "cross-zone verification failed: {err}" + ))); + return; + } + // Not judged either way yet, so retry rather than halt. + Err(err @ CrossZoneVerifyError::PeerUnavailable { .. }) => { + error!( + "Cross-zone verification of block {} stalled: {err}. Holding the cursor and retrying.", + block.header.block_id + ); + self.set_status(IndexerSyncStatus::error(format!( + "cross-zone peer unavailable: {err}" + ))); + had_cycle_error = true; + break; + } + }, + None => Vec::new(), + }; - // TODO: Remove l1_header placeholder once storage layer - // no longer requires it. Zone-sdk handles L1 tracking internally. - let placeholder_l1_header = HeaderId::from([0_u8; 32]); - if let Err(err) = self.store.put_block(block.clone(), placeholder_l1_header).await { - error!("Failed to store block {}: {err:#}", block.header.block_id); + match self.store.accept_block(&block, slot).await { + Ok(AcceptOutcome::Applied) => { + if let Some(verifier) = &self.verifier { + verifier.record_seen(verified_keys).await; + } + retry_gate.reset(); + info!("Indexed L2 block {}", block.header.block_id); + self.set_status(IndexerSyncStatus::syncing()); + yield Ok(block); + } + Ok(AcceptOutcome::AlreadyApplied) => { + info!( + "Skipping already-applied block {}", + block.header.block_id + ); + } + Ok(AcceptOutcome::Parked(ingest_err)) => { + error!( + "Parked at block {}: {ingest_err}", + block.header.block_id + ); + self.set_status(IndexerSyncStatus::stalled(ingest_err.to_string())); + // L1 proceeds regardless + } + Ok(AcceptOutcome::RetryableFailure(ingest_err)) => { + let attempts = retry_gate.register_failure(block.header.block_id); + if attempts >= APPLY_RETRY_LIMIT { + error!( + "Parked at block {} after {attempts} failed apply attempts: {ingest_err}", + block.header.block_id + ); + // The stall must be durable before the cursor moves. + if let Err(err) = self.store.record_stall( + Some(&block.header), + slot, + ingest_err.clone(), + ) { + error!( + "Failed to record stall reason for block {}: {err:#}", + block.header.block_id + ); + self.set_status(IndexerSyncStatus::error(format!( + "store error: {err:#}" + ))); + had_cycle_error = true; + break; + } + self.set_status(IndexerSyncStatus::stalled(ingest_err.to_string())); + retry_gate.reset(); + } else { + error!( + "Failed to apply block {} (attempt {attempts}/{APPLY_RETRY_LIMIT}), will retry: {ingest_err}", + block.header.block_id + ); + self.set_status(IndexerSyncStatus::error(format!( + "apply failed, retrying: {ingest_err}" + ))); + had_cycle_error = true; + break; + } + } + Err(err) => { + // Infrastructure error (DB read/write), not a bad block. + // will re-poll from the same cursor next cycle. + error!( + "Store error applying block {}: {err:#}", + block.header.block_id + ); + self.set_status(IndexerSyncStatus::error(format!( + "store error: {err:#}" + ))); + had_cycle_error = true; + break; + } } - - cursor = Some(slot); - if let Err(err) = self.store.set_zone_cursor(&slot) { - warn!("Failed to persist indexer cursor: {err:#}"); - } - yield Ok(block); } - // Stream drained: caught up to LIB as of this cycle. Clears any - // prior error (e.g. a transient L1 disconnect that left no - // backlog, so the `Syncing` branch above never ran). Sleep then - // poll again. - self.set_status(IndexerSyncStatus::caught_up()); + if had_cycle_error { + // The slot in progress is not finished, so the cursor stays + // below it and the next pass re-reads it whole. + tokio::time::sleep(poll_interval).await; + continue; + } + + // The stream drained cleanly, so the slot in progress completed too. + if let Some(done) = in_progress.drained() { + self.advance_cursor(&mut cursor, done); + } + + // Stream drained. Stay Stalled if parked; otherwise we are caught up. + // A store error here must not be collapsed to "no stall recorded": + // that would wrongly flip us to caught-up, so we log and hold state. + match self.store.get_stall_reason() { + Ok(None) => self.set_status(IndexerSyncStatus::caught_up()), + Ok(Some(_)) => {} + Err(err) => { + warn!("Failed to read stall reason after draining stream; not marking caught up: {err:#}"); + } + } tokio::time::sleep(poll_interval).await; } } } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use common::{HashType, block::HashableBlockData}; + use logos_blockchain_zone_sdk::Slot; + + use super::*; + use crate::config::{ChannelId, ClientConfig, IndexerConfig}; + + /// The cursor must not move while more of the same slot may still arrive. + /// + /// Two L2 blocks in one L1 slot: the first applies, the second stalls on an + /// unavailable peer and the pass retries. If handling the first had advanced + /// the cursor onto the slot, the retry would resume past it and the second + /// block would never be read again, silently losing whatever it carried. + #[test] + fn a_slot_is_only_left_behind_once_it_is_finished() { + let mut progress = SlotProgress::default(); + let slot = Slot::from(7); + + assert_eq!( + progress.enter(slot), + None, + "nothing precedes the first slot" + ); + assert_eq!( + progress.enter(slot), + None, + "a second message in the same slot must not release it" + ); + + // The pass ends early here, so `drained` is never called and the cursor + // is still below slot 7: the next pass re-reads it whole. + } + + #[test] + fn a_completed_slot_is_released_when_the_next_one_starts() { + let mut progress = SlotProgress::default(); + + assert_eq!(progress.enter(Slot::from(3)), None); + assert_eq!(progress.enter(Slot::from(3)), None); + assert_eq!( + progress.enter(Slot::from(4)), + Some(Slot::from(3)), + "slot 3 is complete once a message from slot 4 arrives" + ); + assert_eq!(progress.drained(), Some(Slot::from(4))); + } + + #[test] + fn draining_an_untouched_stream_releases_nothing() { + assert_eq!(SlotProgress::default().drained(), None); + } + + fn unreachable_core(dir: &std::path::Path) -> IndexerCore { + let config = IndexerConfig { + consensus_info_polling_interval: Duration::from_secs(1), + bedrock_config: ClientConfig { + addr: "http://localhost:1".parse().expect("url"), + auth: None, + }, + channel_id: ChannelId::from([1; 32]), + allow_chain_reset: false, + cross_zone: None, + bridge_lock_holdings: Vec::new(), + }; + IndexerCore::open(config, dir).expect("open core") + } + + fn test_block(block_id: u64, timestamp: u64) -> Block { + HashableBlockData { + block_id, + prev_block_hash: HashType([0; 32]), + timestamp, + transactions: vec![], + } + .into_pending_block(&lee::PrivateKey::try_new([7; 32]).expect("valid key")) + } + + #[tokio::test] + async fn cold_store_is_inconclusive() { + // An empty store has no cursor, so there is nothing to compare: the check + // must be Inconclusive (not Consistent), and it returns before any L1 read. + let dir = tempfile::tempdir().expect("tempdir"); + let core = unreachable_core(dir.path()); + assert!(matches!( + core.verify_chain_consistency().await.expect("verify"), + ChainConsistency::Inconclusive + )); + } + + #[tokio::test] + async fn parked_store_with_unreachable_node_is_inconclusive() { + // Network failure is not evidence of a reset: a parked store must stay + // parked (Inconclusive), not error out or trip the wipe path. + let dir = tempfile::tempdir().expect("tempdir"); + let core = unreachable_core(dir.path()); + let parked = test_block(5, 42); + core.store + .record_stall( + Some(&parked.header), + Slot::from(1_000), + BlockIngestError::EmptyBlock, + ) + .expect("record stall"); + assert!(matches!( + core.verify_chain_consistency().await.expect("verify"), + ChainConsistency::Inconclusive + )); + } + + #[tokio::test] + async fn caught_up_store_with_unreachable_node_is_inconclusive() { + let dir = tempfile::tempdir().expect("tempdir"); + let core = unreachable_core(dir.path()); + let genesis = common::test_utils::produce_dummy_block(1, None, vec![]); + assert!(matches!( + core.store + .accept_block(&genesis, Slot::from(1_000)) + .await + .expect("accept"), + AcceptOutcome::Applied + )); + core.store + .set_zone_cursor(&Slot::from(1_000)) + .expect("set cursor"); + assert!(matches!( + core.verify_chain_consistency().await.expect("verify"), + ChainConsistency::Inconclusive + )); + } + + #[tokio::test] + async fn startup_anchor_prefers_tip_slot_over_lagging_cursor() { + // Cursor persist failures are warn-only, so the read cursor can lag the + // tip by several blocks. The anchor must pair the tip with its own + // inscription slot; pairing it with the stale cursor would make the scan + // misread the chain's intermediate blocks as re-inscriptions. + let dir = tempfile::tempdir().expect("tempdir"); + let core = unreachable_core(dir.path()); + + let genesis = common::test_utils::produce_dummy_block(1, None, vec![]); + core.store + .accept_block(&genesis, Slot::from(1_000)) + .await + .expect("accept"); + let block2 = common::test_utils::produce_dummy_block(2, Some(genesis.header.hash), vec![]); + core.store + .accept_block(&block2, Slot::from(1_005)) + .await + .expect("accept"); + let block3 = common::test_utils::produce_dummy_block(3, Some(block2.header.hash), vec![]); + core.store + .accept_block(&block3, Slot::from(1_010)) + .await + .expect("accept"); + + // Cursor last persisted at the genesis slot: two blocks behind the tip. + core.store + .set_zone_cursor(&Slot::from(1_000)) + .expect("set cursor"); + + let anchor = core.get_startup_anchor().expect("anchor").expect("present"); + let expected = Anchor::new(Slot::from(1_010), Some((3, block3.header.hash))); + assert_eq!(anchor, expected); + } +} diff --git a/lez/indexer/core/src/retry.rs b/lez/indexer/core/src/retry.rs new file mode 100644 index 00000000..d7c5f5a2 --- /dev/null +++ b/lez/indexer/core/src/retry.rs @@ -0,0 +1,64 @@ +//! Retry gate for possibly-transient block-apply failures. + +/// Counts consecutive apply failures per block id so the ingest loop can +/// retry before parking. +/// +/// A failure streak is keyed to one block id: a failure of a different block +/// starts a fresh streak. Reset only on a genuinely applied block — the +/// `AlreadyApplied` replay of the prefix after a retry cycle must not clear +/// the failing block's streak. +pub struct ApplyRetryGate { + failing: Option<(u64, u32)>, +} + +impl ApplyRetryGate { + #[must_use] + pub const fn new() -> Self { + Self { failing: None } + } + + /// Registers a failed apply of `block_id`; returns its consecutive + /// attempt count. + pub const fn register_failure(&mut self, block_id: u64) -> u32 { + let attempts = match self.failing { + Some((id, attempts)) if id == block_id => attempts.saturating_add(1), + _ => 1, + }; + self.failing = Some((block_id, attempts)); + attempts + } + + /// Clears the streak; call when a block actually applies. + pub const fn reset(&mut self) { + self.failing = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn counts_consecutive_failures_of_same_block() { + let mut gate = ApplyRetryGate::new(); + assert_eq!(gate.register_failure(7), 1); + assert_eq!(gate.register_failure(7), 2); + assert_eq!(gate.register_failure(7), 3); + } + + #[test] + fn different_block_starts_fresh_streak() { + let mut gate = ApplyRetryGate::new(); + gate.register_failure(7); + gate.register_failure(7); + assert_eq!(gate.register_failure(8), 1); + } + + #[test] + fn reset_clears_streak() { + let mut gate = ApplyRetryGate::new(); + gate.register_failure(7); + gate.reset(); + assert_eq!(gate.register_failure(7), 1); + } +} diff --git a/lez/indexer/core/src/status.rs b/lez/indexer/core/src/status.rs index 1193e124..aa182c23 100644 --- a/lez/indexer/core/src/status.rs +++ b/lez/indexer/core/src/status.rs @@ -1,9 +1,9 @@ +use chain_state::StallReason; use serde::Serialize; /// Coarse lifecycle state of the indexer's ingestion loop, so a client can tell /// "still catching up" apart from "something went wrong". #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] pub enum IndexerSyncState { /// Booted; no ingestion cycle has run yet. Starting, @@ -13,12 +13,14 @@ pub enum IndexerSyncState { CaughtUp, /// The last cycle failed (e.g. the L1 node is unreachable). See `last_error`. Error, + /// Parked on a stall reason: the validated tip is frozen awaiting a valid + /// continuation. See `last_error` and the snapshot's `stall_reason`. + Stalled, } /// Live ingestion status owned by the ingest loop: the coarse `state` plus the /// reason when it is `Error`. #[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] pub struct IndexerSyncStatus { pub state: IndexerSyncState, pub last_error: Option, @@ -56,6 +58,15 @@ impl IndexerSyncStatus { last_error: Some(reason), } } + + /// Parked on a stall reason; `reason` mirrors the stall's error message. + /// The full stall is attached to the [`IndexerStatus`] snapshot. + pub(crate) const fn stalled(reason: String) -> Self { + Self { + state: IndexerSyncState::Stalled, + last_error: Some(reason), + } + } } /// Full status snapshot returned to callers (FFI/RPC): the live [`IndexerSyncStatus`] @@ -64,11 +75,11 @@ impl IndexerSyncStatus { /// The tip is tracked by the store, not the ingest loop, so it lives here on the /// returned snapshot rather than inside the shared [`IndexerSyncStatus`]. #[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] pub struct IndexerStatus { #[serde(flatten)] pub sync: IndexerSyncStatus, pub indexed_block_id: Option, + pub stall_reason: Option, } #[cfg(test)] @@ -80,14 +91,16 @@ mod tests { let status = IndexerStatus { sync: IndexerSyncStatus::error("boom".to_owned()), indexed_block_id: Some(7), + stall_reason: None, }; let value = serde_json::to_value(&status).expect("serialize"); assert_eq!( value, serde_json::json!({ - "state": "error", - "lastError": "boom", - "indexedBlockId": 7, + "state": "Error", + "last_error": "boom", + "indexed_block_id": 7, + "stall_reason": null, }) ); } @@ -97,7 +110,35 @@ mod tests { let value = serde_json::to_value(IndexerSyncStatus::caught_up()).expect("serialize"); assert_eq!( value, - serde_json::json!({ "state": "caught_up", "lastError": null }) + serde_json::json!({ "state": "CaughtUp", "last_error": null }) ); } + + #[test] + fn stalled_status_serializes_with_stall_reason() { + use chain_state::{BlockIngestError, StallReason}; + use logos_blockchain_zone_sdk::Slot; + + let status = IndexerStatus { + sync: IndexerSyncStatus::stalled("broken chain link".to_owned()), + indexed_block_id: Some(41), + stall_reason: Some(StallReason { + block_id: Some(42), + block_hash: None, + prev_block_hash: None, + l1_slot: Slot::from(0), + error: BlockIngestError::StateTransition { + tx_index: 0, + reason: String::default(), + }, + first_seen: None, + orphans_since: 2, + }), + }; + let value = serde_json::to_value(&status).expect("serialize"); + assert_eq!(value["state"], serde_json::json!("Stalled")); + assert_eq!(value["last_error"], serde_json::json!("broken chain link")); + assert_eq!(value["indexed_block_id"], serde_json::json!(41)); + assert_eq!(value["stall_reason"]["orphans_since"], serde_json::json!(2)); + } } diff --git a/lez/indexer/ffi/Cargo.toml b/lez/indexer/ffi/Cargo.toml index a1615b75..6b74ab8b 100644 --- a/lez/indexer/ffi/Cargo.toml +++ b/lez/indexer/ffi/Cargo.toml @@ -6,7 +6,7 @@ version = "0.1.0" [dependencies] lee.workspace = true -indexer_core.workspace = true +indexer_core = { workspace = true, features = ["testnet"] } indexer_service_protocol = { workspace = true, features = ["convert"] } env_logger.workspace = true diff --git a/lez/indexer/ffi/indexer_ffi.h b/lez/indexer/ffi/indexer_ffi.h index 8347ad3c..26857af7 100644 --- a/lez/indexer/ffi/indexer_ffi.h +++ b/lez/indexer/ffi/indexer_ffi.h @@ -503,9 +503,9 @@ struct LastBlockIdResult query_last_block(const struct IndexerServiceFFI *indexe * Query the indexer's current sync status as a JSON C-string. * * The JSON schema is owned by `indexer_core` (`IndexerStatus`): an object with - * `state` (`starting`/`syncing`/`caught_up`/`error`), `indexedBlockId`, and - * `lastError`. Lets a client distinguish "still catching up" from "something - * went wrong". + * `state` (`Starting`/`Syncing`/`CaughtUp`/`Error`/`Stalled`), + * `indexed_block_id`, `last_error`, and `stall_reason`. Lets a client + * distinguish "still catching up" from "something went wrong". * * # Arguments * diff --git a/lez/indexer/ffi/src/api/lifecycle.rs b/lez/indexer/ffi/src/api/lifecycle.rs index f668f3ee..8a1eafaf 100644 --- a/lez/indexer/ffi/src/api/lifecycle.rs +++ b/lez/indexer/ffi/src/api/lifecycle.rs @@ -112,10 +112,12 @@ unsafe fn setup_indexer( unsafe { Runtime::from_borrowed(caller.as_ref()) } }; - let core = IndexerCore::new(config, &storage_dir).map_err(|e| { - log::error!("Could not initialize indexer core: {e}"); - OperationStatus::InitializationError - })?; + let core = runtime + .block_on(IndexerCore::new(config, &storage_dir)) + .map_err(|e| { + log::error!("Could not initialize indexer core: {e}"); + OperationStatus::InitializationError + })?; // The block stream writes each parsed block into the store as a side effect // of being polled, so we spawn a task that simply drains it. There are no diff --git a/lez/indexer/ffi/src/api/query.rs b/lez/indexer/ffi/src/api/query.rs index 1943f6d4..dff027fa 100644 --- a/lez/indexer/ffi/src/api/query.rs +++ b/lez/indexer/ffi/src/api/query.rs @@ -91,9 +91,9 @@ pub unsafe extern "C" fn query_last_block(indexer: *const IndexerServiceFFI) -> /// Query the indexer's current sync status as a JSON C-string. /// /// The JSON schema is owned by `indexer_core` (`IndexerStatus`): an object with -/// `state` (`starting`/`syncing`/`caught_up`/`error`), `indexedBlockId`, and -/// `lastError`. Lets a client distinguish "still catching up" from "something -/// went wrong". +/// `state` (`Starting`/`Syncing`/`CaughtUp`/`Error`/`Stalled`), +/// `indexed_block_id`, `last_error`, and `stall_reason`. Lets a client +/// distinguish "still catching up" from "something went wrong". /// /// # Arguments /// diff --git a/lez/indexer/service/Cargo.toml b/lez/indexer/service/Cargo.toml index a07a2285..44ab7068 100644 --- a/lez/indexer/service/Cargo.toml +++ b/lez/indexer/service/Cargo.toml @@ -10,7 +10,7 @@ workspace = true [dependencies] indexer_service_protocol = { workspace = true, features = ["convert"] } indexer_service_rpc = { workspace = true, features = ["server"] } -indexer_core.workspace = true +indexer_core = { workspace = true, features = ["testnet"] } clap = { workspace = true, features = ["derive"] } anyhow.workspace = true diff --git a/lez/indexer/service/configs/debug/indexer_config.json b/lez/indexer/service/configs/debug/indexer_config.json index 85227700..be5cf353 100644 --- a/lez/indexer/service/configs/debug/indexer_config.json +++ b/lez/indexer/service/configs/debug/indexer_config.json @@ -1,7 +1,8 @@ { - "consensus_info_polling_interval": "1s", - "bedrock_config": { - "addr": "http://localhost:18080" - }, - "channel_id": "0101010101010101010101010101010101010101010101010101010101010101" + "consensus_info_polling_interval": "1s", + "bedrock_config": { + "addr": "http://localhost:18080" + }, + "channel_id": "0101010101010101010101010101010101010101010101010101010101010101", + "allow_chain_reset": true } diff --git a/lez/indexer/service/configs/docker/indexer_config.json b/lez/indexer/service/configs/docker/indexer_config.json index f083ca27..ce28af0b 100644 --- a/lez/indexer/service/configs/docker/indexer_config.json +++ b/lez/indexer/service/configs/docker/indexer_config.json @@ -3,5 +3,6 @@ "bedrock_config": { "addr": "http://host.docker.internal:18080" }, - "channel_id": "0101010101010101010101010101010101010101010101010101010101010101" + "channel_id": "0101010101010101010101010101010101010101010101010101010101010101", + "allow_chain_reset": true } diff --git a/lez/indexer/service/protocol/Cargo.toml b/lez/indexer/service/protocol/Cargo.toml index 5a4176f5..b3e8f65c 100644 --- a/lez/indexer/service/protocol/Cargo.toml +++ b/lez/indexer/service/protocol/Cargo.toml @@ -11,6 +11,7 @@ workspace = true lee_core = { workspace = true, optional = true, features = ["host"] } lee = { workspace = true, optional = true } common = { workspace = true, optional = true } +indexer_core = { workspace = true, optional = true } serde = { workspace = true, features = ["derive"] } serde_with.workspace = true @@ -22,4 +23,4 @@ anyhow.workspace = true [features] # Enable conversion to/from LEE core types -convert = ["dep:lee_core", "dep:lee", "dep:common"] +convert = ["dep:lee_core", "dep:lee", "dep:common", "dep:indexer_core"] diff --git a/lez/indexer/service/protocol/src/convert.rs b/lez/indexer/service/protocol/src/convert.rs index cd0bff7e..55c4dc6c 100644 --- a/lez/indexer/service/protocol/src/convert.rs +++ b/lez/indexer/service/protocol/src/convert.rs @@ -3,11 +3,12 @@ use lee_core::account::Nonce; use crate::{ - Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, Ciphertext, Commitment, - CommitmentSetDigest, Data, EncryptedAccountData, EphemeralPublicKey, HashType, Nullifier, - PrivacyPreservingMessage, PrivacyPreservingTransaction, ProgramDeploymentMessage, - ProgramDeploymentTransaction, ProgramId, Proof, PublicKey, PublicMessage, PublicTransaction, - Signature, Transaction, ValidityWindow, WitnessSet, + Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, BlockIngestError, Ciphertext, + Commitment, CommitmentSetDigest, Data, EncryptedAccountData, EphemeralPublicKey, HashType, + IndexerStatus, IndexerSyncState, Nullifier, PrivacyPreservingMessage, + PrivacyPreservingTransaction, ProgramDeploymentMessage, ProgramDeploymentTransaction, + ProgramId, Proof, PublicKey, PublicMessage, PublicTransaction, Signature, StallReason, + Transaction, ValidityWindow, WitnessSet, }; // ============================================================================ @@ -707,3 +708,94 @@ impl TryFrom for lee_core::program::ValidityWindow { value.0.try_into() } } + +// ============================================================================ +// Indexer status conversions +// ============================================================================ + +impl From for IndexerSyncState { + fn from(value: indexer_core::status::IndexerSyncState) -> Self { + match value { + indexer_core::status::IndexerSyncState::Starting => Self::Starting, + indexer_core::status::IndexerSyncState::Syncing => Self::Syncing, + indexer_core::status::IndexerSyncState::CaughtUp => Self::CaughtUp, + indexer_core::status::IndexerSyncState::Error => Self::Error, + indexer_core::status::IndexerSyncState::Stalled => Self::Stalled, + } + } +} + +impl From for BlockIngestError { + fn from(value: indexer_core::BlockIngestError) -> Self { + match value { + indexer_core::BlockIngestError::Deserialize(msg) => Self::Deserialize(msg), + indexer_core::BlockIngestError::UnexpectedBlockId { expected, got } => { + Self::UnexpectedBlockId { expected, got } + } + indexer_core::BlockIngestError::BrokenChainLink { + expected_prev, + got_prev, + } => Self::BrokenChainLink { + expected_prev: expected_prev.into(), + got_prev: got_prev.into(), + }, + indexer_core::BlockIngestError::HashMismatch { computed, header } => { + Self::HashMismatch { + computed: computed.into(), + header: header.into(), + } + } + indexer_core::BlockIngestError::EmptyBlock => Self::EmptyBlock, + indexer_core::BlockIngestError::InvalidClockTransaction => { + Self::InvalidClockTransaction + } + indexer_core::BlockIngestError::NonPublicGenesisTransaction => { + Self::NonPublicGenesisTransaction + } + indexer_core::BlockIngestError::StateTransition { tx_index, reason } => { + Self::StateTransition { tx_index, reason } + } + } + } +} + +impl From for StallReason { + fn from(value: indexer_core::StallReason) -> Self { + let indexer_core::StallReason { + block_id, + block_hash, + prev_block_hash, + l1_slot, + error, + first_seen, + orphans_since, + } = value; + + Self { + block_id, + block_hash: block_hash.map(Into::into), + prev_block_hash: prev_block_hash.map(Into::into), + l1_slot: l1_slot.into_inner(), + error: error.into(), + first_seen, + orphans_since, + } + } +} + +impl From for IndexerStatus { + fn from(value: indexer_core::status::IndexerStatus) -> Self { + let indexer_core::status::IndexerStatus { + sync, + indexed_block_id, + stall_reason, + } = value; + + Self { + state: sync.state.into(), + last_error: sync.last_error, + indexed_block_id, + stall_reason: stall_reason.map(Into::into), + } + } +} diff --git a/lez/indexer/service/protocol/src/lib.rs b/lez/indexer/service/protocol/src/lib.rs index a670dee6..e17d539b 100644 --- a/lez/indexer/service/protocol/src/lib.rs +++ b/lez/indexer/service/protocol/src/lib.rs @@ -363,3 +363,73 @@ pub enum BedrockStatus { Safe, Finalized, } + +/// Coarse lifecycle state of the indexer's ingestion loop, so a client can tell +/// "still catching up" apart from "something went wrong". +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub enum IndexerSyncState { + /// Booted; no ingestion cycle has run yet. + Starting, + /// Streaming finalized messages toward the L1 frontier. + Syncing, + /// Drained the stream up to the last finalized block; idle until new blocks finalize. + CaughtUp, + /// The last cycle failed (e.g. the L1 node is unreachable). See `last_error`. + Error, + /// Parked on a stall reason: the validated tip is frozen awaiting a valid + /// continuation. See `last_error` and `stall_reason`. + Stalled, +} + +/// Why the indexer could not apply an L2 block from the channel. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub enum BlockIngestError { + Deserialize(String), + UnexpectedBlockId { + expected: u64, + got: u64, + }, + BrokenChainLink { + expected_prev: HashType, + got_prev: HashType, + }, + HashMismatch { + computed: HashType, + header: HashType, + }, + EmptyBlock, + InvalidClockTransaction, + NonPublicGenesisTransaction, + StateTransition { + /// Index of the failing transaction within the block body. + tx_index: u64, + reason: String, + }, +} + +/// Diagnostic record of the first block that broke the L2 chain. +/// +/// The block-derived fields are `None` for a deserialize break (no header was +/// ever parsed). `l1_slot` is the L1 slot the breaking inscription was read at. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub struct StallReason { + pub block_id: Option, + pub block_hash: Option, + pub prev_block_hash: Option, + pub l1_slot: u64, + pub error: BlockIngestError, + /// The breaking block's L2 timestamp (`None` for a deserialize break). + pub first_seen: Option, + /// Number of later non-chaining blocks seen while the tip is frozen. + pub orphans_since: u64, +} + +/// Status snapshot returned by `getStatus`: the ingestion state plus the +/// indexed L2 tip and, when stalled, the stall diagnostics. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub struct IndexerStatus { + pub state: IndexerSyncState, + pub last_error: Option, + pub indexed_block_id: Option, + pub stall_reason: Option, +} diff --git a/lez/indexer/service/rpc/src/lib.rs b/lez/indexer/service/rpc/src/lib.rs index 5763fe82..8ea807eb 100644 --- a/lez/indexer/service/rpc/src/lib.rs +++ b/lez/indexer/service/rpc/src/lib.rs @@ -1,4 +1,6 @@ -use indexer_service_protocol::{Account, AccountId, Block, BlockId, HashType, Transaction}; +use indexer_service_protocol::{ + Account, AccountId, Block, BlockId, HashType, IndexerStatus, Transaction, +}; use jsonrpsee::proc_macros::rpc; #[cfg(feature = "server")] use jsonrpsee::{core::SubscriptionResult, types::ErrorObjectOwned}; @@ -69,6 +71,9 @@ pub trait Rpc { limit: u64, ) -> Result, ErrorObjectOwned>; + #[method(name = "getStatus")] + async fn get_status(&self) -> Result; + // ToDo: expand healthcheck response into some kind of report #[method(name = "checkHealth")] async fn healthcheck(&self) -> Result<(), ErrorObjectOwned>; diff --git a/lez/indexer/service/src/lib.rs b/lez/indexer/service/src/lib.rs index b1c57163..aa142b38 100644 --- a/lez/indexer/service/src/lib.rs +++ b/lez/indexer/service/src/lib.rs @@ -5,6 +5,7 @@ pub use indexer_core::config::*; use indexer_service_rpc::RpcServer as _; use jsonrpsee::server::{Server, ServerHandle}; use log::{error, info}; +use tokio_util::sync::CancellationToken; pub mod service; @@ -69,9 +70,10 @@ pub async fn run_server( config: IndexerConfig, storage_dir: &Path, port: u16, + shutdown: CancellationToken, ) -> Result { #[cfg(feature = "mock-responses")] - let _ = (config, storage_dir); + let _ = (config, storage_dir, shutdown); let server = Server::builder() .build(SocketAddr::from(([0, 0, 0, 0], port))) @@ -86,7 +88,8 @@ pub async fn run_server( #[cfg(not(feature = "mock-responses"))] let handle = { - let service = service::IndexerService::new(config, storage_dir) + let service = service::IndexerService::new(config, storage_dir, shutdown.child_token()) + .await .context("Failed to initialize indexer service")?; server.start(service.into_rpc()) }; diff --git a/lez/indexer/service/src/main.rs b/lez/indexer/service/src/main.rs index 3e36967d..52f195e9 100644 --- a/lez/indexer/service/src/main.rs +++ b/lez/indexer/service/src/main.rs @@ -34,7 +34,9 @@ async fn main() -> Result<()> { let cancellation_token = listen_for_shutdown_signal(); let config = indexer_service::IndexerConfig::from_path(&config_path)?; - let indexer_handle = indexer_service::run_server(config, data_dir.as_path(), port).await?; + let indexer_handle = + indexer_service::run_server(config, data_dir.as_path(), port, cancellation_token.clone()) + .await?; tokio::select! { () = cancellation_token.cancelled() => { diff --git a/lez/indexer/service/src/mock_service.rs b/lez/indexer/service/src/mock_service.rs index d9ab9484..70af6239 100644 --- a/lez/indexer/service/src/mock_service.rs +++ b/lez/indexer/service/src/mock_service.rs @@ -10,10 +10,10 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use indexer_service_protocol::{ Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, BlockId, Commitment, - CommitmentSetDigest, Data, EncryptedAccountData, HashType, PrivacyPreservingMessage, - PrivacyPreservingTransaction, ProgramDeploymentMessage, ProgramDeploymentTransaction, - ProgramId, PublicMessage, PublicTransaction, Signature, Transaction, ValidityWindow, - WitnessSet, + CommitmentSetDigest, Data, EncryptedAccountData, HashType, IndexerStatus, IndexerSyncState, + PrivacyPreservingMessage, PrivacyPreservingTransaction, ProgramDeploymentMessage, + ProgramDeploymentTransaction, ProgramId, PublicMessage, PublicTransaction, Signature, + Transaction, ValidityWindow, WitnessSet, }; use jsonrpsee::{ core::{SubscriptionResult, async_trait}, @@ -325,11 +325,99 @@ impl indexer_service_rpc::RpcServer for MockIndexerService { .collect()) } + async fn get_status(&self) -> Result { + let indexed_block_id = self + .state + .read() + .await + .blocks + .iter() + .rev() + .find(|block| block.bedrock_status == BedrockStatus::Finalized) + .map(|block| block.header.block_id); + Ok(IndexerStatus { + state: IndexerSyncState::CaughtUp, + last_error: None, + indexed_block_id, + stall_reason: None, + }) + } + async fn healthcheck(&self) -> Result<(), ErrorObjectOwned> { Ok(()) } } +fn mock_public_tx( + tx_hash: HashType, + block_id: BlockId, + tx_idx: u64, + account_ids: &[AccountId], +) -> Transaction { + Transaction::Public(PublicTransaction { + hash: tx_hash, + message: PublicMessage { + program_id: ProgramId([1_u32; 8]), + account_ids: vec![ + account_ids[tx_idx as usize % account_ids.len()], + account_ids[(tx_idx as usize + 1) % account_ids.len()], + ], + nonces: vec![block_id as u128, (block_id + 1) as u128], + instruction_data: vec![1, 2, 3, 4], + }, + witness_set: WitnessSet { + signatures_and_public_keys: vec![], + proof: None, + }, + }) +} + +fn mock_privacy_preserving_tx( + tx_hash: HashType, + block_id: BlockId, + tx_idx: u64, + account_ids: &[AccountId], +) -> Transaction { + Transaction::PrivacyPreserving(PrivacyPreservingTransaction { + hash: tx_hash, + message: PrivacyPreservingMessage { + public_account_ids: vec![account_ids[tx_idx as usize % account_ids.len()]], + nonces: vec![block_id as u128], + public_post_states: vec![Account { + program_owner: ProgramId([1_u32; 8]), + balance: 500, + data: Data(vec![0xdd, 0xee]), + nonce: block_id as u128, + }], + encrypted_private_post_states: vec![EncryptedAccountData { + ciphertext: indexer_service_protocol::Ciphertext(vec![0x01, 0x02, 0x03, 0x04]), + epk: indexer_service_protocol::EphemeralPublicKey(vec![0xaa; 32]), + view_tag: 42, + }], + new_commitments: vec![Commitment([block_id as u8; 32])], + new_nullifiers: vec![( + indexer_service_protocol::Nullifier([tx_idx as u8; 32]), + CommitmentSetDigest([0xff; 32]), + )], + block_validity_window: ValidityWindow((None, None)), + timestamp_validity_window: ValidityWindow((None, None)), + }, + witness_set: WitnessSet { + signatures_and_public_keys: vec![], + proof: Some(indexer_service_protocol::Proof(vec![0; 32])), + }, + }) +} + +fn mock_program_deployment_tx(tx_hash: HashType) -> Transaction { + Transaction::ProgramDeployment(ProgramDeploymentTransaction { + hash: tx_hash, + message: ProgramDeploymentMessage { + bytecode: vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00], + }, + }) +} + fn build_mock_block( block_id: BlockId, prev_hash: HashType, @@ -344,7 +432,6 @@ fn build_mock_block( HashType(hash) }; - // Create 2-4 transactions per block (mix of Public, PrivacyPreserving, and ProgramDeployment) let num_txs = 2 + (block_id % 3); let mut block_transactions = Vec::new(); @@ -356,65 +443,10 @@ fn build_mock_block( HashType(hash) }; - // Vary transaction types: Public, PrivacyPreserving, or ProgramDeployment let tx = match (block_id + tx_idx) % 5 { - // Public transactions (most common) - 0 | 1 => Transaction::Public(PublicTransaction { - hash: tx_hash, - message: PublicMessage { - program_id: ProgramId([1_u32; 8]), - account_ids: vec![ - account_ids[tx_idx as usize % account_ids.len()], - account_ids[(tx_idx as usize + 1) % account_ids.len()], - ], - nonces: vec![block_id as u128, (block_id + 1) as u128], - instruction_data: vec![1, 2, 3, 4], - }, - witness_set: WitnessSet { - signatures_and_public_keys: vec![], - proof: None, - }, - }), - // PrivacyPreserving transactions - 2 | 3 => Transaction::PrivacyPreserving(PrivacyPreservingTransaction { - hash: tx_hash, - message: PrivacyPreservingMessage { - public_account_ids: vec![account_ids[tx_idx as usize % account_ids.len()]], - nonces: vec![block_id as u128], - public_post_states: vec![Account { - program_owner: ProgramId([1_u32; 8]), - balance: 500, - data: Data(vec![0xdd, 0xee]), - nonce: block_id as u128, - }], - encrypted_private_post_states: vec![EncryptedAccountData { - ciphertext: indexer_service_protocol::Ciphertext(vec![ - 0x01, 0x02, 0x03, 0x04, - ]), - epk: indexer_service_protocol::EphemeralPublicKey(vec![0xaa; 32]), - view_tag: 42, - }], - new_commitments: vec![Commitment([block_id as u8; 32])], - new_nullifiers: vec![( - indexer_service_protocol::Nullifier([tx_idx as u8; 32]), - CommitmentSetDigest([0xff; 32]), - )], - block_validity_window: ValidityWindow((None, None)), - timestamp_validity_window: ValidityWindow((None, None)), - }, - witness_set: WitnessSet { - signatures_and_public_keys: vec![], - proof: Some(indexer_service_protocol::Proof(vec![0; 32])), - }, - }), - // ProgramDeployment transactions (rare) - _ => Transaction::ProgramDeployment(ProgramDeploymentTransaction { - hash: tx_hash, - message: ProgramDeploymentMessage { - bytecode: vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00], /* WASM magic - * number */ - }, - }), + 0 | 1 => mock_public_tx(tx_hash, block_id, tx_idx, account_ids), + 2 | 3 => mock_privacy_preserving_tx(tx_hash, block_id, tx_idx, account_ids), + _ => mock_program_deployment_tx(tx_hash), }; block_transactions.push(tx); diff --git a/lez/indexer/service/src/service.rs b/lez/indexer/service/src/service.rs index 7a8ed90f..09759362 100644 --- a/lez/indexer/service/src/service.rs +++ b/lez/indexer/service/src/service.rs @@ -2,9 +2,11 @@ use std::{path::Path, pin::pin, sync::Arc}; use anyhow::{Context as _, Result, bail}; use arc_swap::ArcSwap; -use futures::{StreamExt as _, never::Never}; +use futures::StreamExt as _; use indexer_core::{IndexerCore, config::IndexerConfig}; -use indexer_service_protocol::{Account, AccountId, Block, BlockId, HashType, Transaction}; +use indexer_service_protocol::{ + Account, AccountId, Block, BlockId, HashType, IndexerStatus, Transaction, +}; use jsonrpsee::{ SubscriptionSink, core::{Serialize, SubscriptionResult, async_trait}, @@ -12,6 +14,7 @@ use jsonrpsee::{ }; use log::{debug, error, info, warn}; use tokio::sync::mpsc::UnboundedSender; +use tokio_util::sync::CancellationToken; pub struct IndexerService { subscription_service: SubscriptionService, @@ -19,9 +22,13 @@ pub struct IndexerService { } impl IndexerService { - pub fn new(config: IndexerConfig, storage_dir: &Path) -> Result { - let indexer = IndexerCore::new(config, storage_dir)?; - let subscription_service = SubscriptionService::spawn_new(indexer.clone()); + pub async fn new( + config: IndexerConfig, + storage_dir: &Path, + shutdown: CancellationToken, + ) -> Result { + let indexer = IndexerCore::new(config, storage_dir).await?; + let subscription_service = SubscriptionService::spawn_new(indexer.clone(), shutdown); Ok(Self { subscription_service, @@ -149,6 +156,10 @@ impl indexer_service_rpc::RpcServer for IndexerService { Ok(tx_res) } + async fn get_status(&self) -> Result { + Ok(self.indexer.status().into()) + } + async fn healthcheck(&self) -> Result<(), ErrorObjectOwned> { // Checking, that indexer can calculate last state let _ = self @@ -164,15 +175,21 @@ impl indexer_service_rpc::RpcServer for IndexerService { struct SubscriptionService { parts: ArcSwap, indexer: IndexerCore, + /// Cancellation token that is used to signal the subscription service to shut down. + /// + /// NOTE: This will auto-cancel on `Drop`, so if your token is shared with other parts + /// use [`CancellationToken::child_token()`] instead. + shutdown: CancellationToken, } impl SubscriptionService { - pub fn spawn_new(indexer: IndexerCore) -> Self { - let parts = Self::spawn_respond_subscribers_loop(indexer.clone()); + pub fn spawn_new(indexer: IndexerCore, shutdown: CancellationToken) -> Self { + let parts = Self::spawn_respond_subscribers_loop(indexer.clone(), shutdown.clone()); Self { parts: ArcSwap::new(Arc::new(parts)), indexer, + shutdown, } } @@ -184,14 +201,18 @@ impl SubscriptionService { ); // Respawn the subscription service loop if it has finished (either with error or panic) - if guard.handle.is_finished() { + if guard.handle.is_finished() && !self.shutdown.is_cancelled() { drop(guard); - let new_parts = Self::spawn_respond_subscribers_loop(self.indexer.clone()); + let new_parts = Self::spawn_respond_subscribers_loop( + self.indexer.clone(), + self.shutdown.clone(), + ); let old_handle_and_sender = self.parts.swap(Arc::new(new_parts)); let old_parts = Arc::into_inner(old_handle_and_sender) .expect("There should be no other references to the old handle and sender"); match old_parts.handle.await { + Ok(Ok(())) => {} Ok(Err(err)) => { error!( "Subscription service loop has unexpectedly finished with error: {err:#}" @@ -209,7 +230,10 @@ impl SubscriptionService { Ok(()) } - fn spawn_respond_subscribers_loop(indexer: IndexerCore) -> SubscriptionLoopParts { + fn spawn_respond_subscribers_loop( + indexer: IndexerCore, + shutdown: CancellationToken, + ) -> SubscriptionLoopParts { let (new_subscription_sender, mut sub_receiver) = tokio::sync::mpsc::unbounded_channel::>(); @@ -225,6 +249,10 @@ impl SubscriptionService { )] loop { tokio::select! { + () = shutdown.cancelled() => { + info!("Shutdown requested; stopping block ingestion"); + return Ok(()); + } sub = sub_receiver.recv() => { let Some(subscription) = sub else { bail!("Subscription receiver closed unexpectedly"); @@ -253,10 +281,11 @@ impl SubscriptionService { } } }; - let res: anyhow::Result = run_loop.await; - let Err(err) = res; - error!("Subscription service loop has unexpectedly finished with error: {err:#?}"); - Err(err) + let res: anyhow::Result<()> = run_loop.await; + if let Err(err) = &res { + error!("Subscription service loop has unexpectedly finished with error: {err:#?}"); + } + res }); SubscriptionLoopParts { handle, @@ -267,12 +296,13 @@ impl SubscriptionService { impl Drop for SubscriptionService { fn drop(&mut self) { + self.shutdown.cancel(); self.parts.load().handle.abort(); } } struct SubscriptionLoopParts { - handle: tokio::task::JoinHandle>, + handle: tokio::task::JoinHandle>, new_subscription_sender: UnboundedSender>, } diff --git a/lez/keycard_wallet/Cargo.toml b/lez/keycard_wallet/Cargo.toml index 4abff1f1..33fabfb4 100644 --- a/lez/keycard_wallet/Cargo.toml +++ b/lez/keycard_wallet/Cargo.toml @@ -9,8 +9,11 @@ workspace = true [dependencies] lee.workspace = true -pyo3.workspace = true +keycard-rs.workspace = true +bip39.workspace = true +hex.workspace = true log.workspace = true -serde = { workspace = true, features = ["derive"] } -serde_json.workspace = true +pcsc.workspace = true +rand.workspace = true +thiserror.workspace = true zeroize.workspace = true diff --git a/lez/keycard_wallet/keycard_applets/LEE_keycard.cap b/lez/keycard_wallet/keycard_applets/LEE_keycard.cap deleted file mode 100644 index b2e71d56..00000000 Binary files a/lez/keycard_wallet/keycard_applets/LEE_keycard.cap and /dev/null differ diff --git a/lez/keycard_wallet/keycard_applets/math.cap b/lez/keycard_wallet/keycard_applets/math.cap deleted file mode 100644 index b9c0e99f..00000000 Binary files a/lez/keycard_wallet/keycard_applets/math.cap and /dev/null differ diff --git a/lez/keycard_wallet/python/keycard_wallet.py b/lez/keycard_wallet/python/keycard_wallet.py deleted file mode 100644 index 21e966cb..00000000 --- a/lez/keycard_wallet/python/keycard_wallet.py +++ /dev/null @@ -1,221 +0,0 @@ -from smartcard.System import readers -from keycard.exceptions import APDUError, TransportError -from ecdsa import VerifyingKey, SECP256k1 - -from keycard.keycard import KeyCard -from keycard.commands.export_lee_key import export_lee_key -from mnemonic import Mnemonic -from keycard import constants - -import os -import secrets - -DEFAULT_PAIRING_PASSWORD = "KeycardDefaultPairing" - -def _pairing_password() -> str: - return os.environ.get("KEYCARD_PAIRING_PASSWORD", DEFAULT_PAIRING_PASSWORD) - -class KeycardWallet: - def __init__(self): - self.card = KeyCard() - - def _is_smart_card_reader_detected(self) -> bool: - try: - return len(readers()) > 0 - except Exception: - return False - - def _is_keycard_detected(self) -> bool: - try: - KeyCard().select() - return True - except (TransportError, APDUError, Exception): - # No readers, no card, or card doesn't respond. - return False - - def is_unpaired_keycard_available(self) -> bool: - if not self._is_smart_card_reader_detected(): - return False - elif not self._is_keycard_detected(): - return False - return True - - def initialize(self, pin: str, pairing_password: str | None = None) -> bool: - try: - self.card.select() - - if self.card.is_initialized: - raise RuntimeError("Card is already initialized") - - puk = ''.join(secrets.choice('0123456789') for _ in range(12)) - self.card.init(pin, puk, pairing_password or _pairing_password()) - print(f"Keycard PUK: {puk}") - print("Record this PUK and store it somewhere safe. It cannot be recovered.") - return True - except Exception as e: - raise RuntimeError(f"Error initializing keycard: {e}") from e - - def _reconnect(self) -> None: - self.card = KeyCard() - self.card.select() - - def _pair(self, pin: str, password: str) -> tuple[int, bytes]: - self.card.select() - - if not self.card.is_initialized: - raise RuntimeError("Card is not initialized — run 'wallet keycard init' first") - - pairing_index, pairing_key = self.card.pair(password) - self.pairing_index = pairing_index - self.pairing_key = pairing_key - - try: - self.card.open_secure_channel(pairing_index, pairing_key) - self.card.verify_pin(pin) - except Exception as e: - try: - self.card.unpair(pairing_index) - except Exception: - pass - raise RuntimeError(f"Error opening secure channel after fresh pair: {e}") from e - - return pairing_index, pairing_key - - def pair(self, pin: str, password: str | None = None) -> tuple[int, bytes]: - password = password or _pairing_password() - try: - return self._pair(pin, password) - except TransportError as e: - print(f"Transport error during fresh pair ({e}), attempting card reset and retry...") - try: - self._reconnect() - result = self._pair(pin, password) - print("Retry succeeded after card reset.") - return result - except TransportError as e2: - raise RuntimeError( - "Card lost power and did not recover after reset. " - "Try reseating the card in the reader." - ) from e2 - - def _setup_communication_with_pairing(self, pin: str, pairing_index: int, pairing_key: bytes) -> bool: - self.card.select() - - if not self.card.is_initialized: - raise RuntimeError("Card is not initialized — run 'wallet keycard init' first") - - self.pairing_index = pairing_index - self.pairing_key = pairing_key - - try: - self.card.open_secure_channel(pairing_index, pairing_key) - self.card.verify_pin(pin) - except Exception as e: - raise RuntimeError(f"Error setting up communication with stored pairing: {e}") from e - - return True - - def setup_communication_with_pairing(self, pin: str, pairing_index: int, pairing_key: bytes) -> bool: - try: - return self._setup_communication_with_pairing(pin, pairing_index, pairing_key) - except TransportError as e: - print(f"Transport error during stored pairing ({e}), attempting card reset and retry...") - try: - self._reconnect() - result = self._setup_communication_with_pairing(pin, pairing_index, pairing_key) - print("Retry succeeded after card reset.") - return result - except TransportError as e2: - raise RuntimeError( - "Card lost power and did not recover after reset. " - "Try reseating the card in the reader." - ) from e2 - - def close_session(self) -> bool: - return True - - def load_mnemonic(self, mnemonic: str) -> bool: - try: - # Convert mnemonic to seed - mnemo = Mnemonic("english") - if not mnemo.check(mnemonic): - raise RuntimeError("Invalid mnemonic phrase — check spelling and word count") - seed = mnemo.to_seed(mnemonic) - - # Load the LEE seed onto the card - result = self.card.load_key( - key_type = constants.LoadKeyType.LEE_SEED, - lee_seed = seed - ) - return True - except Exception as e: - raise RuntimeError(f"Error loading mnemonic: {e}") from e - - def disconnect(self) -> bool: - try: - if not self.card.is_secure_channel_open: - return False - - self.card.unpair(self.pairing_index) - - return True - except Exception as e: - raise RuntimeError(f"Error during disconnect: {e}") from e - - def get_public_key_for_path(self, path: str = "m/44'/60'/0'/0/0") -> bytes | None: - try: - if not self.card.is_secure_channel_open or not self.card.is_pin_verified: - return None - - public_key = self.card.export_key( - derivation_option = constants.DerivationOption.DERIVE, - public_only = True, - keypath = path - ) - - public_key = public_key.public_key - public_key = VerifyingKey.from_string(public_key[1:], curve=SECP256k1) - public_key = public_key.to_string("compressed")[1:] - - return public_key - - except Exception as e: - raise RuntimeError(f"Error getting public key: {e}") from e - - - def sign_message_for_path(self, message: bytes, path: str = "m/44'/60'/0'/0/0") -> bytes | None: - try: - if not self.card.is_secure_channel_open or not self.card.is_pin_verified: - return None - - signature = self.card.sign_with_path( - digest = message, - path = path, - algorithm = constants.SigningAlgorithm.SCHNORR_BIP340, - make_current = False - ) - - return signature.signature - - except Exception as e: - raise RuntimeError(f"Error signing message: {e}") from e - - def get_private_keys_for_path(self, path: str = "m/44'/60'/0'/0/0") -> bytes | None: - try: - if not self.card.is_secure_channel_open or not self.card.is_pin_verified: - return None - - private_keys = export_lee_key( - self.card, - constants.DerivationOption.DERIVE, - path - ) - - nsk = private_keys.lee_nsk - vsk = private_keys.lee_vsk - - return (nsk, vsk) - - except Exception as e: - raise RuntimeError(f"Error getting private keys: {e}") from e - diff --git a/lez/keycard_wallet/src/bin/force_unpower.rs b/lez/keycard_wallet/src/bin/force_unpower.rs new file mode 100644 index 00000000..9cf07f1b --- /dev/null +++ b/lez/keycard_wallet/src/bin/force_unpower.rs @@ -0,0 +1,52 @@ +#![expect( + clippy::print_stdout, + reason = "This is a CLI test helper, printing to stdout is expected and convenient" +)] + +//! Forces the card in the first available reader into the unpowered state via PC/SC +//! `SCARD_UNPOWER_CARD`. Run immediately before a wallet command to simulate the power-loss +//! condition reported on some USB reader/driver combinations. +//! +//! Either: +//! - pcscd re-powers the card on the next `SCardConnect`, so wallet commands will succeed without +//! triggering the retry path. +//! - the card stays unpowered, triggering a PC/SC transport error (`keycard_rs::Error::Io`) and +//! exercising the reconnect-and-retry wrapper in `KeycardWallet::connect()`. + +fn main() { + let context = match pcsc::Context::establish(pcsc::Scope::User) { + Ok(context) => context, + Err(e) => { + println!("force_unpower: failed to establish PC/SC context ({e}), skipping."); + return; + } + }; + + let readers = match context.list_readers_owned() { + Ok(readers) => readers, + Err(e) => { + println!("force_unpower: failed to list readers ({e}), skipping."); + return; + } + }; + + let Some(reader) = readers.first() else { + println!("force_unpower: no readers found, skipping."); + return; + }; + + let card = match context.connect(reader, pcsc::ShareMode::Shared, pcsc::Protocols::ANY) { + Ok(card) => card, + Err(e) => { + println!("force_unpower: connect failed ({e}), skipping."); + return; + } + }; + + if let Err((_card, e)) = card.disconnect(pcsc::Disposition::UnpowerCard) { + println!("force_unpower: disconnect failed ({e}), skipping."); + return; + } + + println!("force_unpower: card powered down."); +} diff --git a/lez/keycard_wallet/src/lib.rs b/lez/keycard_wallet/src/lib.rs index 73486392..f7d521cd 100644 --- a/lez/keycard_wallet/src/lib.rs +++ b/lez/keycard_wallet/src/lib.rs @@ -1,187 +1,230 @@ -use std::path::PathBuf; +use std::str::FromStr as _; +use keycard_rs::{ + KeycardCommandSet, PcscChannel, + constants::sign_p2, + parsing::Bip32KeyPair, + secure_channel::SecureChannelVersion, + tlv::{BerTlvReader, TLV_KEY_TEMPLATE, TLV_PUB_KEY, TLV_SIGNATURE_TEMPLATE}, +}; use lee::{AccountId, PublicKey, Signature}; -use pyo3::{prelude::*, types::PyAny}; -use serde::{Deserialize, Serialize}; +use rand::Rng as _; use zeroize::Zeroizing; -pub mod python_path; +/// LEE-applet extension tags. `keycard-rs` implements *sending* the LEE commands +/// (`load_lee_key`, `export_lee_key`) but never added parsing for their LEE-specific responses, +/// so these tag values — owned by the applet, not either client library — have to be hardcoded +/// here. Matches `KeycardApplet.TLV_LEE_NSK`/`TLV_LEE_VSK` in `status-keycard` +/// (`KeycardApplet.java:97-98`), the applet `LEE_keycard.cap` was almost certainly built from. +const TLV_LEE_NSK: u8 = 0x83; +const TLV_LEE_VSK: u8 = 0x84; +/// Raw Schnorr signature (64 bytes: `r || s`, no ASN.1/DER wrapping) nested inside the standard +/// `TLV_SIGNATURE_TEMPLATE` alongside the usual `TLV_PUB_KEY`. Confirmed against real hardware — +/// the LEE applet's `SIGN` response for `sign_p2::BIP340_SCHNORR` is +/// `0xA0 { 0x80 <65-byte pubkey>, 0x88 <64-byte r||s> }` — and matches +/// `SECP256k1.TLV_RAW_SIGNATURE` in `status-keycard` (`SECP256k1.java:64`). +const TLV_LEE_RAW_SIGNATURE: u8 = 0x88; /// NSK (32 bytes) and VSK (64 bytes, the ML-KEM-768 seed `d || z`) as fixed-length zeroizing byte /// arrays. type PrivateKeyPair = (Zeroizing<[u8; 32]>, Zeroizing<[u8; 64]>); -// TODO: encrypt at rest alongside broader wallet storage encryption work. -#[derive(Serialize, Deserialize)] -pub struct KeycardPairingData { - pub index: u8, - pub key: Vec, +#[derive(Debug, thiserror::Error)] +pub enum KeycardWalletError { + #[error(transparent)] + Keycard(#[from] keycard_rs::Error), + #[error("keycard is already initialized")] + AlreadyInitialized, + #[error( + "this wallet only supports Secure Channel V2 keycards (applet version >= 4.0); detected {0:?}" + )] + UnsupportedSecureChannel(Option), + #[error("invalid mnemonic phrase: {0}")] + InvalidMnemonic(String), + #[error("invalid key material from keycard: {0}")] + InvalidKeyMaterial(String), + #[error("keycard returned a signature that does not verify against its own public key")] + SignatureVerificationFailed, + #[error("invalid KEYCARD_CA_PUBLIC_KEY: {0}")] + InvalidCaPublicKey(String), } -impl KeycardPairingData { - const fn is_valid(&self) -> bool { - self.key.len() == 32 && self.index <= 4 - } -} - -/// Rust wrapper around the Python `KeycardWallet` class. +/// Rust wrapper around `keycard-rs`, talking to the LEE-flavored Keycard applet over PC/SC. +/// Only Secure Channel V2 cards are supported — see `require_secure_channel_v2`. pub struct KeycardWallet { - instance: Py, + command_set: KeycardCommandSet, } impl KeycardWallet { - /// Create a new Python `KeycardWallet` instance. - pub fn new(py: Python) -> PyResult { - let module = py.import("keycard_wallet")?; - let class = module.getattr("KeycardWallet")?; - - let instance = class.call0()?; - + /// Connects to the first available PC/SC reader. Does not select the applet yet — callers + /// that need application info (`initialize`, `connect`, ...) do that themselves. + /// + /// Verifies the card's identity certificate against `keycard-rs`'s default production CA, + /// unless overridden via `KEYCARD_CA_PUBLIC_KEY` — see `ca_public_key_override`. + pub fn new() -> Result { + let channel = PcscChannel::connect()?; Ok(Self { - instance: instance.into(), + command_set: Self::build_command_set(channel)?, }) } - pub fn is_unpaired_keycard_available(&self, py: Python) -> PyResult { - self.instance - .bind(py) - .call_method0("is_unpaired_keycard_available")? - .extract() + fn build_command_set(channel: PcscChannel) -> Result { + Ok(match ca_public_key_override()? { + Some(ca) => KeycardCommandSet::new_with_ca(channel, ca), + None => KeycardCommandSet::new(channel), + }) } - pub fn initialize(&self, py: Python<'_>, pin: &str) -> PyResult { - self.instance - .bind(py) - .call_method1("initialize", (pin,))? - .extract() - } - - pub fn pair(&self, py: Python<'_>, pin: &str) -> PyResult<(u8, Vec)> { - self.instance - .bind(py) - .call_method1("pair", (pin,))? - .extract() - } - - pub fn setup_communication_with_pairing( - &self, - py: Python<'_>, - pin: &str, - index: u8, - key: &[u8], - ) -> PyResult { - self.instance - .bind(py) - .call_method1( - "setup_communication_with_pairing", - (pin, index, key.to_vec()), - )? - .extract() - } - - pub fn close_session(&self, py: Python<'_>) -> PyResult { - self.instance - .bind(py) - .call_method0("close_session")? - .extract() - } - - /// Connect using a stored pairing if available, falling back to a fresh pair. - /// Saves any newly established pairing to disk. - pub fn connect(&self, py: Python<'_>, pin: &str) -> PyResult<()> { - if let Some(pairing) = load_pairing().filter(KeycardPairingData::is_valid) - && self - .setup_communication_with_pairing(py, pin, pairing.index, &pairing.key) - .is_ok() - { - return Ok(()); + /// Returns whether a smart card reader and a selectable, Secure-Channel-V2 Keycard are both + /// present. + #[must_use] + pub fn is_keycard_available() -> bool { + let Ok(channel) = PcscChannel::connect() else { + return false; + }; + let Ok(mut command_set) = Self::build_command_set(channel) else { + return false; + }; + if !command_set.select().is_ok_and(|resp| resp.is_ok()) { + return false; } - let (index, key) = self.pair(py, pin)?; - save_pairing(&KeycardPairingData { index, key }); + command_set.secure_channel_version() == Some(SecureChannelVersion::V2) + } + + fn select(&mut self) -> Result<(), KeycardWalletError> { + self.command_set.select()?.check_ok()?; Ok(()) } - pub fn disconnect(&self, py: Python) -> PyResult { - self.instance.bind(py).call_method0("disconnect")?.extract() + /// Rejects any card that isn't running Secure Channel V2 (older applets, or a card that + /// hasn't advertised a secure channel at all). Call right after `select()`. + fn require_secure_channel_v2(&self) -> Result<(), KeycardWalletError> { + match self.command_set.secure_channel_version() { + Some(SecureChannelVersion::V2) => Ok(()), + other => Err(KeycardWalletError::UnsupportedSecureChannel(other)), + } } - pub fn get_public_key_for_path(&self, py: Python, path: &str) -> PyResult { - let public_key: Vec = self - .instance - .bind(py) - .call_method1("get_public_key_for_path", (path,))? - .extract()?; - - let public_key: [u8; 32] = public_key.try_into().map_err(|vec: Vec| { - PyErr::new::(format!( - "expected 32-byte public key from keycard, got {} bytes", - vec.len() - )) - })?; - - PublicKey::try_new(public_key) - .map_err(|e| PyErr::new::(e.to_string())) + /// Rebuilds the PC/SC channel and command set, then retries `op` once, if `op` failed with a + /// transport-level error (e.g. the card lost power mid-session). + fn with_reconnect_on_transport_error( + &mut self, + op: impl Fn(&mut Self) -> Result, + ) -> Result { + match op(self) { + Err(KeycardWalletError::Keycard(keycard_rs::Error::Io(io_err))) => { + log::warn!( + "transport error during keycard operation ({io_err}), reconnecting and retrying once" + ); + *self = Self::new()?; + op(self) + } + result => result, + } } - pub fn get_public_key_for_path_with_connect(pin: &str, path: &str) -> PyResult { - Python::attach(|py| { - python_path::add_python_path(py)?; - let wallet = Self::new(py)?; - wallet.connect(py, pin)?; - let pub_key = wallet.get_public_key_for_path(py, path); - drop(wallet.close_session(py)); - pub_key + /// Initializes an uninitialized card, returning the generated PUK. The caller is responsible + /// for surfacing the PUK to the operator — it cannot be recovered afterward. + pub fn initialize(&mut self, pin: &str) -> Result { + self.select()?; + self.require_secure_channel_v2()?; + let already_initialized = self + .command_set + .app_info() + .expect("select() populates app_info on success") + .is_initialized(); + if already_initialized { + return Err(KeycardWalletError::AlreadyInitialized); + } + + // V2's INIT has no shared-secret field (confirmed against real hardware and the + // applet's own reference command set: a payload containing one is rejected outright) — + // pass an empty secret and let the card default the PIN/PUK retry counts. + let puk = generate_puk(); + self.command_set + .init_with_secret(pin, None, &puk, &[], 0, 0)? + .check_ok()?; + Ok(puk) + } + + /// Wipes the card's PIN, PUK, and loaded keys, returning it to an uninitialized state — + /// the counterpart to `initialize()`. Does **not** remove the identity certificate + /// provisioned via `IdentApplet`, so the card can be re-`initialize()`d afterward without + /// re-personalizing it. Irreversibly destroys any keys currently loaded on the card. + pub fn factory_reset(&mut self) -> Result<(), KeycardWalletError> { + self.select()?; + self.require_secure_channel_v2()?; + self.command_set.factory_reset()?.check_ok()?; + Ok(()) + } + + /// Opens the secure channel and verifies the PIN. Secure Channel V2 re-authenticates from + /// the card's certificate every session — there's no pairing step and nothing to persist. + pub fn connect(&mut self, pin: &str) -> Result<(), KeycardWalletError> { + self.with_reconnect_on_transport_error(|wallet| { + wallet.select()?; + wallet.require_secure_channel_v2()?; + wallet.command_set.auto_open_secure_channel()?; + wallet.command_set.verify_pin(pin)?.check_auth_ok()?; + Ok(()) }) } - #[expect( - clippy::arithmetic_side_effects, - reason = "64 - s_stripped.len() is safe: s_stripped.len() ≤ 31 because py_signature.len() is in [32, 63]" - )] - pub fn sign_message_for_path( - &self, - py: Python, - path: &str, - message: &[u8; 32], - ) -> PyResult<(Signature, PublicKey)> { - let py_signature: Vec = self - .instance - .bind(py) - .call_method1("sign_message_for_path", (message, path))? - .extract()?; + pub fn get_public_key_for_path(&mut self, path: &str) -> Result { + let resp = self.command_set.export_key(path, false, true)?; + resp.check_ok()?; + let keypair = Bip32KeyPair::from_tlv(resp.data())?; - // The keycard Python library strips leading zeros from S when S < 2^(8k) for some k. - // Left-pad S back to 32 bytes so the full signature is always 64 bytes (R || S). - let py_signature = if py_signature.len() < 64 { - if py_signature.len() < 32 { - return Err(PyErr::new::(format!( - "signature from keycard too short: {} bytes", - py_signature.len() + // Uncompressed SEC1 point (0x04 || X || Y); the BIP340 x-only public key is just its X + // coordinate, since secp256k1 (what the card signs with) and k256's Schnorr verifying key + // are the same curve. + let public_key = keypair.public_key(); + let x_only: [u8; 32] = match public_key.split_first() { + Some((&0x04, xy)) if xy.len() == 64 => xy + .split_at(32) + .0 + .try_into() + .expect("split_at(32) of a 64-byte slice"), + _ => { + return Err(KeycardWalletError::InvalidKeyMaterial(format!( + "expected a 65-byte uncompressed secp256k1 public key from keycard, got {} bytes", + public_key.len() ))); } - let s_stripped = &py_signature[32..]; - let mut padded = [0_u8; 64]; - padded[..32].copy_from_slice(&py_signature[..32]); - padded[(64 - s_stripped.len())..].copy_from_slice(s_stripped); - padded.to_vec() - } else { - py_signature }; - let signature: [u8; 64] = py_signature.try_into().map_err(|vec: Vec| { - PyErr::new::(format!( - "Invalid signature length: expected 64 bytes, got {} (bytes: {:02x?})", - vec.len(), - vec - )) - })?; + PublicKey::try_new(x_only) + .map_err(|e| KeycardWalletError::InvalidKeyMaterial(e.to_string())) + } - let sig = Signature { value: signature }; - let pub_key = self.get_public_key_for_path(py, path)?; + pub fn get_public_key_for_path_with_connect( + pin: &str, + path: &str, + ) -> Result { + let mut wallet = Self::new()?; + wallet.connect(pin)?; + wallet.get_public_key_for_path(path) + } + + pub fn sign_message_for_path( + &mut self, + path: &str, + message: &[u8; 32], + ) -> Result<(Signature, PublicKey), KeycardWalletError> { + let resp = self.command_set.sign_with_path_and_algo( + message, + path, + sign_p2::BIP340_SCHNORR, + false, + )?; + resp.check_ok()?; + + let sig = Signature { + value: parse_schnorr_signature(resp.data())?, + }; + let pub_key = self.get_public_key_for_path(path)?; if !sig.is_valid_for(message, &pub_key) { - return Err(PyErr::new::( - "keycard returned a signature that does not verify against its own public key", - )); + return Err(KeycardWalletError::SignatureVerificationFailed); } Ok((sig, pub_key)) } @@ -190,66 +233,43 @@ impl KeycardWallet { pin: &str, path: &str, message: &[u8; 32], - ) -> PyResult<(Signature, PublicKey)> { - Python::attach(|py| { - python_path::add_python_path(py)?; - let wallet = Self::new(py)?; - wallet.connect(py, pin)?; - let result = wallet.sign_message_for_path(py, path, message); - drop(wallet.close_session(py)); - result - }) + ) -> Result<(Signature, PublicKey), KeycardWalletError> { + let mut wallet = Self::new()?; + wallet.connect(pin)?; + wallet.sign_message_for_path(path, message) } - pub fn load_mnemonic(&self, py: Python, mnemonic: &str) -> PyResult<()> { - self.instance - .bind(py) - .call_method1("load_mnemonic", (mnemonic,))?; + pub fn load_mnemonic(&mut self, mnemonic: &str) -> Result<(), KeycardWalletError> { + let mnemonic = bip39::Mnemonic::from_str(mnemonic) + .map_err(|e| KeycardWalletError::InvalidMnemonic(e.to_string()))?; + let seed = mnemonic.to_seed(""); + self.command_set.load_lee_key(&seed)?.check_ok()?; Ok(()) } pub fn get_public_account_id_for_path_with_connect( pin: &str, key_path: &str, - ) -> PyResult { + ) -> Result { let public_key = Self::get_public_key_for_path_with_connect(pin, key_path)?; Ok(format!("Public/{}", AccountId::from(&public_key))) } - pub fn get_private_keys_for_path(&self, py: Python, path: &str) -> PyResult { - let (raw_nsk, raw_vsk): (Vec, Vec) = self - .instance - .bind(py) - .call_method1("get_private_keys_for_path", (path,))? - .extract()?; + pub fn get_private_keys_for_path( + &mut self, + path: &str, + ) -> Result { + let resp = self.command_set.export_lee_key(path)?; + resp.check_ok()?; - let raw_nsk = Zeroizing::new(raw_nsk); - let raw_vsk = Zeroizing::new(raw_vsk); + let mut reader = BerTlvReader::new(resp.data()); + reader.enter_constructed(TLV_KEY_TEMPLATE)?; + let raw_nsk = reader.read_primitive(TLV_LEE_NSK)?; + let raw_vsk = reader.read_primitive(TLV_LEE_VSK)?; - let nsk = { - if raw_nsk.len() != 32 { - return Err(PyErr::new::(format!( - "expected 32-byte NSK from keycard, got {} bytes", - raw_nsk.len() - ))); - } - let mut arr = Zeroizing::new([0_u8; 32]); - arr.copy_from_slice(&raw_nsk); - arr - }; - - let vsk = { - if raw_vsk.len() != 64 { - return Err(PyErr::new::(format!( - "expected 64-byte VSK from keycard, got {} bytes", - raw_vsk.len() - ))); - } - let mut arr = Zeroizing::new([0_u8; 64]); - arr.copy_from_slice(&raw_vsk); - arr - }; + let nsk = zeroizing_fixed_bytes::<32>("nullifier secret key", Zeroizing::new(raw_nsk))?; + let vsk = zeroizing_fixed_bytes::<64>("view secret key", Zeroizing::new(raw_vsk))?; Ok((nsk, vsk)) } @@ -257,46 +277,83 @@ impl KeycardWallet { pub fn get_private_keys_for_path_with_connect( pin: &str, path: &str, - ) -> PyResult { - Python::attach(|py| { - python_path::add_python_path(py)?; - let wallet = Self::new(py)?; - wallet.connect(py, pin)?; - let result = wallet.get_private_keys_for_path(py, path); - drop(wallet.disconnect(py)); - result - }) + ) -> Result { + let mut wallet = Self::new()?; + wallet.connect(pin)?; + wallet.get_private_keys_for_path(path) } } -fn pairing_file_path() -> Option { - let home = std::env::var("LEE_WALLET_HOME_DIR") - .map(PathBuf::from) - .or_else(|_| { - std::env::home_dir() - .map(|h| h.join(".lee").join("wallet")) - .ok_or(()) - }) - .ok()?; - Some(home.join("keycard_pairing.json")) +fn generate_puk() -> String { + let mut rng = rand::rngs::OsRng; + std::iter::repeat_with(|| char::from(rng.gen_range(b'0'..=b'9'))) + .take(12) + .collect() } -fn load_pairing() -> Option { - let path = pairing_file_path()?; - let file = std::fs::File::open(path).ok()?; - serde_json::from_reader(file).ok() +/// Optional override for the CA public key used to verify a card's identity certificate, read +/// from `KEYCARD_CA_PUBLIC_KEY` as 66 hex characters (a 33-byte compressed secp256k1 key). +/// Falls back to `keycard-rs`'s production default when unset. +/// +/// This exists purely for testing against cards that weren't personalized through the real +/// production process — e.g. `status-keycard`'s own `JUnit` suite signs test cards with a fixed, +/// throwaway CA that will never match the production default. Real users' cards should need no +/// override at all. +fn ca_public_key_override() -> Result, KeycardWalletError> { + let Ok(hex_str) = std::env::var("KEYCARD_CA_PUBLIC_KEY") else { + return Ok(None); + }; + let mut bytes = [0_u8; 33]; + hex::decode_to_slice(hex_str.trim(), &mut bytes) + .map_err(|e| KeycardWalletError::InvalidCaPublicKey(e.to_string()))?; + Ok(Some(bytes)) } -fn save_pairing(data: &KeycardPairingData) { - if let Some(path) = pairing_file_path() - && let Ok(json) = serde_json::to_vec_pretty(data) - { - drop(std::fs::write(path, json)); +/// Parses a BIP340 Schnorr signature from a LEE `SIGN` response. +/// +/// Confirmed against real hardware: `TLV_SIGNATURE_TEMPLATE` (0xA0) contains the usual +/// `TLV_PUB_KEY` (0x80, 65 bytes, unused here — the caller fetches the pubkey separately via +/// `export_key`) followed by `TLV_LEE_RAW_SIGNATURE` (0x88, 64 bytes: `r || s` with no ASN.1/DER +/// wrapping). `keycard-rs`'s own `RecoverableSignature` parser doesn't apply — it's ECDSA-only +/// (attempts point recovery, which Schnorr doesn't have). +fn parse_schnorr_signature(data: &[u8]) -> Result<[u8; 64], KeycardWalletError> { + parse_schnorr_signature_inner(data).map_err(|e| { + KeycardWalletError::InvalidKeyMaterial(format!( + "failed to parse schnorr signature response ({e}); raw response bytes: {data:02x?}" + )) + }) +} + +fn parse_schnorr_signature_inner(data: &[u8]) -> Result<[u8; 64], KeycardWalletError> { + let mut reader = BerTlvReader::new(data); + reader.enter_constructed(TLV_SIGNATURE_TEMPLATE)?; + if reader.next_tag_is(TLV_PUB_KEY) { + reader.read_primitive(TLV_PUB_KEY)?; } + let sig = reader.read_primitive(TLV_LEE_RAW_SIGNATURE)?; + sig.try_into().map_err(|v: Vec| { + KeycardWalletError::InvalidKeyMaterial(format!( + "expected a 64-byte raw schnorr signature, got {} bytes", + v.len() + )) + }) } -pub fn clear_pairing() { - if let Some(path) = pairing_file_path() { - drop(std::fs::remove_file(path)); +#[expect( + clippy::needless_pass_by_value, + reason = "Zeroizing> is consumed to ensure the source is zeroed on drop" +)] +fn zeroizing_fixed_bytes( + label: &str, + raw: Zeroizing>, +) -> Result, KeycardWalletError> { + if raw.len() != N { + return Err(KeycardWalletError::InvalidKeyMaterial(format!( + "expected {N}-byte {label} from keycard, got {} bytes", + raw.len() + ))); } + let mut arr = Zeroizing::new([0_u8; N]); + arr.copy_from_slice(&raw); + Ok(arr) } diff --git a/lez/keycard_wallet/src/python_path.rs b/lez/keycard_wallet/src/python_path.rs deleted file mode 100644 index 99ed936e..00000000 --- a/lez/keycard_wallet/src/python_path.rs +++ /dev/null @@ -1,67 +0,0 @@ -use std::{env, path::PathBuf}; - -use pyo3::{prelude::*, types::PyList}; - -/// Adds the project's `python/` directory and venv site-packages to Python's sys.path. -pub fn add_python_path(py: Python<'_>) -> PyResult<()> { - let current_dir = env::current_dir().expect("Failed to get current working directory"); - - let python_base = env::var("VIRTUAL_ENV") - .ok() - .and_then(|v| PathBuf::from(v).parent().map(PathBuf::from)) - .unwrap_or_else(|| current_dir.clone()); - - let mut paths_to_add: Vec = vec![ - python_base - .join("lez") - .join("keycard_wallet") - .join("python"), - python_base - .join("lez") - .join("keycard_wallet") - .join("python") - .join("keycard-py"), - ]; - - // If a virtualenv is active, add its site-packages so that dependencies - // installed in the venv (e.g. smartcard, ecdsa) are importable by the - // pyo3 embedded interpreter, which does not inherit sys.path from the - // shell's `python3` executable. - if let Ok(venv) = env::var("VIRTUAL_ENV") { - let lib = PathBuf::from(&venv).join("lib"); - if let Ok(entries) = std::fs::read_dir(&lib) { - for entry in entries.flatten() { - let site_packages = entry.path().join("site-packages"); - if site_packages.exists() { - paths_to_add.push(site_packages); - } - } - } - } - - // Sanity check — warns early if a path doesn't exist - for path in &paths_to_add { - if !path.exists() { - log::info!("Warning: Python path does not exist: {}", path.display()); - } - } - - let sys = PyModule::import(py, "sys")?; - let binding = sys.getattr("path")?; - let sys_path = binding.cast::()?; - - for path in &paths_to_add { - let path_str = path.to_str().expect("Invalid path"); - - // Avoid duplicating the path - let already_present = sys_path - .iter() - .any(|p| p.extract::<&str>().is_ok_and(|s| s == path_str)); - - if !already_present { - sys_path.insert(0, path_str)?; - } - } - - Ok(()) -} diff --git a/lez/keycard_wallet/tests/force_unpower.py b/lez/keycard_wallet/tests/force_unpower.py deleted file mode 100755 index 427d2028..00000000 --- a/lez/keycard_wallet/tests/force_unpower.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -""" -Forces the card in the first available reader into the unpowered state via -PC/SC SCARD_UNPOWER_CARD. Run immediately before a wallet command to simulate -the power-loss condition reported on some USB reader/driver combinations. - -Either: -- pcscd re-powers the card on the next SCardConnect, so wallet -commands will succeed without triggering the retry path. -- the card stays unpowered, triggering TransportError -and exercising the retry wrapper in pair() / setup_communication_with_pairing(). -""" -import sys -from smartcard.scard import ( - SCardEstablishContext, SCardListReaders, SCardConnect, SCardDisconnect, - SCARD_SCOPE_USER, SCARD_SHARE_SHARED, - SCARD_PROTOCOL_T0, SCARD_PROTOCOL_T1, - SCARD_UNPOWER_CARD, -) - -hresult, hcontext = SCardEstablishContext(SCARD_SCOPE_USER) -hresult, reader_list = SCardListReaders(hcontext, []) - -if not reader_list: - print("force_unpower: no readers found, skipping.") - sys.exit(0) - -hresult, hcard, _ = SCardConnect( - hcontext, - reader_list[0], - SCARD_SHARE_SHARED, - SCARD_PROTOCOL_T0 | SCARD_PROTOCOL_T1, -) - -if hresult != 0: - print(f"force_unpower: SCardConnect failed (hresult={hresult:#010x}), skipping.") - sys.exit(0) - -SCardDisconnect(hcard, SCARD_UNPOWER_CARD) -print("force_unpower: card powered down.") diff --git a/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh b/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh index 3d8301f7..7440f735 100755 --- a/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh +++ b/lez/keycard_wallet/tests/keycard_power_recovery_tests.sh @@ -4,14 +4,13 @@ # Forces a card power cycle before each keycard-backed wallet command to verify # commands survive mid-session power loss. -source venv/bin/activate - export KEYCARD_PIN=111111 +export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" unpower() { - python "$SCRIPT_DIR/force_unpower.py" + cargo run -q --manifest-path "$SCRIPT_DIR/../Cargo.toml" --bin force_unpower } echo "Test: wallet keycard available" diff --git a/lez/keycard_wallet/tests/keycard_test_3.sh b/lez/keycard_wallet/tests/keycard_test_3.sh index d80e2aca..59e0ec6b 100755 --- a/lez/keycard_wallet/tests/keycard_test_3.sh +++ b/lez/keycard_wallet/tests/keycard_test_3.sh @@ -2,14 +2,12 @@ # keycard_test_3.sh — tests for `wallet keycard get-private-keys`. # # Prerequisites: -# 1. Run wallet_with_keycard.sh once to install dependencies. -# 2. Keycard reader inserted with card loaded (wallet keycard load has been run). - -source venv/bin/activate +# 1. Keycard reader inserted with card loaded (wallet keycard load has been run). cargo install --path lez/wallet --force --features keycard-debug export KEYCARD_PIN=111111 +export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743 echo "=== Test: wallet keycard get-private-keys path 10 ===" wallet keycard get-private-keys --key-path "m/44'/60'/0'/0/10" --reveal diff --git a/lez/keycard_wallet/tests/keycard_tests.sh b/lez/keycard_wallet/tests/keycard_tests.sh index dfa30461..3d7018b8 100755 --- a/lez/keycard_wallet/tests/keycard_tests.sh +++ b/lez/keycard_wallet/tests/keycard_tests.sh @@ -1,9 +1,8 @@ #!/bin/bash -# Run wallet_with_keycard.sh first - -source venv/bin/activate # Load the appropriate virtual environment +# Run `cargo install --path lez/wallet --force` first export KEYCARD_PIN=111111 +export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743 # Tests wallet keycard available # - Checks whether smart reader and keycard are both available. diff --git a/lez/keycard_wallet/tests/keycard_tests_2.sh b/lez/keycard_wallet/tests/keycard_tests_2.sh index cbff19fe..b574e6a3 100755 --- a/lez/keycard_wallet/tests/keycard_tests_2.sh +++ b/lez/keycard_wallet/tests/keycard_tests_2.sh @@ -2,7 +2,7 @@ # keycard_tests_2.sh — comprehensive token + AMM keycard integration tests. # # Prerequisites: -# 1. Run wallet_with_keycard.sh once to install dependencies. +# 1. Run `cargo install --path lez/wallet --force` once to install the wallet CLI. # 2. Reset the local chain so all accounts are uninitialized. # 3. Keycard reader inserted with card loaded. # @@ -23,8 +23,8 @@ # amm-lee-fund → public LEE holding used to seed the AMM pool # (LP holding for amm new is created fresh each run — no persistent label) -source venv/bin/activate export KEYCARD_PIN=111111 +export KEYCARD_CA_PUBLIC_KEY=025877220aaae6e54a6f974602d5995c0fe24a3ea7ddabd8644bec795b9da00743 # ============================================================================= # Keycard setup diff --git a/lez/keycard_wallet/wallet_with_keycard.sh b/lez/keycard_wallet/wallet_with_keycard.sh deleted file mode 100755 index 7a43bb57..00000000 --- a/lez/keycard_wallet/wallet_with_keycard.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -cargo install --path lez/wallet --force - -# Install appropriate version of `keycard-py`. -git clone --branch lee-schnorr --single-branch https://github.com/bitgamma/keycard-py.git lez/keycard_wallet/python/keycard-py - -# Set up virtual environment. -python3 -m venv venv -source venv/bin/activate -pip install pyscard mnemonic ecdsa pyaes -pip install -e lez/keycard_wallet/python/keycard-py \ No newline at end of file diff --git a/lez/mempool/src/lib.rs b/lez/mempool/src/lib.rs index 1b36eaf7..0006f2c3 100644 --- a/lez/mempool/src/lib.rs +++ b/lez/mempool/src/lib.rs @@ -65,6 +65,11 @@ impl MemPoolHandle { pub async fn push(&self, item: T) -> Result<(), tokio::sync::mpsc::error::SendError> { self.sender.send(item).await } + + /// Send an item to the mempool, failing _immediately_ if it is full. + pub fn try_push(&self, item: T) -> Result<(), tokio::sync::mpsc::error::TrySendError> { + self.sender.try_send(item) + } } #[cfg(test)] @@ -123,6 +128,19 @@ mod tests { assert_eq!(pool.pop(), Some(2)); } + #[test] + async fn try_push_fails_when_full_without_blocking() { + let (mut pool, handle) = MemPool::new(1); + + handle.try_push(1).unwrap(); + assert!(handle.try_push(2).is_err(), "full mempool must not accept"); + + // Popping frees capacity again. + assert_eq!(pool.pop(), Some(1)); + handle.try_push(2).unwrap(); + assert_eq!(pool.pop(), Some(2)); + } + #[test] async fn push_front() { let (mut pool, handle) = MemPool::new(10); diff --git a/lez/programs/Cargo.toml b/lez/programs/Cargo.toml index 06038d7f..707c3d6b 100644 --- a/lez/programs/Cargo.toml +++ b/lez/programs/Cargo.toml @@ -54,6 +54,36 @@ name = "vault" path = "vault/src/main.rs" required-features = ["programs"] +[[bin]] +name = "cross_zone_outbox" +path = "cross_zone_outbox/src/main.rs" +required-features = ["programs"] + +[[bin]] +name = "cross_zone_inbox" +path = "cross_zone_inbox/src/main.rs" +required-features = ["programs"] + +[[bin]] +name = "ping_sender" +path = "ping_sender/src/main.rs" +required-features = ["programs"] + +[[bin]] +name = "ping_receiver" +path = "ping_receiver/src/main.rs" +required-features = ["programs"] + +[[bin]] +name = "bridge_lock" +path = "bridge_lock/src/main.rs" +required-features = ["programs"] + +[[bin]] +name = "wrapped_token" +path = "wrapped_token/src/main.rs" +required-features = ["programs"] + [features] # TODO: Uncomment once https://github.com/risc0/risc0/issues/3772 is resolved. # default = ["artifacts"] @@ -78,6 +108,11 @@ programs = [ "dep:faucet_core", "dep:token_core", "dep:vault_core", + "dep:cross_zone_inbox_core", + "dep:cross_zone_outbox_core", + "dep:bridge_lock_core", + "dep:wrapped_token_core", + "dep:ping_core", ] [dependencies] @@ -92,6 +127,11 @@ clock_core = { workspace = true, optional = true } faucet_core = { workspace = true, optional = true } token_core = { workspace = true, optional = true } vault_core = { workspace = true, optional = true } +cross_zone_inbox_core = { workspace = true, optional = true } +cross_zone_outbox_core = { workspace = true, optional = true } +bridge_lock_core = { workspace = true, optional = true } +wrapped_token_core = { workspace = true, optional = true } +ping_core = { workspace = true, optional = true } amm_program = { path = "amm", optional = true } associated_token_account_program = { path = "associated_token_account", optional = true } diff --git a/lez/programs/bridge/Cargo.toml b/lez/programs/bridge/Cargo.toml index d7762f1f..1241adde 100644 --- a/lez/programs/bridge/Cargo.toml +++ b/lez/programs/bridge/Cargo.toml @@ -7,5 +7,4 @@ license = { workspace = true } [dependencies] bridge_core.workspace = true vault_core.workspace = true -authenticated_transfer_core.workspace = true lee_core.workspace = true diff --git a/lez/programs/bridge/core/Cargo.toml b/lez/programs/bridge/core/Cargo.toml index 201e899f..d08f2cd6 100644 --- a/lez/programs/bridge/core/Cargo.toml +++ b/lez/programs/bridge/core/Cargo.toml @@ -9,4 +9,5 @@ workspace = true [dependencies] lee_core.workspace = true +risc0-zkvm.workspace = true serde = { workspace = true, default-features = false } diff --git a/lez/programs/bridge/core/src/lib.rs b/lez/programs/bridge/core/src/lib.rs index c9666f27..129276dc 100644 --- a/lez/programs/bridge/core/src/lib.rs +++ b/lez/programs/bridge/core/src/lib.rs @@ -3,14 +3,19 @@ use lee_core::{account::AccountId, program::ProgramId}; use serde::{Deserialize, Serialize}; const BRIDGE_SEED_DOMAIN_SEPARATOR: [u8; 32] = *b"/LEZ/v0.3/BridgeSeed/0000000000/"; +const DEPOSIT_RECEIPT_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeDepositReceipt/0"; #[derive(Serialize, Deserialize)] pub enum Instruction { - /// Transfers native tokens from the bridge PDA account to a recipient vault. + /// Transfers native tokens from the bridge PDA account to a recipient vault, + /// exactly once per `l1_deposit_op_id`. /// - /// Required accounts (2): + /// Required accounts (3): /// - Bridge PDA account /// - Recipient vault PDA account + /// - Deposit-receipt PDA account, derived from `l1_deposit_op_id`. Its existence records that + /// this op id was already minted; a second application of the same op id finds it present and + /// transfers nothing. Deposit { /// Deposit OP ID from L1, stored here to pin each [`Deposit`](Instruction::Deposit) to a /// Deposit Event on L1. @@ -43,3 +48,58 @@ pub const fn compute_bridge_seed() -> PdaSeed { pub fn compute_bridge_account_id(bridge_program_id: ProgramId) -> AccountId { AccountId::for_public_pda(&bridge_program_id, &compute_bridge_seed()) } + +/// Seed of the deposit-receipt PDA for `l1_deposit_op_id`, exposed so the guest +/// can claim the account. Domain-separated from [`compute_bridge_seed`]. +#[must_use] +pub fn deposit_receipt_seed(l1_deposit_op_id: [u8; 32]) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut bytes = [0_u8; 64]; + bytes[..32].copy_from_slice(&DEPOSIT_RECEIPT_SEED_DOMAIN); + bytes[32..].copy_from_slice(&l1_deposit_op_id); + + let seed: [u8; 32] = Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .unwrap_or_else(|_| unreachable!()); + PdaSeed::new(seed) +} + +/// The deposit-receipt PDA whose existence marks `l1_deposit_op_id` as minted. +#[must_use] +pub fn deposit_receipt_account_id( + bridge_program_id: ProgramId, + l1_deposit_op_id: [u8; 32], +) -> AccountId { + AccountId::for_public_pda(&bridge_program_id, &deposit_receipt_seed(l1_deposit_op_id)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const BRIDGE_ID: ProgramId = [7; 8]; + + #[test] + fn receipt_id_is_deterministic_per_op_id() { + let op = [3_u8; 32]; + assert_eq!( + deposit_receipt_account_id(BRIDGE_ID, op), + deposit_receipt_account_id(BRIDGE_ID, op) + ); + } + + #[test] + fn distinct_op_ids_and_domains_do_not_collide() { + let a = deposit_receipt_account_id(BRIDGE_ID, [1; 32]); + let b = deposit_receipt_account_id(BRIDGE_ID, [2; 32]); + assert_ne!(a, b, "different op ids must derive different receipts"); + // The op-id-derived seed must not alias the plain bridge PDA, even if an + // op id ever equals the bridge seed's raw bytes. + assert_ne!( + deposit_receipt_account_id(BRIDGE_ID, *compute_bridge_seed().as_bytes()), + compute_bridge_account_id(BRIDGE_ID) + ); + } +} diff --git a/lez/programs/bridge/src/main.rs b/lez/programs/bridge/src/main.rs index 19d6509c..1fd3333a 100644 --- a/lez/programs/bridge/src/main.rs +++ b/lez/programs/bridge/src/main.rs @@ -1,6 +1,7 @@ use bridge_core::Instruction; -use lee_core::program::{ - AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_lee_inputs, +use lee_core::{ + account::Account, + program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, }; fn unchanged_post_states( @@ -29,18 +30,17 @@ fn main() { ); let pre_states_clone = pre_states.clone(); - let post_states = unchanged_post_states(&pre_states_clone); - let chained_calls = match instruction { + let (post_states, chained_calls) = match instruction { Instruction::Deposit { - l1_deposit_op_id: _, + l1_deposit_op_id, vault_program_id, recipient_id, amount, } => { - let [bridge, recipient_vault] = pre_states + let [bridge, recipient_vault, receipt] = pre_states .try_into() - .expect("Deposit requires exactly 2 accounts"); + .expect("Deposit requires exactly 3 accounts"); assert_eq!( bridge.account_id, @@ -54,48 +54,85 @@ fn main() { "Second account must be recipient vault PDA" ); - let mut bridge_for_vault = bridge; - bridge_for_vault.is_authorized = true; + assert_eq!( + receipt.account_id, + bridge_core::deposit_receipt_account_id(self_program_id, l1_deposit_op_id), + "Third account must be the deposit-receipt PDA" + ); - vec![ - ChainedCall::new( - vault_program_id, - vec![bridge_for_vault, recipient_vault], - &vault_core::Instruction::Transfer { - recipient_id, - amount: u128::from(amount), - }, - ) - .with_pda_seeds(vec![bridge_core::compute_bridge_seed()]), - ] + // Replay protection: the receipt PDA exists iff this op id was + // already minted. On replay it is non-default and the whole + // instruction is a no-op. + // + // Observability note: a no-op replay and a real first mint are both + // successful txs, so an indexer cannot tell "credited here" from + // "already credited by a peer" without deriving the receipt id and + // checking whether it existed before this block — the receipt claim + // is the only on-chain signal. Relevant once the explorer surfaces + // deposits. + if receipt.account != Account::default() { + (unchanged_post_states(&pre_states_clone), vec![]) + } else { + // First mint: claim the receipt — its existence is the record, + // the account's contents are never read — and chain the vault + // transfer. + let receipt_post = AccountPostState::new_claimed_if_default( + receipt.account, + Claim::Pda(bridge_core::deposit_receipt_seed(l1_deposit_op_id)), + ); + + let post_states = vec![ + AccountPostState::new(bridge.account.clone()), + AccountPostState::new(recipient_vault.account.clone()), + receipt_post, + ]; + + let mut bridge_for_vault = bridge; + bridge_for_vault.is_authorized = true; + let chained_calls = vec![ + ChainedCall::new( + vault_program_id, + vec![bridge_for_vault, recipient_vault], + &vault_core::Instruction::Transfer { + recipient_id, + amount: u128::from(amount), + }, + ) + .with_pda_seeds(vec![bridge_core::compute_bridge_seed()]), + ]; + (post_states, chained_calls) + } } Instruction::Withdraw { - amount, + amount: _, bedrock_account_pk: _, } => { - let [sender, bridge] = pre_states - .try_into() - .expect("Withdraw requires exactly 2 accounts"); + panic!("Withdraws are disabled in the current version of LEZ"); - assert_eq!( - bridge.account_id, - bridge_core::compute_bridge_account_id(self_program_id), - "Second account must be bridge PDA" - ); + // let [sender, bridge] = pre_states + // .try_into() + // .expect("Withdraw requires exactly 2 accounts"); - let auth_transfer_program_id = bridge.account.program_owner; - assert_eq!( - sender.account.program_owner, auth_transfer_program_id, - "Sender account must be owned by the authenticated transfer program" - ); + // assert_eq!( + // bridge.account_id, + // bridge_core::compute_bridge_account_id(self_program_id), + // "Second account must be bridge PDA" + // ); - vec![ChainedCall::new( - auth_transfer_program_id, - vec![sender, bridge], - &authenticated_transfer_core::Instruction::Transfer { - amount: u128::from(amount), - }, - )] + // let auth_transfer_program_id = bridge.account.program_owner; + // assert_eq!( + // sender.account.program_owner, auth_transfer_program_id, + // "Sender account must be owned by the authenticated transfer program" + // ); + + // let chained_calls = vec![ChainedCall::new( + // auth_transfer_program_id, + // vec![sender, bridge], + // &authenticated_transfer_core::Instruction::Transfer { + // amount: u128::from(amount), + // }, + // )]; + // (unchanged_post_states(&pre_states_clone), chained_calls) } }; diff --git a/lez/programs/bridge_lock/Cargo.toml b/lez/programs/bridge_lock/Cargo.toml new file mode 100644 index 00000000..8a4d6c20 --- /dev/null +++ b/lez/programs/bridge_lock/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "bridge_lock_program" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +bridge_lock_core.workspace = true +cross_zone_outbox_core.workspace = true +wrapped_token_core.workspace = true +risc0-zkvm.workspace = true diff --git a/lez/programs/bridge_lock/core/Cargo.toml b/lez/programs/bridge_lock/core/Cargo.toml new file mode 100644 index 00000000..190fc4f2 --- /dev/null +++ b/lez/programs/bridge_lock/core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "bridge_lock_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +serde = { workspace = true, features = ["alloc"] } diff --git a/lez/programs/bridge_lock/core/src/lib.rs b/lez/programs/bridge_lock/core/src/lib.rs new file mode 100644 index 00000000..6d2aaf49 --- /dev/null +++ b/lez/programs/bridge_lock/core/src/lib.rs @@ -0,0 +1,51 @@ +//! Core types for the bridge-lock program, the source side of the cross-zone +//! token bridge. A holder locks part of their balance into an escrow and emits a +//! cross-zone message minting the wrapped token on the target zone. + +use lee_core::{ + account::AccountId, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +const ESCROW_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockEscrow/0000/"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Instruction { + /// Lock `amount` of the holder's balance and emit a cross-zone message + /// minting the wrapped token on `target_zone`. The emission fields mirror + /// `cross_zone_outbox::Instruction::Emit` so the watcher reads them directly. + /// + /// Required accounts (3): holder holding (authorized), escrow PDA, outbox PDA. + Lock { + amount: u128, + target_zone: [u8; 32], + target_program_id: ProgramId, + target_accounts: Vec<[u8; 32]>, + payload: Vec, + outbox_program_id: ProgramId, + ordinal: u32, + }, +} + +/// PDA accumulating all locked balance on this zone. +#[must_use] +pub fn escrow_account_id(bridge_lock_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&bridge_lock_id, &escrow_seed()) +} + +#[must_use] +pub const fn escrow_seed() -> PdaSeed { + PdaSeed::new(ESCROW_SEED_DOMAIN) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escrow_is_stable() { + let id: ProgramId = [4; 8]; + assert_eq!(escrow_account_id(id), escrow_account_id(id)); + } +} diff --git a/lez/programs/bridge_lock/src/main.rs b/lez/programs/bridge_lock/src/main.rs new file mode 100644 index 00000000..8b176ee5 --- /dev/null +++ b/lez/programs/bridge_lock/src/main.rs @@ -0,0 +1,129 @@ +use bridge_lock_core::{Instruction, escrow_account_id, escrow_seed}; +use cross_zone_outbox_core::Instruction as OutboxInstruction; +use lee_core::{ + account::AccountWithMetadata, + program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, +}; +use wrapped_token_core::Instruction as WrappedInstruction; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_lee_inputs::(); + + assert!( + caller_program_id.is_none(), + "bridge_lock is only invoked as a top-level user transaction" + ); + + let Instruction::Lock { + amount, + target_zone, + target_program_id, + target_accounts, + payload, + outbox_program_id, + ordinal, + } = instruction; + + // Value conservation: the forwarded payload must mint exactly what is locked. + let WrappedInstruction::Mint { + amount: mint_amount, + .. + } = decode_mint(&payload) + else { + panic!("bridge_lock payload must be a wrapped-token mint"); + }; + assert_eq!( + mint_amount, amount, + "locked amount must equal the wrapped mint amount" + ); + + // pre_states: [holder holding (authorized), escrow PDA, outbox PDA]. + let [holder, escrow, outbox] = <[AccountWithMetadata; 3]>::try_from(pre_states) + .expect("Lock requires holder, escrow, and outbox accounts"); + + assert!(holder.is_authorized, "holder must authorize the lock"); + // The holder holding is bridge_lock-owned, so bridge_lock may debit its native + // balance directly (state-machine rule 5). This also pins the transfer to a + // genuine holding: a caller cannot substitute an account owned by some other + // program to emit the mint without an actual lock. + assert_eq!( + holder.account.program_owner, self_program_id, + "holder account must be a bridge_lock holding" + ); + assert_eq!( + escrow.account_id, + escrow_account_id(self_program_id), + "second account must be the escrow PDA" + ); + + // Move the real native balance holder -> escrow. bridge_lock owns both accounts, + // so it debits the holder and credits the escrow directly; conservation holds + // because the same amount moves between them. + let holder_new = holder + .account + .balance + .checked_sub(amount) + .expect("insufficient balance to lock"); + let escrow_new = escrow + .account + .balance + .checked_add(amount) + .expect("escrow balance overflow"); + + let mut holder_account = holder.account.clone(); + holder_account.balance = holder_new; + let holder_post = AccountPostState::new(holder_account); + + let mut escrow_account = escrow.account.clone(); + escrow_account.balance = escrow_new; + let escrow_post = + AccountPostState::new_claimed_if_default(escrow_account, Claim::Pda(escrow_seed())); + + let call = ChainedCall::new( + outbox_program_id, + vec![outbox.clone()], + &OutboxInstruction::Emit { + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + }, + ); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![holder, escrow, outbox.clone()], + vec![ + holder_post, + escrow_post, + AccountPostState::new(outbox.account), + ], + ) + .with_chained_calls(vec![call]) + .write(); +} + +/// Decodes the cross-zone payload (risc0 words, little-endian bytes) into the +/// wrapped-token instruction it carries. +fn decode_mint(payload: &[u8]) -> WrappedInstruction { + assert!( + payload.len().is_multiple_of(4), + "payload must be u32-aligned instruction words" + ); + let words: Vec = payload + .chunks_exact(4) + .map(|chunk| u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!()))) + .collect(); + risc0_zkvm::serde::from_slice(&words).expect("payload decodes to a wrapped-token instruction") +} diff --git a/lez/programs/cross_zone_inbox/Cargo.toml b/lez/programs/cross_zone_inbox/Cargo.toml new file mode 100644 index 00000000..6f4c651a --- /dev/null +++ b/lez/programs/cross_zone_inbox/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "cross_zone_inbox_program" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +cross_zone_inbox_core.workspace = true diff --git a/lez/programs/cross_zone_inbox/core/Cargo.toml b/lez/programs/cross_zone_inbox/core/Cargo.toml new file mode 100644 index 00000000..6bea109f --- /dev/null +++ b/lez/programs/cross_zone_inbox/core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cross_zone_inbox_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +serde = { workspace = true, features = ["alloc"] } +risc0-zkvm.workspace = true +borsh.workspace = true diff --git a/lez/programs/cross_zone_inbox/core/src/lib.rs b/lez/programs/cross_zone_inbox/core/src/lib.rs new file mode 100644 index 00000000..4323db6b --- /dev/null +++ b/lez/programs/cross_zone_inbox/core/src/lib.rs @@ -0,0 +1,214 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use borsh::{BorshDeserialize, BorshSerialize}; +use lee_core::{ + account::AccountId, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +/// Source blocks per seen-set shard, so no single seen account grows without bound. +pub const EPOCH_BLOCKS: u64 = 10_000; + +const MESSAGE_KEY_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneMsgKey/00000/"; +const INBOX_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxCfg/000/"; +const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/00/"; + +/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. +pub type ZoneId = [u8; 32]; + +/// Block-signing public key pinned per peer zone. +pub type ExpectedPubkey = [u8; 32]; + +/// Content-addressed replay key for a delivered message. +pub type MessageKey = [u8; 32]; + +/// A peer zone whose outbox a zone watches for inbound cross-zone messages. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CrossZonePeer { + /// The peer's Bedrock channel; its 32 bytes double as the peer's zone id. + pub channel_id: ZoneId, + /// Programs on the local zone a message from this peer is allowed to target. + pub allowed_targets: Vec, + /// The peer's block-signing public key, pinned to reject blocks inscribed by + /// anyone other than that zone's sequencer. `None` skips the check (the + /// channel signer is still authenticated by the zone-sdk). + #[serde(default)] + pub expected_block_signing_pubkey: Option<[u8; 32]>, +} + +/// Cross-zone configuration shared by a zone's sequencer (watcher) and indexer +/// (verifier): the peers it reads from Bedrock and, per peer, the local programs +/// they may deliver to. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CrossZoneConfig { + pub peers: Vec, +} + +/// A finalized outbound message observed on a peer zone, addressed to a program +/// on this zone. The watcher fills it from the peer's block; it is never +/// self-reported by a user. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CrossZoneMessage { + pub src_zone: ZoneId, + pub src_block_id: u64, + pub src_tx_index: u32, + pub src_program_id: ProgramId, + pub target_program_id: ProgramId, + pub payload: Vec, + /// Reserved for a future source-state proof; MUST be `None` in v1. + pub l1_inclusion_witness: Option>, +} + +/// Peer and per-peer target allowlists, plus this inbox's own zone id. +#[derive( + Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize, +)] +pub struct InboxConfig { + pub self_zone: ZoneId, + pub allowed_peers: BTreeMap, + pub allowed_targets: BTreeMap>, +} + +impl InboxConfig { + /// Borsh-encoded form stored in the inbox config account. + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("InboxConfig serializes") + } + + /// Decodes an [`InboxConfig`] from account data. + pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { + borsh::from_slice(bytes) + } +} + +/// The replay keys seen for one `(src_zone, epoch)` shard. +#[derive(Clone, Debug, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct SeenShard(pub BTreeSet); + +impl SeenShard { + /// Decodes a shard from account data; empty data is an empty shard. + pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { + if bytes.is_empty() { + return Ok(Self::default()); + } + borsh::from_slice(bytes) + } + + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("SeenShard serializes") + } + + #[must_use] + pub fn contains(&self, key: &MessageKey) -> bool { + self.0.contains(key) + } + + /// Inserts a key; returns true if it was newly inserted. + pub fn insert(&mut self, key: MessageKey) -> bool { + self.0.insert(key) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Instruction { + /// Delivers a finalized peer message to its target program. + Dispatch(CrossZoneMessage), + /// Initializes the inbox config account at genesis. Written once, into a + /// default (unclaimed) config PDA; the guest refuses a non-default pre-state, + /// so it cannot be re-run to overwrite the allowlists. + InitConfig(InboxConfig), +} + +/// Content-addressed replay key for a delivered message. +/// +/// Hashes `(src_zone, src_block_id, src_tx_index)` under a domain separator. +/// Watcher-independent and immune to proof malleability, since it keys on block +/// id plus index rather than a tx hash. +#[must_use] +pub fn message_key(src_zone: &ZoneId, src_block_id: u64, src_tx_index: u32) -> MessageKey { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut bytes = [0_u8; 76]; + bytes[..32].copy_from_slice(&MESSAGE_KEY_DOMAIN); + bytes[32..64].copy_from_slice(src_zone); + bytes[64..72].copy_from_slice(&src_block_id.to_le_bytes()); + bytes[72..].copy_from_slice(&src_tx_index.to_le_bytes()); + + Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .unwrap_or_else(|_| unreachable!()) +} + +/// The config account holding the allowlists. +#[must_use] +pub fn inbox_config_account_id(inbox_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&inbox_id, &inbox_config_seed()) +} + +/// Seed of the config PDA, exposed so the guest can claim the account when it +/// initializes the config at genesis. +#[must_use] +pub const fn inbox_config_seed() -> PdaSeed { + PdaSeed::new(INBOX_CONFIG_SEED) +} + +/// The seen-set shard for the `(src_zone, epoch)` the message falls in. +#[must_use] +pub fn inbox_seen_shard_account_id( + inbox_id: ProgramId, + src_zone: &ZoneId, + src_block_id: u64, +) -> AccountId { + AccountId::for_public_pda(&inbox_id, &inbox_seen_shard_seed(src_zone, src_block_id)) +} + +/// Seed of the seen-shard PDA, exposed so the guest can claim the account. +#[must_use] +pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let src_epoch = src_block_id.wrapping_div(EPOCH_BLOCKS); + let mut bytes = [0_u8; 72]; + bytes[..32].copy_from_slice(&INBOX_SEEN_SEED_DOMAIN); + bytes[32..64].copy_from_slice(src_zone); + bytes[64..].copy_from_slice(&src_epoch.to_le_bytes()); + + let seed: [u8; 32] = Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .unwrap_or_else(|_| unreachable!()); + PdaSeed::new(seed) +} +#[cfg(test)] +mod tests { + use super::*; + + fn zone(b: u8) -> ZoneId { + [b; 32] + } + + #[test] + fn message_key_is_stable_and_content_addressed() { + assert_eq!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 3)); + assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(2), 7, 3)); + assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 8, 3)); + assert_ne!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 4)); + } + + #[test] + fn seen_shards_split_on_epoch_boundary() { + let id: ProgramId = [9; 8]; + assert_eq!( + inbox_seen_shard_account_id(id, &zone(1), 0), + inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1), + ); + assert_ne!( + inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1), + inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS), + ); + } +} diff --git a/lez/programs/cross_zone_inbox/src/main.rs b/lez/programs/cross_zone_inbox/src/main.rs new file mode 100644 index 00000000..bea71c69 --- /dev/null +++ b/lez/programs/cross_zone_inbox/src/main.rs @@ -0,0 +1,205 @@ +use cross_zone_inbox_core::{ + CrossZoneMessage, InboxConfig, Instruction, SeenShard, inbox_config_account_id, + inbox_config_seed, inbox_seen_shard_account_id, inbox_seen_shard_seed, message_key, +}; +use lee_core::{ + account::{Account, AccountWithMetadata}, + program::{ + AccountPostState, ChainedCall, Claim, ProgramId, ProgramInput, ProgramOutput, + read_lee_inputs, + }, +}; + +fn unchanged(pre: &AccountWithMetadata) -> AccountPostState { + AccountPostState::new(pre.account.clone()) +} + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_lee_inputs::(); + + assert!( + caller_program_id.is_none(), + "Inbox is only invoked as a top-level sequencer-origin transaction" + ); + + match instruction { + Instruction::Dispatch(msg) => dispatch( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + &msg, + ), + Instruction::InitConfig(config) => init_config( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + &config, + ), + } +} + +/// Delivers a finalized peer message to its target program, no-op on replay. +fn dispatch( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + msg: &CrossZoneMessage, +) { + assert!( + msg.l1_inclusion_witness.is_none(), + "l1_inclusion_witness must be None in v1" + ); + + // pre_states layout: [config, seen_shard, then the target accounts]. + let mut accounts = pre_states.into_iter(); + let config = accounts.next().expect("config account required"); + let seen = accounts.next().expect("seen shard account required"); + let target_accounts: Vec = accounts.collect(); + + assert_eq!( + config.account_id, + inbox_config_account_id(self_program_id), + "First account must be the inbox config PDA" + ); + assert_eq!( + seen.account_id, + inbox_seen_shard_account_id(self_program_id, &msg.src_zone, msg.src_block_id), + "Second account must be the seen-shard PDA" + ); + + let cfg = InboxConfig::from_bytes(&config.account.data.clone().into_inner()) + .expect("inbox config decodes"); + + assert!( + msg.src_zone != cfg.self_zone, + "Source zone must not be this zone" + ); + let allowed_targets = cfg + .allowed_targets + .get(&msg.src_zone) + .expect("Source zone is not an allowed peer"); + assert!( + allowed_targets.contains(&msg.target_program_id), + "Target program is not allowed for this peer" + ); + + let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index); + let mut shard = + SeenShard::from_bytes(&seen.account.data.clone().into_inner()).expect("seen shard decodes"); + let already_seen = shard.contains(&key); + + // On replay this is a no-op: the seen shard is untouched and no call is made. + let (seen_post, chained_calls) = if already_seen { + (unchanged(&seen), vec![]) + } else { + shard.insert(key); + let mut seen_account = seen.account.clone(); + seen_account.data = shard + .to_bytes() + .try_into() + .expect("seen shard fits in account data"); + let seen_post = AccountPostState::new_claimed_if_default( + seen_account, + Claim::Pda(inbox_seen_shard_seed(&msg.src_zone, msg.src_block_id)), + ); + + // The payload carries the target instruction as risc0 words, little-endian. + assert!( + msg.payload.len().is_multiple_of(4), + "payload must be u32-aligned instruction words" + ); + let instruction_data = msg + .payload + .chunks_exact(4) + .map(|c| u32::from_le_bytes(c.try_into().unwrap_or_else(|_| unreachable!()))) + .collect(); + + let call = ChainedCall { + program_id: msg.target_program_id, + pre_states: target_accounts.clone(), + instruction_data, + pda_seeds: vec![], + }; + (seen_post, vec![call]) + }; + + let mut post_states = vec![unchanged(&config), seen_post]; + post_states.extend(target_accounts.iter().map(unchanged)); + + let mut output_pre_states = vec![config, seen]; + output_pre_states.extend(target_accounts); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + output_pre_states, + post_states, + ) + .with_chained_calls(chained_calls) + .write(); +} + +/// Writes the inbox config (peer + target allowlists) into the config PDA exactly +/// once at genesis. +fn init_config( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + config: &InboxConfig, +) { + // pre_states: [config PDA]. + let [config_meta] = <[AccountWithMetadata; 1]>::try_from(pre_states) + .expect("InitConfig requires the config account"); + assert_eq!( + config_meta.account_id, + inbox_config_account_id(self_program_id), + "account must be the inbox config PDA" + ); + // Init-once, idempotent under genesis replay: a `default` config is a first + // init; an already-owned config must already hold exactly these allowlists (the + // genesis block is replayed onto seeded state during multi-sequencer + // reconstruction), otherwise reject a post-genesis attempt to change them. + // `new_claimed_if_default` alone would not stop the owning program from + // rewriting its own config data on a later call. + if config_meta.account != Account::default() { + assert_eq!( + config_meta.account.program_owner, self_program_id, + "inbox config PDA is owned by another program" + ); + assert_eq!( + config_meta.account.data.clone().into_inner(), + config.to_bytes(), + "inbox config already initialized with different allowlists" + ); + } + + let mut config_account = config_meta.account.clone(); + config_account.data = config + .to_bytes() + .try_into() + .expect("inbox config fits in account data"); + let config_post = + AccountPostState::new_claimed_if_default(config_account, Claim::Pda(inbox_config_seed())); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![config_meta], + vec![config_post], + ) + .write(); +} diff --git a/lez/programs/cross_zone_outbox/Cargo.toml b/lez/programs/cross_zone_outbox/Cargo.toml new file mode 100644 index 00000000..2f236cbf --- /dev/null +++ b/lez/programs/cross_zone_outbox/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "cross_zone_outbox_program" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +cross_zone_outbox_core.workspace = true diff --git a/lez/programs/cross_zone_outbox/core/Cargo.toml b/lez/programs/cross_zone_outbox/core/Cargo.toml new file mode 100644 index 00000000..c7876286 --- /dev/null +++ b/lez/programs/cross_zone_outbox/core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cross_zone_outbox_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +serde = { workspace = true, features = ["alloc"] } +risc0-zkvm.workspace = true +borsh.workspace = true diff --git a/lez/programs/cross_zone_outbox/core/src/lib.rs b/lez/programs/cross_zone_outbox/core/src/lib.rs new file mode 100644 index 00000000..736b5fa2 --- /dev/null +++ b/lez/programs/cross_zone_outbox/core/src/lib.rs @@ -0,0 +1,93 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use lee_core::{ + account::AccountId, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00000/"; + +/// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. +pub type ZoneId = [u8; 32]; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Instruction { + /// Records an outbound cross-zone message as a write to a self-owned PDA. + /// + /// Required accounts (1): + /// - Outbox PDA account + Emit { + target_zone: ZoneId, + target_program_id: ProgramId, + /// Accounts the destination inbox must hand to the target program's + /// chained call. The emitter specifies them; the watcher forwards them + /// verbatim so the inbox stays target-agnostic. + target_accounts: Vec<[u8; 32]>, + payload: Vec, + ordinal: u32, + }, +} + +/// The message as stored in an outbox PDA. The destination zone's watcher reads +/// this from the inscribed block; the source coordinates are filled by the +/// watcher, not stored here. +#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct OutboxRecord { + pub target_zone: ZoneId, + pub target_program_id: ProgramId, + pub target_accounts: Vec<[u8; 32]>, + pub payload: Vec, +} + +impl OutboxRecord { + /// Borsh-encoded form stored in the outbox PDA's account data. + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("OutboxRecord serializes") + } + + /// Decodes an [`OutboxRecord`] from account data. + pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { + borsh::from_slice(bytes) + } +} + +/// PDA holding one emitted message, keyed by destination zone and a per-zone +/// ordinal. +#[must_use] +pub fn outbox_pda(outbox_id: ProgramId, target_zone: &ZoneId, ordinal: u32) -> AccountId { + AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(target_zone, ordinal)) +} + +/// Seed of an outbox message PDA, exposed so the guest can claim the account. +#[must_use] +pub fn outbox_pda_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut bytes = [0_u8; 68]; + bytes[..32].copy_from_slice(&OUTBOX_SEED_DOMAIN); + bytes[32..64].copy_from_slice(target_zone); + bytes[64..].copy_from_slice(&ordinal.to_le_bytes()); + + let seed: [u8; 32] = Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .unwrap_or_else(|_| unreachable!()); + PdaSeed::new(seed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn outbox_pda_is_unique_per_zone_and_ordinal() { + let id: ProgramId = [3; 8]; + let zone_a = [1; 32]; + let zone_b = [2; 32]; + + assert_eq!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 0)); + assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 1)); + assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_b, 0)); + } +} diff --git a/lez/programs/cross_zone_outbox/src/main.rs b/lez/programs/cross_zone_outbox/src/main.rs new file mode 100644 index 00000000..432e8d9c --- /dev/null +++ b/lez/programs/cross_zone_outbox/src/main.rs @@ -0,0 +1,72 @@ +use cross_zone_outbox_core::{Instruction, OutboxRecord, outbox_pda, outbox_pda_seed}; +use lee_core::{ + account::AccountWithMetadata, + program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, +}; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_lee_inputs::(); + + assert!( + caller_program_id.is_some(), + "Outbox is only callable through a chain call from a user program" + ); + + let (target_zone, target_program_id, target_accounts, payload, ordinal) = match instruction { + Instruction::Emit { + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + } => ( + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + ), + }; + + let [outbox] = + <[AccountWithMetadata; 1]>::try_from(pre_states).expect("Emit requires exactly 1 account"); + + assert_eq!( + outbox.account_id, + outbox_pda(self_program_id, &target_zone, ordinal), + "Account must be the outbox PDA for (target_zone, ordinal)" + ); + + let mut post_account = outbox.account.clone(); + post_account.data = OutboxRecord { + target_zone, + target_program_id, + target_accounts, + payload, + } + .to_bytes() + .try_into() + .expect("OutboxRecord fits in account data"); + + let post = AccountPostState::new_claimed_if_default( + post_account, + Claim::Pda(outbox_pda_seed(&target_zone, ordinal)), + ); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![outbox], + vec![post], + ) + .write(); +} diff --git a/lez/programs/ping_core/Cargo.toml b/lez/programs/ping_core/Cargo.toml new file mode 100644 index 00000000..29870630 --- /dev/null +++ b/lez/programs/ping_core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ping_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +serde = { workspace = true, features = ["alloc"] } diff --git a/lez/programs/ping_core/src/lib.rs b/lez/programs/ping_core/src/lib.rs new file mode 100644 index 00000000..80b27247 --- /dev/null +++ b/lez/programs/ping_core/src/lib.rs @@ -0,0 +1,38 @@ +use lee_core::{ + account::AccountId, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +const PING_RECORD_SEED: [u8; 32] = *b"/LEZ/v0.3/PingRecord/0000000000/"; + +/// Instruction delivered to `ping_receiver` by the inbox: record the payload. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ReceiverInstruction { + Record { payload: Vec }, +} + +/// Instruction to `ping_sender`: forwarded verbatim into `cross_zone_outbox::Instruction::Emit`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SenderInstruction { + Send { + outbox_program_id: ProgramId, + target_zone: [u8; 32], + target_program_id: ProgramId, + target_accounts: Vec<[u8; 32]>, + payload: Vec, + ordinal: u32, + }, +} + +/// The account a `ping_receiver` records the latest delivered payload into. +#[must_use] +pub fn ping_record_pda(receiver_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&receiver_id, &ping_record_seed()) +} + +/// Seed of the record PDA, exposed so the guest can claim the account. +#[must_use] +pub const fn ping_record_seed() -> PdaSeed { + PdaSeed::new(PING_RECORD_SEED) +} diff --git a/lez/programs/ping_receiver/Cargo.toml b/lez/programs/ping_receiver/Cargo.toml new file mode 100644 index 00000000..a1d88f39 --- /dev/null +++ b/lez/programs/ping_receiver/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ping_receiver_program" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +ping_core.workspace = true diff --git a/lez/programs/ping_receiver/src/main.rs b/lez/programs/ping_receiver/src/main.rs new file mode 100644 index 00000000..4fd9679f --- /dev/null +++ b/lez/programs/ping_receiver/src/main.rs @@ -0,0 +1,48 @@ +use lee_core::{ + account::AccountWithMetadata, + program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, +}; +use ping_core::{ReceiverInstruction, ping_record_pda, ping_record_seed}; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_lee_inputs::(); + + assert!( + caller_program_id.is_some(), + "ping_receiver is only callable through a chained call" + ); + + let payload = match instruction { + ReceiverInstruction::Record { payload } => payload, + }; + + let [record] = <[AccountWithMetadata; 1]>::try_from(pre_states) + .expect("Record requires exactly 1 account"); + assert_eq!( + record.account_id, + ping_record_pda(self_program_id), + "Account must be the ping record PDA" + ); + + let mut post_account = record.account.clone(); + post_account.data = payload.try_into().expect("payload fits in account data"); + let post = + AccountPostState::new_claimed_if_default(post_account, Claim::Pda(ping_record_seed())); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![record], + vec![post], + ) + .write(); +} diff --git a/lez/programs/ping_sender/Cargo.toml b/lez/programs/ping_sender/Cargo.toml new file mode 100644 index 00000000..8567ae55 --- /dev/null +++ b/lez/programs/ping_sender/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ping_sender_program" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +ping_core.workspace = true +cross_zone_outbox_core.workspace = true diff --git a/lez/programs/ping_sender/src/main.rs b/lez/programs/ping_sender/src/main.rs new file mode 100644 index 00000000..d0ad04f5 --- /dev/null +++ b/lez/programs/ping_sender/src/main.rs @@ -0,0 +1,59 @@ +use cross_zone_outbox_core::Instruction as OutboxInstruction; +use lee_core::{ + account::AccountWithMetadata, + program::{AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_lee_inputs}, +}; +use ping_core::SenderInstruction; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_lee_inputs::(); + + assert!( + caller_program_id.is_none(), + "ping_sender is only invoked as a top-level user transaction" + ); + + let SenderInstruction::Send { + outbox_program_id, + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + } = instruction; + + // The single account is the outbox PDA the chained call writes into; the + // outbox claims it, so ping_sender forwards it unchanged. + let [outbox] = + <[AccountWithMetadata; 1]>::try_from(pre_states).expect("Send requires exactly 1 account"); + + let call = ChainedCall::new( + outbox_program_id, + vec![outbox.clone()], + &OutboxInstruction::Emit { + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + }, + ); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![outbox.clone()], + vec![AccountPostState::new(outbox.account)], + ) + .with_chained_calls(vec![call]) + .write(); +} diff --git a/lez/programs/src/lib.rs b/lez/programs/src/lib.rs index 4acade0f..fb448038 100644 --- a/lez/programs/src/lib.rs +++ b/lez/programs/src/lib.rs @@ -10,9 +10,12 @@ mod inner { use guests::{ AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID, - AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID, CLOCK_ELF, - CLOCK_ID, FAUCET_ELF, FAUCET_ID, PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, PINATA_TOKEN_ID, - TOKEN_ELF, TOKEN_ID, VAULT_ELF, VAULT_ID, + AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, BRIDGE_ELF, BRIDGE_ID, + BRIDGE_LOCK_ELF, BRIDGE_LOCK_ID, CLOCK_ELF, CLOCK_ID, CROSS_ZONE_INBOX_ELF, + CROSS_ZONE_INBOX_ID, CROSS_ZONE_OUTBOX_ELF, CROSS_ZONE_OUTBOX_ID, FAUCET_ELF, FAUCET_ID, + PINATA_ELF, PINATA_ID, PINATA_TOKEN_ELF, PINATA_TOKEN_ID, PING_RECEIVER_ELF, + PING_RECEIVER_ID, PING_SENDER_ELF, PING_SENDER_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF, + VAULT_ID, WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID, }; use lee::program::Program; @@ -87,6 +90,42 @@ mod inner { Program::new_unchecked(BRIDGE_ID, Cow::Borrowed(BRIDGE_ELF)) } + #[must_use] + #[inline] + pub const fn cross_zone_outbox() -> Program { + Program::new_unchecked(CROSS_ZONE_OUTBOX_ID, Cow::Borrowed(CROSS_ZONE_OUTBOX_ELF)) + } + + #[must_use] + #[inline] + pub const fn cross_zone_inbox() -> Program { + Program::new_unchecked(CROSS_ZONE_INBOX_ID, Cow::Borrowed(CROSS_ZONE_INBOX_ELF)) + } + + #[must_use] + #[inline] + pub const fn ping_sender() -> Program { + Program::new_unchecked(PING_SENDER_ID, Cow::Borrowed(PING_SENDER_ELF)) + } + + #[must_use] + #[inline] + pub const fn ping_receiver() -> Program { + Program::new_unchecked(PING_RECEIVER_ID, Cow::Borrowed(PING_RECEIVER_ELF)) + } + + #[must_use] + #[inline] + pub const fn bridge_lock() -> Program { + Program::new_unchecked(BRIDGE_LOCK_ID, Cow::Borrowed(BRIDGE_LOCK_ELF)) + } + + #[must_use] + #[inline] + pub const fn wrapped_token() -> Program { + Program::new_unchecked(WRAPPED_TOKEN_ID, Cow::Borrowed(WRAPPED_TOKEN_ELF)) + } + #[cfg(test)] mod tests { use super::*; @@ -127,6 +166,12 @@ mod inner { (PINATA_TOKEN_ELF, PINATA_TOKEN_ID), (TOKEN_ELF, TOKEN_ID), (VAULT_ELF, VAULT_ID), + (CROSS_ZONE_OUTBOX_ELF, CROSS_ZONE_OUTBOX_ID), + (CROSS_ZONE_INBOX_ELF, CROSS_ZONE_INBOX_ID), + (PING_SENDER_ELF, PING_SENDER_ID), + (PING_RECEIVER_ELF, PING_RECEIVER_ID), + (BRIDGE_LOCK_ELF, BRIDGE_LOCK_ID), + (WRAPPED_TOKEN_ELF, WRAPPED_TOKEN_ID), ]; for (elf, expected_id) in cases { let program = Program::new((*elf).into()).unwrap(); diff --git a/lez/programs/wrapped_token/Cargo.toml b/lez/programs/wrapped_token/Cargo.toml new file mode 100644 index 00000000..a80f60ff --- /dev/null +++ b/lez/programs/wrapped_token/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "wrapped_token_program" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +wrapped_token_core.workspace = true diff --git a/lez/programs/wrapped_token/core/Cargo.toml b/lez/programs/wrapped_token/core/Cargo.toml new file mode 100644 index 00000000..ef0aabbc --- /dev/null +++ b/lez/programs/wrapped_token/core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "wrapped_token_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee_core.workspace = true +serde = { workspace = true, features = ["alloc"] } +risc0-zkvm.workspace = true diff --git a/lez/programs/wrapped_token/core/src/lib.rs b/lez/programs/wrapped_token/core/src/lib.rs new file mode 100644 index 00000000..a95a5ca0 --- /dev/null +++ b/lez/programs/wrapped_token/core/src/lib.rs @@ -0,0 +1,127 @@ +//! Core types for the wrapped-token program, the destination side of the +//! cross-zone bridge. Only the cross-zone inbox may mint; the guest enforces +//! this by reading the authorized minter from a genesis-seeded config account. + +use lee_core::{ + account::AccountId, + program::{PdaSeed, ProgramId}, +}; +use serde::{Deserialize, Serialize}; + +const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenConfig/00/"; +const HOLDING_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenHold/00000"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Instruction { + /// Credit `amount` wrapped tokens to `recipient`'s holding. Delivered only by + /// the cross-zone inbox. + /// + /// Required accounts (2): the wrapped-token config PDA, then the recipient's + /// holding PDA. + Mint { recipient: [u8; 32], amount: u128 }, + /// Pins `minter` (the cross-zone inbox) as the authorized minter, written once + /// into a default config PDA at genesis. The guest refuses a non-default + /// pre-state, so it cannot be re-run to hijack the minter. + /// + /// Required accounts (1): the wrapped-token config PDA. + InitConfig { minter: ProgramId }, +} + +/// PDA holding the authorized minter program id (the cross-zone inbox), seeded at +/// genesis so the guest can pin its caller without importing the inbox image id. +#[must_use] +pub fn config_account_id(wrapped_token_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&wrapped_token_id, &config_seed()) +} + +#[must_use] +pub const fn config_seed() -> PdaSeed { + PdaSeed::new(CONFIG_SEED_DOMAIN) +} + +/// PDA holding one recipient's wrapped-token balance. +#[must_use] +pub fn holding_account_id(wrapped_token_id: ProgramId, recipient: &[u8; 32]) -> AccountId { + AccountId::for_public_pda(&wrapped_token_id, &holding_seed(recipient)) +} + +#[must_use] +pub fn holding_seed(recipient: &[u8; 32]) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut bytes = [0_u8; 64]; + bytes[..32].copy_from_slice(&HOLDING_SEED_DOMAIN); + bytes[32..].copy_from_slice(recipient); + let seed: [u8; 32] = Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .unwrap_or_else(|_| unreachable!()); + PdaSeed::new(seed) +} + +/// Encodes the authorized minter program id for the config account's data. +#[must_use] +pub fn minter_bytes(minter: ProgramId) -> [u8; 32] { + let mut bytes = [0_u8; 32]; + for (word, chunk) in minter.iter().zip(bytes.chunks_exact_mut(4)) { + chunk.copy_from_slice(&word.to_le_bytes()); + } + bytes +} + +/// Decodes the authorized minter program id from the config account's data. +#[must_use] +pub fn read_minter(data: &[u8]) -> Option { + if data.len() < 32 { + return None; + } + let mut minter = [0_u32; 8]; + for (word, chunk) in minter.iter_mut().zip(data[..32].chunks_exact(4)) { + *word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!())); + } + Some(minter) +} + +/// Reads a wrapped-token balance from account data; empty data is a zero balance. +#[must_use] +pub fn read_balance(data: &[u8]) -> u128 { + if data.len() < 16 { + return 0; + } + u128::from_le_bytes(data[..16].try_into().unwrap_or_else(|_| unreachable!())) +} + +#[must_use] +pub const fn balance_bytes(amount: u128) -> [u8; 16] { + amount.to_le_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn minter_round_trips() { + let minter: ProgramId = [1, 2, 3, 4, 5, 6, 7, 8]; + assert_eq!(read_minter(&minter_bytes(minter)), Some(minter)); + } + + #[test] + fn balance_round_trips() { + assert_eq!(read_balance(&balance_bytes(42)), 42); + assert_eq!(read_balance(&[]), 0); + } + + #[test] + fn holding_is_unique_per_recipient() { + let id: ProgramId = [9; 8]; + assert_ne!( + holding_account_id(id, &[1; 32]), + holding_account_id(id, &[2; 32]) + ); + assert_eq!( + holding_account_id(id, &[1; 32]), + holding_account_id(id, &[1; 32]) + ); + } +} diff --git a/lez/programs/wrapped_token/src/main.rs b/lez/programs/wrapped_token/src/main.rs new file mode 100644 index 00000000..19095e39 --- /dev/null +++ b/lez/programs/wrapped_token/src/main.rs @@ -0,0 +1,152 @@ +use lee_core::{ + account::{Account, AccountWithMetadata}, + program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, +}; +use wrapped_token_core::{ + Instruction, balance_bytes, config_account_id, config_seed, holding_account_id, holding_seed, + minter_bytes, read_balance, read_minter, +}; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_lee_inputs::(); + + match instruction { + Instruction::Mint { recipient, amount } => mint( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + recipient, + amount, + ), + Instruction::InitConfig { minter } => init_config( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + minter, + ), + } +} + +fn mint( + self_program_id: lee_core::program::ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + recipient: [u8; 32], + amount: u128, +) { + // pre_states: [config PDA, recipient holding PDA]. + let [config, holding] = <[AccountWithMetadata; 2]>::try_from(pre_states) + .expect("Mint requires the config and recipient holding accounts"); + + // The config PDA is genesis-seeded with the authorized minter (the cross-zone + // inbox). Pin the caller to it, since the guest cannot import the inbox id. + assert_eq!( + config.account_id, + config_account_id(self_program_id), + "first account must be the wrapped-token config PDA" + ); + let minter = read_minter(&config.account.data.clone().into_inner()) + .expect("config account holds an authorized minter id"); + assert_eq!( + caller_program_id, + Some(minter), + "Mint is only callable by the authorized minter (the cross-zone inbox)" + ); + + assert_eq!( + holding.account_id, + holding_account_id(self_program_id, &recipient), + "second account must be the recipient holding PDA" + ); + + let new_balance = read_balance(&holding.account.data.clone().into_inner()) + .checked_add(amount) + .expect("wrapped-token balance overflow"); + let mut holding_account = holding.account.clone(); + holding_account.data = balance_bytes(new_balance) + .to_vec() + .try_into() + .expect("balance fits in account data"); + let holding_post = AccountPostState::new_claimed_if_default( + holding_account, + Claim::Pda(holding_seed(&recipient)), + ); + let config_post = AccountPostState::new(config.account.clone()); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![config, holding], + vec![config_post, holding_post], + ) + .write(); +} + +/// Writes the authorized minter into the config PDA exactly once at genesis. +fn init_config( + self_program_id: lee_core::program::ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + minter: lee_core::program::ProgramId, +) { + assert!( + caller_program_id.is_none(), + "InitConfig is a top-level genesis transaction" + ); + + // pre_states: [config PDA]. + let [config] = <[AccountWithMetadata; 1]>::try_from(pre_states) + .expect("InitConfig requires the config account"); + assert_eq!( + config.account_id, + config_account_id(self_program_id), + "account must be the wrapped-token config PDA" + ); + // Init-once, idempotent under genesis replay: a `default` config is a first + // init; an already-owned config must already hold exactly this minter (the + // genesis block is replayed onto seeded state during multi-sequencer + // reconstruction), otherwise reject a post-genesis attempt to set a different + // minter. `new_claimed_if_default` alone would not stop the owning program from + // rewriting its own config data on a later call. + if config.account != Account::default() { + assert_eq!( + config.account.program_owner, self_program_id, + "wrapped-token config PDA is owned by another program" + ); + assert_eq!( + config.account.data.clone().into_inner(), + minter_bytes(minter).to_vec(), + "wrapped-token config already initialized with a different minter" + ); + } + + let mut config_account = config.account.clone(); + config_account.data = minter_bytes(minter) + .to_vec() + .try_into() + .expect("minter id fits in account data"); + let config_post = + AccountPostState::new_claimed_if_default(config_account, Claim::Pda(config_seed())); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![config], + vec![config_post], + ) + .write(); +} diff --git a/lez/sequencer/core/Cargo.toml b/lez/sequencer/core/Cargo.toml index 2931c833..64b154f4 100644 --- a/lez/sequencer/core/Cargo.toml +++ b/lez/sequencer/core/Cargo.toml @@ -10,6 +10,7 @@ workspace = true [dependencies] lee.workspace = true lee_core.workspace = true +chain_state.workspace = true common.workspace = true storage.workspace = true mempool.workspace = true @@ -20,9 +21,12 @@ bridge_core.workspace = true vault_core.workspace = true programs.workspace = true system_accounts.workspace = true +cross_zone.workspace = true +cross_zone_inbox_core.workspace = true logos-blockchain-key-management-system-service.workspace = true logos-blockchain-core.workspace = true +logos-blockchain-http-api-common.workspace = true anyhow.workspace = true serde.workspace = true serde_json.workspace = true @@ -31,6 +35,7 @@ tempfile.workspace = true chrono.workspace = true log.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tokio-util.workspace = true rand.workspace = true borsh.workspace = true bytesize.workspace = true @@ -39,6 +44,7 @@ url.workspace = true num-bigint.workspace = true risc0-zkvm.workspace = true futures.workspace = true +itertools.workspace = true [features] default = [] @@ -52,3 +58,4 @@ test_programs.workspace = true lee = { workspace = true, features = ["test-utils"] } key_protocol.workspace = true token_core.workspace = true +ping_core.workspace = true diff --git a/lez/sequencer/core/src/block_publisher.rs b/lez/sequencer/core/src/block_publisher.rs index 21551131..e78f860a 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -1,85 +1,178 @@ -use std::{pin::Pin, sync::Arc, time::Duration}; +use std::time::Duration; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Context as _, Result, anyhow, ensure}; use common::block::Block; +use futures::Stream; use log::{info, warn}; -pub use logos_blockchain_core::mantle::ops::channel::MsgId; -use logos_blockchain_core::mantle::ops::channel::{ChannelId, inscribe::Inscription}; -pub use logos_blockchain_key_management_system_service::keys::{Ed25519Key, ZkKey}; +pub use logos_blockchain_core::mantle::{ + ledger::NoteId, + ops::channel::{Ed25519PublicKey, MsgId}, +}; +use logos_blockchain_core::{ + mantle::{ + SignedMantleTx, + channel::{SlotTimeframe, SlotTimeout}, + gas::GasCost, + ops::{ + Op, OpProof, + channel::{ + ChannelId, + config::{ChannelConfigOp, Keys}, + inscribe::Inscription, + }, + }, + traits::Hashable as _, + transactions::{MantleTxBuilder, OpsProofs}, + }, + proofs::channel_multi_sig_proof::{ChannelMultiSigProof, IndexedSignature}, +}; +use logos_blockchain_http_api_common::bodies::wallet::fund::WalletFundRequestBody; +pub use logos_blockchain_key_management_system_service::keys::{ + ED25519_SECRET_KEY_SIZE, Ed25519Key, ZkKey, +}; pub use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint; use logos_blockchain_zone_sdk::{ - CommonHttpClient, - adapter::NodeHttpClient, + CommonHttpClient, Slot, ZoneMessage, + adapter::{Node as _, NodeHttpClient}, + indexer::ZoneIndexer, sequencer::{ - DepositInfo, Event, FinalizedOp, InscriptionInfo, - SequencerConfig as ZoneSdkSequencerConfig, WithdrawArg, WithdrawInfo, ZoneSequencer, + ChannelUpdateTx, DepositInfo, Event, FinalizedOp, FundingConfig, InscriptionInfo, + PendingTx, SequencerConfig as ZoneSdkSequencerConfig, TurnNotification, WithdrawArg, + WithdrawInfo, ZoneSequencer, }, }; -use tokio::{sync::mpsc, task::JoinHandle}; +use tokio::sync::{mpsc, oneshot, watch}; +use tokio_util::sync::CancellationToken; -use crate::config::BedrockConfig; +use crate::{config::BedrockConfig, task_group::TaskGroup}; /// Channel capacity for the publish inbox. One publish per produced block, drained /// in microseconds by the drive task — 32 is huge headroom and just provides /// backpressure if the drive task stalls (reconnect, long backfill). const PUBLISH_INBOX_CAPACITY: usize = 32; -/// Sink for `Event::Published` checkpoints emitted by the drive task. -/// Caller is responsible for persistence (e.g. writing to rocksdb). -pub type CheckpointSink = Box; +/// Everything one `Event::BlocksProcessed` carries, with inscription payloads +/// decoded into `(MsgId, Block)` pairs. +/// +/// One struct rather than a sink per effect, because the `checkpoint` and +/// everything it covers must reach the store in a single write. +pub struct FollowUpdate { + /// Resume cursor for this event. Persist only together with the effects + /// below, never ahead of them. + pub checkpoint: SequencerCheckpoint, + /// Inscriptions newly on the followed L1 branch, in channel order: they + /// extend (or, after a reorg, replace part of) the `head` tier. + pub adopted: Vec<(MsgId, Block)>, + /// Inscriptions dropped from the branch by an L1 reorg: their blocks are + /// reverted from the `head` and their user txs resubmitted to the mempool. + pub orphaned: Vec<(MsgId, Block)>, + /// Inscriptions whose containing L1 block reached finality: their blocks + /// move into the irreversible `final` tier. + pub finalized: Vec<(MsgId, Block)>, + /// Finalized Bedrock deposit events, to record and mint on L2. + pub deposits: Vec, + /// Finalized Bedrock withdraw events, to reconcile against local intents. + pub withdrawals: Vec, +} -/// Sink for finalized L2 block ids derived from `Event::TxsFinalized` and -/// `Event::FinalizedInscriptions`. Caller is responsible for cleanup -/// (e.g. marking pending blocks as finalized in storage). -pub type FinalizedBlockSink = Box; +/// Sink for the follow path: apply the channel delta to chain state and +/// persist the whole event in one write. +pub type OnFollowSink = Box; -/// Sink for finalized Bedrock deposit events. -pub type OnDepositEventSink = - Box Pin + Send>> + Send + 'static>; +/// What one publish produced. +pub struct PublishOutcome { + /// The `MsgId` zone-sdk assigned the published inscription. + pub this_msg: MsgId, + /// The checkpoint that now holds the inscription as pending. + pub checkpoint: SequencerCheckpoint, + /// Channel notes the bundled withdrawals release, empty for a plain + /// publish. + /// A [`ChannelWithdrawOp`](logos_blockchain_core::mantle::ops::channel::withdraw::ChannelWithdrawOp) + /// carries nothing but the note ids it releases, so these are the only + /// handle the local withdraw intent shares with the Bedrock Withdraw event + /// that later reports it. + pub released_notes: Vec, +} -/// Sink for finalized Bedrock withdraw events. -pub type OnWithdrawEventSink = - Box Pin + Send>> + Send + 'static>; +/// Commands the drive task executes with `&mut sequencer`. +enum Command { + /// Publish an inscription (+ atomic withdrawals); responds with the + /// [`PublishOutcome`]. + Publish { + inscription: Inscription, + withdrawals: Vec, + resp: oneshot::Sender>, + }, +} + +type CommandSender = mpsc::Sender; #[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")] -pub trait BlockPublisherTrait: Clone { - #[expect( - clippy::too_many_arguments, - reason = "Looks better than bundling all those callbacks into a struct" - )] +pub trait BlockPublisherTrait: Sized { async fn new( config: &BedrockConfig, bedrock_signing_key: Ed25519Key, resubmit_interval: Duration, initial_checkpoint: Option, - on_checkpoint: CheckpointSink, - on_finalized_block: FinalizedBlockSink, - on_deposit_event: OnDepositEventSink, - on_withdraw_event: OnWithdrawEventSink, + on_follow: OnFollowSink, ) -> Result; - /// Fire-and-forget publish. Zone-sdk drives the actual submission and - /// retries internally; this just hands the payload off. - async fn publish_block(&self, block: &Block, withdrawals: Vec) -> Result<()>; + /// Publish a block and return what zone-sdk made of it. Zone-sdk drives the + /// actual submission and retries internally. + /// + /// The checkpoint must be persisted with the block — restoring an older one + /// drops the inscription from the pending set, and it is never resubmitted. + async fn publish_block( + &self, + block: &Block, + withdrawals: Vec, + ) -> Result; fn channel_id(&self) -> ChannelId; + + /// Whether this sequencer is currently authorized to write to the channel. + fn is_our_turn(&self) -> bool; + + /// A [`CancellationToken`] cancelled when the publisher's background driver + /// terminates (a panicked sink, an ended event stream). No channel events + /// are processed past that point, so the node must halt. + fn driver_cancellation(&self) -> CancellationToken; + + /// The publisher's background tasks, for a caller that needs to know when + /// they have actually stopped. Its sinks capture a store handle, so the + /// `RocksDB` lock outlives the sequencer until the drive task is gone. + /// Empty by default, for publishers that run no tasks. + fn background_tasks(&self) -> TaskGroup { + TaskGroup::default() + } + + /// Current channel frontier slot on the connected chain, or `None` if the + /// channel does not exist there. Drives the startup frontier check. + async fn channel_tip_slot(&self) -> Result>; + + /// Finalized channel messages from `after_slot` (exclusive) up to LIB, used + /// for the startup consistency check and reconstruction. Pass `None` to read + /// from the channel's genesis. + async fn read_channel_after( + &self, + after_slot: Option, + ) -> Result + '_>; } /// Real block publisher backed by zone-sdk's `ZoneSequencer`. -#[derive(Clone)] pub struct ZoneSdkPublisher { channel_id: ChannelId, - publish_tx: mpsc::Sender<(Inscription, Vec)>, - // Aborts the drive task when the last clone is dropped. - _drive_task: Arc, -} - -struct DriveTaskGuard(JoinHandle<()>); - -impl Drop for DriveTaskGuard { - fn drop(&mut self) { - self.0.abort(); - } + /// Direct node handle retained for channel reads (startup consistency check + /// and reconstruction); the sequencer itself lives in the drive task. + node: NodeHttpClient, + command_tx: CommandSender, + turn_rx: watch::Receiver, + // Cancelled when the drive task ends for any reason, including a panic. + driver_cancellation: CancellationToken, + // Stops the drive task when the last clone is dropped, and lets a shutdown + // path wait until it has actually stopped. + drive_task: TaskGroup, + indexer: ZoneIndexer, } impl BlockPublisherTrait for ZoneSdkPublisher { @@ -88,23 +181,25 @@ impl BlockPublisherTrait for ZoneSdkPublisher { bedrock_signing_key: Ed25519Key, resubmit_interval: Duration, initial_checkpoint: Option, - on_checkpoint: CheckpointSink, - on_finalized_block: FinalizedBlockSink, - on_deposit_event: OnDepositEventSink, - on_withdraw_event: OnWithdrawEventSink, + on_follow: OnFollowSink, ) -> Result { let basic_auth = config.auth.clone().map(Into::into); let node = NodeHttpClient::new(CommonHttpClient::new(basic_auth), config.node_url.clone()); let zone_sdk_config = ZoneSdkSequencerConfig { resubmit_interval, + funding: Some(FundingConfig { + funding_pk: config.funding_key, + max_tx_fee: GasCost::new(logos_blockchain_core::mantle::Value::MAX), + priority_fee: FundingConfig::DEFAULT_PRIORITY_FEE, + }), ..ZoneSdkSequencerConfig::default() }; let mut sequencer = ZoneSequencer::init_with_config( config.channel_id, bedrock_signing_key, - node, + node.clone(), zone_sdk_config, initial_checkpoint, ); @@ -112,11 +207,18 @@ impl BlockPublisherTrait for ZoneSdkPublisher { // Grab readiness receiver before moving the sequencer into the drive // task so we can await cold-start completion below. let mut ready_rx = sequencer.subscribe_ready(); + // Grab the turn watch before the move; the sdk actor keeps it current. + let turn_rx = sequencer.subscribe_turn_to_write(); - let (publish_tx, mut publish_rx) = - mpsc::channel::<(Inscription, Vec)>(PUBLISH_INBOX_CAPACITY); + let (command_tx, mut command_rx): (CommandSender, _) = + mpsc::channel(PUBLISH_INBOX_CAPACITY); + let driver_cancellation = CancellationToken::new(); + let driver_guard = driver_cancellation.clone().drop_guard(); let drive_task = tokio::spawn(async move { + // Dropped when this task ends (including panics in the sinks), + // cancelling every `driver_cancellation`. + let _driver_guard = driver_guard; loop { #[expect( clippy::integer_division_remainder_used, @@ -124,62 +226,105 @@ impl BlockPublisherTrait for ZoneSdkPublisher { )] { tokio::select! { - // Drain external publish requests by calling the - // borrowing handle — `&mut sequencer` is only - // available here. - Some((data_bounded, withdrawals)) = publish_rx.recv() => { - let data_byte_size = data_bounded.len(); - if withdrawals.is_empty() { - if let Err(e) = sequencer.handle() - .publish(data_bounded) - .context("Failed to publish block") { - warn!("zone-sdk publish failed: {e:?}"); - } - - info!("Published block with the size of {data_byte_size} bytes"); - } else { + // Drain external commands by calling the borrowing + // handle — `&mut sequencer` is only available here. + Some(command) = command_rx.recv() => match command { + Command::Publish { inscription: data_bounded, withdrawals, resp: resp_tx } => { + let data_byte_size = data_bounded.len(); let withdraw_count = withdrawals.len(); - if let Err(e) = sequencer.handle() - .publish_atomic_withdraw(data_bounded, withdrawals) - .context("Failed to publish block with withdrawals") { - warn!("zone-sdk publish failed: {e:?}"); - } + let published = if withdrawals.is_empty() { + sequencer.handle() + .publish(data_bounded) + .await + .context("Failed to publish block") + } else { + sequencer.handle() + .publish_atomic_withdraw(data_bounded, withdrawals) + .await + .context("Failed to publish block with withdrawals") + }; - info!( - "Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals", - ); + let msg_result = published.map(|(result, checkpoint)| PublishOutcome { + this_msg: result.tx.inscription().this_msg, + checkpoint, + released_notes: released_notes(&result.tx), + }); + match &msg_result { + Ok(_) if withdraw_count == 0 => { + info!("Published block with the size of {data_byte_size} bytes"); + } + Ok(_) => { + info!( + "Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals", + ); + } + Err(e) => warn!("zone-sdk publish failed: {e:?}"), + } + let _dontcare = resp_tx.send(msg_result); } - } + }, event = sequencer.next_event() => { - let Some(event) = event else { - continue; - }; match event { Event::BlocksProcessed { checkpoint, - channel_update: _, + channel_update, finalized, } => { - on_checkpoint(checkpoint); + let adopted = channel_update + .adopted + .iter() + .filter_map(channel_update_inscription) + .filter_map(block_from_inscription) + .collect(); + let orphaned = channel_update + .orphaned + .iter() + .filter_map(channel_update_inscription) + .filter_map(block_from_inscription) + .collect(); + + let mut finalized_blocks = Vec::new(); + let mut deposits = Vec::new(); + let mut withdrawals = Vec::new(); for op in finalized.into_iter().flat_map(|item| item.ops) { match op { FinalizedOp::Inscription(inscription) => { - if let Some(block_id) = - block_id_from_inscription(&inscription) + if let Some(entry) = + block_from_inscription(&inscription) { - on_finalized_block(block_id); + finalized_blocks.push(entry); } } - FinalizedOp::Deposit(deposit) => { - on_deposit_event(deposit).await; - } + FinalizedOp::Deposit(deposit) => deposits.push(deposit), FinalizedOp::Withdraw(withdraw) => { - on_withdraw_event(withdraw).await; + withdrawals.push(withdraw); } } } + + // Nothing is awaited here: an await in this + // arm blocks the same task `publish_block` + // needs, and a non-turn sequencer never + // drains what it would be waiting on. + on_follow(FollowUpdate { + checkpoint, + adopted, + orphaned, + finalized: finalized_blocks, + deposits, + withdrawals, + }); } - Event::Ready | Event::TurnNotification { .. } => {} + Event::Ready => {} + Event::TurnNotification { notification } => { + info!( + "Turn update: our_turn={}, starting_slot={:?}, ends_at_slot={:?}", + notification.our_turn_to_write, + notification.starting_slot, + notification.ends_at_slot + ); + } + Event::MempoolPending(_tx_hash) => {} } } } @@ -196,37 +341,198 @@ impl BlockPublisherTrait for ZoneSdkPublisher { Ok(Self { channel_id: config.channel_id, - publish_tx, - _drive_task: Arc::new(DriveTaskGuard(drive_task)), + indexer: ZoneIndexer::new(config.channel_id, node.clone()), + node, + command_tx, + turn_rx, + driver_cancellation, + drive_task: TaskGroup::new(vec![drive_task]), }) } - async fn publish_block(&self, block: &Block, withdrawals: Vec) -> Result<()> { + async fn publish_block( + &self, + block: &Block, + withdrawals: Vec, + ) -> Result { let data = borsh::to_vec(block).context("Failed to serialize block")?; let data_bounded: Inscription = data .try_into() .context("Block data exceeds maximum allowed size")?; - self.publish_tx - .send((data_bounded, withdrawals)) + let (resp_tx, resp_rx) = oneshot::channel(); + self.command_tx + .send(Command::Publish { + inscription: data_bounded, + withdrawals, + resp: resp_tx, + }) .await .map_err(|_closed| anyhow!("Drive task is no longer running"))?; - Ok(()) + resp_rx + .await + .map_err(|_closed| anyhow!("Drive task dropped the publish response"))? } fn channel_id(&self) -> ChannelId { self.channel_id } + + fn is_our_turn(&self) -> bool { + self.turn_rx.borrow().our_turn_to_write + } + + fn driver_cancellation(&self) -> CancellationToken { + self.driver_cancellation.clone() + } + + fn background_tasks(&self) -> TaskGroup { + self.drive_task.clone() + } + + async fn channel_tip_slot(&self) -> Result> { + Ok(self + .node + .channel_state(self.channel_id) + .await + .context("Failed to read channel state")? + .map(|state| state.tip_slot)) + } + + async fn read_channel_after( + &self, + after_slot: Option, + ) -> Result + '_> { + let stream = self + .indexer + .next_messages(after_slot) + .await + .context("Failed to start channel read stream")?; + Ok(stream) + } } -/// Deserialize inscription payload as a `Block` and return it's`block_id`. -/// Bad payloads are logged and skipped. -fn block_id_from_inscription(inscription: &InscriptionInfo) -> Option { +/// Deserialize an inscription payload into `(this_msg, Block)`. Bad payloads are +/// logged and skipped. +fn block_from_inscription(inscription: &InscriptionInfo) -> Option<(MsgId, Block)> { borsh::from_slice::(&inscription.payload) .inspect_err(|err| { warn!("Failed to deserialize block from inscription: {err:?}"); }) .ok() - .map(|block| block.header.block_id) + .map(|block| (inscription.this_msg, block)) +} + +/// Channel notes the withdraws bundled with a published tx release; empty for a +/// plain inscription. See [`PublishOutcome::released_notes`]. +fn released_notes(tx: &PendingTx) -> Vec { + match tx { + PendingTx::Inscription(_) => Vec::new(), + PendingTx::AtomicWithdraw(bundle) => bundle + .withdraws + .iter() + .flat_map(|withdraw| withdraw.op.inputs.iter().copied()) + .collect(), + } +} + +/// The inscription carried by an orphaned tx (plain or atomic-withdraw bundle). +const fn channel_update_inscription(orphan: &ChannelUpdateTx) -> Option<&InscriptionInfo> { + match orphan { + ChannelUpdateTx::Inscription(info) => Some(info), + ChannelUpdateTx::AtomicWithdraw(bundle) => Some(&bundle.inscription), + ChannelUpdateTx::Custom(_signed_mantle_tx) => None, + } +} + +/// Signs a `ChannelConfig` op (accredited keys + rotation params) with +/// `signing_key`, funds it from `config.funding_key` via the node's wallet, +/// and posts it straight to the bedrock node. +/// +/// A standalone one-shot — no running sequencer involved, so authorization is +/// holding the admin key: the L1 rejects non-admin signers. `Ok(())` means the +/// node accepted the transaction; channel acceptance is asynchronous and a +/// rejection only shows up in node logs and on-chain behavior. +pub async fn post_channel_config( + config: &BedrockConfig, + signing_key: &Ed25519Key, + keys: Vec, + posting_timeframe: u32, + posting_timeout: u32, + configuration_threshold: u16, + transfer_threshold: u16, +) -> Result<()> { + ensure!(!keys.is_empty(), "Channel key list must not be empty"); + for (name, threshold) in [ + ("configuration_threshold", configuration_threshold), + ("transfer_threshold", transfer_threshold), + ] { + ensure!( + threshold >= 1 && usize::from(threshold) <= keys.len(), + "{name} must be between 1 and the key count ({}), got {threshold}", + keys.len() + ); + } + ensure!( + posting_timeframe > 0 && posting_timeout >= posting_timeframe, + "posting_timeframe must be nonzero and posting_timeout at least as long, \ + got {posting_timeframe} and {posting_timeout}" + ); + + let keys = Keys::try_from(keys).map_err(|err| anyhow!("Invalid channel key list: {err}"))?; + let config_op = ChannelConfigOp { + channel: config.channel_id, + keys, + posting_timeframe: SlotTimeframe::from(posting_timeframe), + posting_timeout: SlotTimeout::from(posting_timeout), + configuration_threshold, + transfer_threshold, + }; + + let node = NodeHttpClient::new( + CommonHttpClient::new(config.auth.clone().map(Into::into)), + config.node_url.clone(), + ); + + // Fund the op from the node's wallet: the node appends a fee transfer + // (paid from `funding_key`, change back to it) and returns its proof. + let tx_builder = MantleTxBuilder::new() + .extend_ops([Op::ChannelConfig(config_op)]) + .map_err(|err| anyhow!("Too many ops in channel config transaction: {err:?}"))?; + let funded = node + .fund_tx(WalletFundRequestBody { + tip: None, + tx_builder, + change_public_key: config.funding_key, + funding_public_keys: vec![config.funding_key], + max_tx_fee: GasCost::new(logos_blockchain_core::mantle::Value::MAX), + priority_fee: FundingConfig::DEFAULT_PRIORITY_FEE, + }) + .await + .context("Failed to fund channel config transaction")?; + let mantle_tx = funded.funded_tx; + + // Sign the funded tx: the appended fee transfer changes the hash. + let tx_hash = mantle_tx.hash(); + // The admin key is `keys[0]`, hence signature index 0. + let signature = IndexedSignature::new( + 0, + signing_key.sign_payload(tx_hash.as_signing_bytes().as_ref()), + ); + let proof = ChannelMultiSigProof::try_new(signature.into()) + .map_err(|err| anyhow!("Failed to assemble channel multi-sig proof: {err:?}"))?; + + // Proofs follow op order; funding appends the transfer as the last op. + let mut ops_proofs: OpsProofs = OpProof::ChannelMultiSigProof(proof).into(); + if let Some(transfer_proof) = funded.transfer_proof { + ops_proofs + .try_push(transfer_proof) + .map_err(|err| anyhow!("Too many operation proofs: {err:?}"))?; + } + let signed_tx = SignedMantleTx::new(mantle_tx, ops_proofs); + + node.post_transaction(signed_tx) + .await + .context("Failed to post channel config transaction") } diff --git a/lez/sequencer/core/src/block_store.rs b/lez/sequencer/core/src/block_store.rs index 0db05d74..df61b38b 100644 --- a/lez/sequencer/core/src/block_store.rs +++ b/lez/sequencer/core/src/block_store.rs @@ -7,18 +7,21 @@ use common::{ transaction::LeeTransaction, }; use lee::V03State; +use lee_core::BlockId; use log::info; -use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint; -pub use storage::DbResult; +use logos_blockchain_zone_sdk::{Slot, sequencer::SequencerCheckpoint}; use storage::sequencer::{ RocksDBIO, - sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey}, + sequencer_cells::{ + PeerZoneKey, PendingDepositEventRecord, WithdrawalReconciliationKey, ZoneAnchorRecord, + }, }; +pub use storage::{DbResult, sequencer::DbDump}; pub struct SequencerStore { dbio: Arc, // TODO: Consider adding the hashmap to the database for faster recovery. - tx_hash_to_block_map: HashMap, + tx_hash_to_block_map: HashMap, genesis_id: u64, signing_key: lee::PrivateKey, } @@ -27,29 +30,17 @@ impl SequencerStore { /// Open existing database at the given location. Fails if no database is found. pub fn open_db(location: &Path, signing_key: lee::PrivateKey) -> DbResult { let dbio = Arc::new(RocksDBIO::open(location)?); - let genesis_id = dbio.get_meta_first_block_in_db()?; - let last_id = dbio.latest_block_meta()?.id; + Self::from_dbio_and_signing_key(dbio, signing_key) + } - info!("Preparing block cache"); - let mut tx_hash_to_block_map = HashMap::new(); - for i in genesis_id..=last_id { - let block = dbio - .get_block(i)? - .expect("Block should be present in the database"); - - tx_hash_to_block_map.extend(block_to_transactions_map(&block)); - } - info!( - "Block cache prepared. Total blocks in cache: {}", - tx_hash_to_block_map.len() - ); - - Ok(Self { - dbio, - tx_hash_to_block_map, - genesis_id, - signing_key, - }) + /// Create a fresh rocksdb at `location` from `dump`. + pub fn restore_db_from_dump( + location: &Path, + dump: &DbDump, + signing_key: lee::PrivateKey, + ) -> DbResult { + let dbio = Arc::new(RocksDBIO::restore_from_dump(location, dump)?); + Self::from_dbio_and_signing_key(dbio, signing_key) } /// Starting database at the start of new chain. @@ -74,6 +65,38 @@ impl SequencerStore { }) } + fn from_dbio_and_signing_key( + dbio: Arc, + signing_key: lee::PrivateKey, + ) -> DbResult { + let genesis_id = dbio.get_meta_first_block_in_db()?; + let last_id = dbio.latest_block_meta()?.map(|meta| meta.id); + + let mut tx_hash_to_block_map = HashMap::new(); + + if let Some(last_id) = last_id { + info!("Preparing block cache"); + for i in genesis_id..=last_id { + let block = dbio + .get_block(i)? + .expect("Block should be present in the database"); + + tx_hash_to_block_map.extend(block_to_transactions_map(&block)); + } + info!( + "Block cache prepared. Total blocks in cache: {}", + tx_hash_to_block_map.len() + ); + } + + Ok(Self { + dbio, + tx_hash_to_block_map, + genesis_id, + signing_key, + }) + } + /// Shared handle to the underlying rocksdb. Used to persist the zone-sdk /// checkpoint from the sequencer's drive task without needing &mut to the /// store. @@ -96,7 +119,7 @@ impl SequencerStore { /// Returns the transaction corresponding to the given hash, if it exists in the blockchain. #[must_use] - pub fn get_transaction_by_hash(&self, hash: HashType) -> Option { + pub fn get_transaction_by_hash(&self, hash: HashType) -> Option<(LeeTransaction, BlockId)> { let block_id = *self.tx_hash_to_block_map.get(&hash)?; let block = self .get_block_at_id(block_id) @@ -105,7 +128,7 @@ impl SequencerStore { .expect("Block should be present since the hash is in the map"); for transaction in block.body.transactions { if transaction.hash() == hash { - return Some(transaction); + return Some((transaction, block_id)); } } panic!( @@ -113,7 +136,7 @@ impl SequencerStore { ); } - pub fn latest_block_meta(&self) -> DbResult { + pub fn latest_block_meta(&self) -> DbResult> { self.dbio.latest_block_meta() } @@ -134,13 +157,13 @@ impl SequencerStore { pub(crate) fn update( &mut self, block: &Block, - deposit_event_ids: &[HashType], - withdrawals: Vec, + withdrawals: &[WithdrawalReconciliationKey], state: &V03State, + checkpoint: Option<&[u8]>, ) -> DbResult<()> { let new_transactions_map = block_to_transactions_map(block); self.dbio - .atomic_update(block, deposit_event_ids, withdrawals, state)?; + .atomic_update(block, withdrawals, state, checkpoint)?; self.tx_hash_to_block_map.extend(new_transactions_map); Ok(()) } @@ -149,6 +172,21 @@ impl SequencerStore { self.dbio.get_lee_state() } + /// Remove the persisted zone-sdk checkpoint so the next startup is treated as a fresh start. + pub fn delete_zone_checkpoint(&self) -> DbResult<()> { + self.dbio.delete_zone_sdk_checkpoint_bytes() + } + + /// Reset every stored block to `Pending` so the next fresh start republishes the whole chain. + pub fn reset_all_blocks_to_pending(&self) -> DbResult<()> { + self.dbio.reset_all_blocks_to_pending() + } + + /// Single-blob [`DbDump`] of the whole store; restore with [`Self::restore_db_from_dump`]. + pub fn dump(&self) -> DbResult { + self.dbio.dump_all() + } + pub fn get_zone_checkpoint(&self) -> Result> { let Some(bytes) = self.dbio.get_zone_sdk_checkpoint_bytes()? else { return Ok(None); @@ -158,18 +196,36 @@ impl SequencerStore { Ok(Some(checkpoint)) } + /// Persists `checkpoint` on its own. Only valid when the effects it covers + /// are already durable — otherwise it must ride in the same write as them, + /// via [`storage::sequencer::StoreUpdate`]. pub fn set_zone_checkpoint(&self, checkpoint: &SequencerCheckpoint) -> Result<()> { - let bytes = - serde_json::to_vec(checkpoint).context("Failed to serialize zone-sdk checkpoint")?; - self.dbio.put_zone_sdk_checkpoint_bytes(&bytes)?; + self.dbio + .put_zone_sdk_checkpoint_bytes(&checkpoint_bytes(checkpoint)?)?; Ok(()) } - pub fn get_unfulfilled_deposit_events(&self) -> DbResult> { + /// The last channel block read back and verified from Bedrock (L1 slot + + /// `id`/`hash`), or `None` before any block has been read from the channel. + pub fn get_zone_anchor(&self) -> DbResult> { + self.dbio.get_zone_anchor() + } + + pub fn set_zone_anchor(&self, anchor: &ZoneAnchorRecord) -> DbResult<()> { + self.dbio.put_zone_anchor(anchor) + } + + pub fn get_pending_deposit_events(&self) -> DbResult> { self.dbio.get_pending_deposit_events() } } +/// The checkpoint's on-disk encoding. `serde_json` because `SequencerCheckpoint` +/// derives serde but not borsh; paired with `get_zone_checkpoint`'s decode. +pub(crate) fn checkpoint_bytes(checkpoint: &SequencerCheckpoint) -> Result> { + serde_json::to_vec(checkpoint).context("Failed to serialize zone-sdk checkpoint") +} + pub(crate) fn block_to_transactions_map(block: &Block) -> HashMap { block .body @@ -179,10 +235,38 @@ pub(crate) fn block_to_transactions_map(block: &Block) -> HashMap .collect() } +/// A cross-zone watcher's delivery floor on `peer_zone`'s channel. +/// +/// The highest slot every message of which was delivered, or `None` before it +/// has delivered anything from that peer. Stored as a little-endian `u64`. +/// +/// Free functions rather than only [`SequencerStore`] methods because each +/// watcher runs as its own spawned task and holds an `Arc`; +/// `SequencerStore` is not `Clone`. +pub fn get_cross_zone_peer_floor(dbio: &RocksDBIO, peer_zone: PeerZoneKey) -> Result> { + let Some(bytes) = dbio.get_cross_zone_peer_floor_bytes(peer_zone)? else { + return Ok(None); + }; + let bytes: [u8; 8] = bytes.as_slice().try_into().with_context(|| { + format!( + "Stored cross-zone peer floor is {} bytes, expected 8", + bytes.len() + ) + })?; + Ok(Some(Slot::new(u64::from_le_bytes(bytes)))) +} + +pub fn set_cross_zone_peer_floor( + dbio: &RocksDBIO, + peer_zone: PeerZoneKey, + floor: Slot, +) -> Result<()> { + dbio.put_cross_zone_peer_floor_bytes(peer_zone, &floor.to_le_bytes())?; + Ok(()) +} + #[cfg(test)] mod tests { - #![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")] - use common::{block::HashableBlockData, test_utils::sequencer_sign_key_for_testing}; use tempfile::tempdir; @@ -220,12 +304,10 @@ mod tests { assert_eq!(None, retrieved_tx); // Add the block with the transaction let dummy_state = V03State::new(); - node_store - .update(&block, &[], vec![], &dummy_state) - .unwrap(); + node_store.update(&block, &[], &dummy_state, None).unwrap(); // Try again - let retrieved_tx = node_store.get_transaction_by_hash(tx.hash()); - assert_eq!(Some(tx), retrieved_tx); + let output = node_store.get_transaction_by_hash(tx.hash()); + assert_eq!(Some((tx, 1)), output); } #[test] @@ -254,7 +336,7 @@ mod tests { .unwrap(); // Verify that initially the latest block hash equals genesis hash - let latest_meta = node_store.latest_block_meta().unwrap(); + let latest_meta = node_store.latest_block_meta().unwrap().unwrap(); assert_eq!(latest_meta.hash, genesis_hash); } @@ -287,12 +369,10 @@ mod tests { let block_hash = block.header.hash; let dummy_state = V03State::new(); - node_store - .update(&block, &[], vec![], &dummy_state) - .unwrap(); + node_store.update(&block, &[], &dummy_state, None).unwrap(); // Verify that the latest block meta now equals the new block's hash - let latest_meta = node_store.latest_block_meta().unwrap(); + let latest_meta = node_store.latest_block_meta().unwrap().unwrap(); assert_eq!(latest_meta.hash, block_hash); } @@ -325,9 +405,7 @@ mod tests { let block_id = block.header.block_id; let dummy_state = V03State::new(); - node_store - .update(&block, &[], vec![], &dummy_state) - .unwrap(); + node_store.update(&block, &[], &dummy_state, None).unwrap(); // Verify initial status is Pending let retrieved_block = node_store.get_block_at_id(block_id).unwrap().unwrap(); @@ -376,14 +454,14 @@ mod tests { // Add a new block let block = common::test_utils::produce_dummy_block(1, None, vec![tx.clone()]); node_store - .update(&block, &[], vec![], &V03State::new()) + .update(&block, &[], &V03State::new(), None) .unwrap(); } // Re-open the store and verify that the transaction is still retrievable (which means it // was cached correctly) let node_store = SequencerStore::open_db(path, signing_key).unwrap(); - let retrieved_tx = node_store.get_transaction_by_hash(tx.hash()); - assert_eq!(Some(tx), retrieved_tx); + let output = node_store.get_transaction_by_hash(tx.hash()); + assert_eq!(Some((tx, 1)), output); } } diff --git a/lez/sequencer/core/src/config.rs b/lez/sequencer/core/src/config.rs index b445bcd5..aff0ae48 100644 --- a/lez/sequencer/core/src/config.rs +++ b/lez/sequencer/core/src/config.rs @@ -8,9 +8,11 @@ use std::{ use anyhow::Result; use bytesize::ByteSize; use common::config::BasicAuth; +pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer}; use humantime_serde; -use lee::AccountId; +use lee::{AccountId, Balance}; use logos_blockchain_core::mantle::ops::channel::ChannelId; +use logos_blockchain_key_management_system_service::keys::ZkPublicKey; use serde::{Deserialize, Serialize}; use url::Url; @@ -20,10 +22,15 @@ use url::Url; pub enum GenesisAction { SupplyAccount { account_id: AccountId, - balance: u128, + balance: Balance, }, SupplyBridgeAccount { - balance: u128, + balance: Balance, + }, + /// Seeds a bridge-lock holder's initial bridgeable balance into genesis state. + SupplyBridgeLockHolding { + holder: AccountId, + amount: Balance, }, } @@ -53,6 +60,9 @@ pub struct SequencerConfig { /// Genesis configuration. #[serde(default)] pub genesis: Vec, + /// Cross-zone messaging configuration. `None` disables the watcher. + #[serde(default)] + pub cross_zone: Option, } #[derive(Clone, Serialize, Deserialize)] @@ -63,6 +73,7 @@ pub struct BedrockConfig { pub node_url: Url, /// Bedrock auth. pub auth: Option, + pub funding_key: ZkPublicKey, } impl SequencerConfig { diff --git a/lez/sequencer/core/src/cross_zone_watcher.rs b/lez/sequencer/core/src/cross_zone_watcher.rs new file mode 100644 index 00000000..e3ca8ba3 --- /dev/null +++ b/lez/sequencer/core/src/cross_zone_watcher.rs @@ -0,0 +1,1045 @@ +use std::{sync::Arc, time::Duration}; + +use common::{block::Block, transaction::LeeTransaction}; +use cross_zone::{build_dispatch_from_emission, extract_emission}; +use cross_zone_inbox_core::message_key; +use futures::{Stream, StreamExt as _}; +use lee::PublicKey; +use lee_core::program::ProgramId; +use log::{debug, error, info, warn}; +use logos_blockchain_core::mantle::ops::channel::ChannelId; +use logos_blockchain_zone_sdk::{ + CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, +}; +use storage::sequencer::{RocksDBIO, sequencer_cells::PendingCrossZoneDispatchRecord}; + +use crate::{ + block_store::{get_cross_zone_peer_floor, set_cross_zone_peer_floor}, + config::{BedrockConfig, CrossZoneConfig}, + task_group::TaskGroup, +}; + +/// Consecutive passes a watcher re-reads the same undecodable slot before giving +/// up and reading past it. +/// +/// One pass per poll interval, which is the block time, so this is minutes of +/// retrying rather than seconds. A transient failure (a truncated read, a peer +/// mid-upgrade) heals well inside that; a block this node genuinely cannot +/// decode does not heal at all, and waiting longer only delays every later +/// message behind it. +const DECODE_RETRY_LIMIT: u32 = 20; + +/// The per-peer settings one watcher pass needs. +struct PeerContext { + peer_zone: [u8; 32], + self_zone: [u8; 32], + allowed_targets: Vec, + expected_pubkey: Option, +} + +/// What a pass may do about a slot the watcher cannot decode, and whether it may +/// still move the durable delivery floor. +/// +/// The two are one decision, not two. Past a skipped slot everything is +/// delivered on top of a gap, and persisting past that gap would make the skip +/// survive restarts, so the floor has to stop moving and stay stopped. Holding +/// them in one value is what makes "skipping while still persisting", which +/// would quietly restore that bug, unrepresentable. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum SkipPolicy { + /// Nothing has been given up on: deliver everything and move the floor. + #[default] + DeliverAll, + /// Read past this slot, and stop moving the floor. + Skipping(Slot), + /// A slot was skipped earlier in this run. Nothing is being skipped now, but + /// everything read from here sits above the gap, so the floor stays put. + FloorFrozen, +} + +/// Why one pass over a peer's stream ended. +/// +/// A pass that gave up inside a slot says which kind of failure did it. Only a +/// block this node cannot decode is a reason to eventually read past a slot; +/// a delivery that could not be recorded or handed off is our own problem, and +/// counting it towards the decode budget would read past a slot that is fine. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PassOutcome { + /// The stream drained. + Drained, + /// Ended inside this slot: its block would not deserialize. + Undecodable(Slot), + /// Ended inside this slot: a delivery could not be recorded or enqueued. + Undelivered(Slot), +} + +/// The pass-to-pass state of one watcher: what it is stuck on, and what it is +/// allowed to do about it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct WatcherState { + /// The slot the watcher is stuck on and how many passes it has spent there. + /// Keyed by slot so a failure at a new slot does not inherit an older + /// slot's count. + stalled: Option<(Slot, u32)>, + skip: SkipPolicy, +} + +impl SkipPolicy { + /// The slot this pass reads past rather than stalling on. + const fn skip_slot(self) -> Option { + match self { + Self::Skipping(slot) => Some(slot), + Self::DeliverAll | Self::FloorFrozen => None, + } + } + + /// Whether this pass may still move the durable delivery floor. + const fn persists_floor(self) -> bool { + matches!(self, Self::DeliverAll) + } + + /// The policy once a pass has read past whatever it was stuck on. + /// + /// Nothing is being skipped any more, but a run that has skipped once keeps + /// its floor frozen: everything from here sits above the gap, and moving the + /// floor over it would make the skip survive a restart. Deliberately not + /// named for clearing: it downgrades, it does not reset. + const fn after_clean_pass(self) -> Self { + match self { + Self::DeliverAll => Self::DeliverAll, + Self::Skipping(_) | Self::FloorFrozen => Self::FloorFrozen, + } + } + + /// Whether a pass that ended at `cursor` actually got past the slot this + /// policy is skipping. + /// + /// A stream can end without reaching it: the zone-sdk ends a stream on a + /// fetch failure exactly as on catching up. Downgrading on such a pass would + /// disarm the skip before it was ever used, and the slot would have to be + /// given up on again from scratch, so a peer endpoint that is flaky around + /// one bad slot would never be read past. + fn used_its_skip(self, cursor: Option) -> bool { + match self { + Self::Skipping(slot) => cursor.is_some_and(|read_to| read_to >= slot), + Self::DeliverAll | Self::FloorFrozen => true, + } + } +} + +impl WatcherState { + /// Folds one pass's outcome in, returning a slot the watcher has just given + /// up on so the caller can report it. + /// + /// `cursor` is the read position after the pass. It is what tells a stream + /// that truncated early apart from one that genuinely drained: the zone-sdk + /// ends a stream on a fetch failure exactly as it does on catching up, so + /// without this a flaky peer endpoint would reset the retry count for ever + /// and the watcher would never escape a slot it cannot decode. + fn after_pass(&mut self, outcome: PassOutcome, cursor: Option) -> Option { + match outcome { + PassOutcome::Undecodable(slot) => { + let attempts = match self.stalled { + Some((stuck_on, attempts)) if stuck_on == slot => attempts.saturating_add(1), + _ => 1, + }; + if attempts < DECODE_RETRY_LIMIT { + self.stalled = Some((slot, attempts)); + return None; + } + // Set before the pass that reads past the bad slot, so the + // stored floor stays below it. + self.stalled = None; + self.skip = SkipPolicy::Skipping(slot); + Some(slot) + } + // Ours to fix, not the peer's: retry the slot without spending the + // decode budget on it, or a store outage would read past good blocks. + PassOutcome::Undelivered(_) => None, + PassOutcome::Drained => { + if self.passed_the_stall(cursor) { + self.stalled = None; + } + // Checked against the skip's own slot, not against `stalled`, + // which arming a skip clears. Otherwise the first truncated + // stream after arming would downgrade the skip before it had + // read past anything. + if self.skip.used_its_skip(cursor) { + self.skip = self.skip.after_clean_pass(); + } + None + } + } + } + + /// Whether the read position is now past whatever the watcher was stuck on. + /// Vacuously true when it was not stuck. + fn passed_the_stall(self, cursor: Option) -> bool { + self.stalled + .is_none_or(|(stuck_on, _)| cursor.is_some_and(|read_to| read_to >= stuck_on)) + } +} + +/// Spawns one watcher task per configured peer. +/// +/// Each task reads the peer's finalized blocks from Bedrock, recognizes outbound +/// messages addressed to this zone, and records the matching inbox dispatch in +/// the store. Delivering it is block production's job, which drains those +/// records every turn. +/// +/// The returned group must be kept alive for as long as the watchers should +/// run; dropping it stops them, and awaiting +/// [`TaskGroup::shutdown`](crate::task_group::TaskGroup::shutdown) is what +/// proves they have stopped. Each watcher holds an `Arc`, so a +/// watcher still running keeps the `RocksDB` lock held and a restarting +/// sequencer cannot reopen its home directory. +#[must_use] +pub fn spawn_watchers( + bedrock_config: &BedrockConfig, + cross_zone: &CrossZoneConfig, + poll_interval: Duration, + dbio: &Arc, +) -> TaskGroup { + let self_zone: [u8; 32] = *bedrock_config.channel_id.as_ref(); + let mut tasks = Vec::new(); + + for peer in cross_zone.peers.clone() { + let node = NodeHttpClient::new( + CommonHttpClient::new(bedrock_config.auth.clone().map(Into::into)), + bedrock_config.node_url.clone(), + ); + let expected_pubkey = peer.expected_block_signing_pubkey.map(|bytes| { + PublicKey::try_new(bytes).expect("configured peer block-signing pubkey is a valid key") + }); + tasks.push(tokio::spawn(watch_peer( + ZoneIndexer::new(ChannelId::from(peer.channel_id), node), + PeerContext { + peer_zone: peer.channel_id, + self_zone, + allowed_targets: peer.allowed_targets, + expected_pubkey, + }, + poll_interval, + Arc::clone(dbio), + ))); + } + + TaskGroup::new(tasks) +} + +#[expect( + clippy::infinite_loop, + reason = "the peer watcher runs for the lifetime of the sequencer process" +)] +async fn watch_peer( + zone_indexer: ZoneIndexer, + peer: PeerContext, + poll_interval: Duration, + dbio: Arc, +) { + let peer_zone = peer.peer_zone; + info!( + "Cross-zone watcher started for peer {}", + hex::encode(peer_zone) + ); + + // Resume from the delivery floor: the highest slot every message of which + // was decoded and recorded. Re-reading a peer channel is safe (the dispatch + // key is content-addressed and the inbox no-ops a replay) but re-records + // every already-delivered message, so without this a restart replayed the + // peer's whole history into the store. + let mut cursor = match get_cross_zone_peer_floor(&dbio, peer_zone) { + Ok(floor) => floor, + Err(err) => { + // Falling back to `None` would re-read the peer's whole history and + // re-inject every message it ever delivered. Stopping is the smaller + // failure, and a stopped watcher shows up as unhealthy. + error!( + "Watcher failed to load the stored delivery floor for peer {}: {err:#}. Stopping this watcher rather than re-reading the channel from the beginning.", + hex::encode(peer_zone) + ); + return; + } + }; + if let Some(slot) = cursor { + info!( + "Resuming watcher for peer {} from slot {slot:?}", + hex::encode(peer_zone) + ); + } + + // The slot the watcher is stuck on and how many passes it has spent there, + // and the slot it has given up on. Keyed by slot so a failure at a new slot + // does not inherit an older slot's count. Both stay in memory: a skip must + // not outlive the process, or a peer whose blocks this build cannot decode + // would be skipped past for good and its messages never delivered, even + // after the decoder is fixed. + let mut state = WatcherState::default(); + loop { + let stream = match zone_indexer.next_messages(cursor).await { + Ok(stream) => stream, + Err(err) => { + error!( + "Watcher next_messages failed for peer {}: {err}", + hex::encode(peer_zone) + ); + tokio::time::sleep(poll_interval).await; + continue; + } + }; + let outcome = consume_peer_stream(stream, &peer, &dbio, &mut cursor, state.skip).await; + + if let Some(slot) = state.after_pass(outcome, cursor) { + error!( + "Watcher for peer {} could not decode slot {slot:?} after {DECODE_RETRY_LIMIT} attempts; reading past it. Messages in that block are undelivered until this node can decode it, and the delivery floor stops advancing, so every restart re-reads from {:?} onwards.", + hex::encode(peer_zone), + get_cross_zone_peer_floor(&dbio, peer_zone).ok().flatten() + ); + } + + // Stream ended (caught up to the peer's last finalized block); poll again. + tokio::time::sleep(poll_interval).await; + } +} + +/// Delivers the peer blocks carried by `stream`, moving `cursor` as it goes and +/// persisting the delivery floor behind it. Says why the pass ended, since only +/// a block this node cannot decode counts towards [`DECODE_RETRY_LIMIT`]. +/// +/// A block that fails to deserialize ends the pass without advancing, so the +/// next poll re-reads it and a transient failure heals. [`SkipPolicy`] names a +/// slot the caller gave up on after [`DECODE_RETRY_LIMIT`] attempts, which is +/// read past so a permanently undecodable inscription cannot wedge the watcher, +/// and says whether the floor may still move: past a skipped slot it may not, +/// because the floor is what a restart resumes from and the skipped messages +/// have to stay reachable. +async fn consume_peer_stream( + stream: S, + peer: &PeerContext, + dbio: &RocksDBIO, + cursor: &mut Option, + skip: SkipPolicy, +) -> PassOutcome +where + S: Stream, +{ + let mut stream = std::pin::pin!(stream); + // The slot being consumed: every message of it seen so far is handled, but + // there may be more to come, so the cursor may not advance onto it yet. + let mut in_progress: Option = None; + + while let Some((msg, slot)) = stream.next().await { + if in_progress != Some(slot) { + // A message from a later slot means the previous one completed. + if let Some(done) = in_progress { + advance_cursor(dbio, peer.peer_zone, cursor, done, skip.persists_floor()); + } + in_progress = Some(slot); + } + + let zone_block = match msg { + ZoneMessage::Block(block) => block, + ZoneMessage::Deposit(_) | ZoneMessage::Withdraw(_) => continue, + }; + match borsh::from_slice::(&zone_block.data) { + Ok(block) => { + debug!( + "Watcher observed finalized peer {} block {}", + hex::encode(peer.peer_zone), + block.header.block_id + ); + // Reject blocks not signed by the pinned peer key (equivocation): + // the channel signer is authenticated by the zone-sdk, but that + // does not prove the peer's honest sequencer produced the block. + if peer + .expected_pubkey + .as_ref() + .is_some_and(|pk| !block.is_signed_by(pk)) + { + warn!( + "Watcher dropping peer {} block {}: block-signing key does not match the pinned key", + hex::encode(peer.peer_zone), + block.header.block_id + ); + continue; + } + + if !record_block_deliveries(&block, peer, dbio) { + // Recording a delivery is what makes it survive the mempool. + // Letting the pass finish here would move the floor past this + // slot on a store that just refused the write, and nothing + // re-reads a slot below the floor. + error!( + "Watcher could not record every delivery in peer {} block {}. Holding the floor and retrying the slot.", + hex::encode(peer.peer_zone), + block.header.block_id + ); + return PassOutcome::Undelivered(slot); + } + } + Err(err) if skip.skip_slot() == Some(slot) => { + debug!( + "Watcher skipping undecodable peer {} block at slot {slot:?}: {err}", + hex::encode(peer.peer_zone) + ); + } + Err(err) => { + error!( + "Watcher failed to deserialize peer {} block at slot {slot:?}: {err}. Holding the cursor and retrying.", + hex::encode(peer.peer_zone) + ); + return PassOutcome::Undecodable(slot); + } + } + } + + // The stream drained cleanly, so the slot in progress completed too. + if let Some(done) = in_progress { + advance_cursor(dbio, peer.peer_zone, cursor, done, skip.persists_floor()); + } + PassOutcome::Drained +} + +/// Moves the in-memory read cursor past `slot`, and the durable delivery floor +/// with it while `persist_floor` holds. +/// +/// A persist failure is only logged: the worst case is re-reading from the last +/// stored slot after a restart, which delivery handles idempotently. +fn advance_cursor( + dbio: &RocksDBIO, + peer_zone: [u8; 32], + cursor: &mut Option, + slot: Slot, + persist_floor: bool, +) { + *cursor = Some(slot); + if !persist_floor { + return; + } + if let Err(err) = set_cross_zone_peer_floor(dbio, peer_zone, slot) { + warn!( + "Failed to persist watcher delivery floor for peer {}: {err:#}", + hex::encode(peer_zone) + ); + } +} + +/// Scans one peer block for outbound messages and records a dispatch per match. +/// +/// Returns `false` if a delivery could not be recorded, which the caller turns +/// into a stall: the record is the only thing standing between a durable read +/// position and a lost message. +fn record_block_deliveries(block: &Block, peer: &PeerContext, dbio: &RocksDBIO) -> bool { + let peer_zone = peer.peer_zone; + let self_zone = peer.self_zone; + let allowed_targets = peer.allowed_targets.as_slice(); + // Collected and written once. The pending list is a single value, so a write + // per delivery would rewrite the whole list once per message, which is + // quadratic in a peer block that carries many of them, on a task holding the + // lock block production needs. + let mut deliveries = Vec::new(); + for (index, tx) in block.body.transactions.iter().enumerate() { + let LeeTransaction::Public(public_tx) = tx else { + continue; + }; + let message = public_tx.message(); + let Some(emission) = extract_emission(message.program_id, &message.instruction_data) else { + continue; + }; + + if emission.target_zone != self_zone { + continue; + } + if !allowed_targets.contains(&emission.target_program_id) { + warn!( + "Watcher dropping message to disallowed target from peer {}", + hex::encode(peer_zone) + ); + continue; + } + + let src_tx_index = u32::try_from(index).unwrap_or(u32::MAX); + let dispatch = build_dispatch_from_emission( + peer_zone, + block.header.block_id, + src_tx_index, + message.program_id, + emission.target_program_id, + &emission.target_accounts, + emission.payload, + ); + let dispatch = LeeTransaction::Public(dispatch); + + // Recording is the delivery. The floor is durable, so once it advances + // this peer block is never re-read; the record is what block production + // drains on its next turn, and what a restart still has. It is dropped + // when the delivery itself becomes irreversible. + let key = message_key(&peer_zone, block.header.block_id, src_tx_index); + let encoded = match borsh::to_vec(&dispatch) { + Ok(encoded) => encoded, + Err(err) => { + error!( + "Failed to encode cross-zone dispatch {}: {err}", + hex::encode(key) + ); + return false; + } + }; + deliveries.push(PendingCrossZoneDispatchRecord::recorded(key, encoded)); + } + + let offered = deliveries.len(); + match dbio.add_pending_cross_zone_dispatches(deliveries) { + // Fewer accepted than offered means the rest were recorded by an earlier + // pass over the same slot, which the retry loop does up to + // [`DECODE_RETRY_LIMIT`] times. + Ok(accepted) => { + if accepted > 0 { + info!( + "Watcher recorded {accepted} of {offered} cross-zone deliveries from peer {} block {}", + hex::encode(peer_zone), + block.header.block_id + ); + } else { + debug!( + "Watcher already held every cross-zone delivery in peer {} block {}", + hex::encode(peer_zone), + block.header.block_id + ); + } + true + } + // Includes the pending list being full, which is why this holds the + // floor rather than dropping the block: the slot stays re-readable and + // the peer's messages wait instead of being lost. + Err(err) => { + error!( + "Failed to record the {offered} cross-zone deliveries in peer {} block {}: {err}", + hex::encode(peer_zone), + block.header.block_id + ); + false + } + } +} + +#[cfg(test)] +mod tests { + use common::test_utils::produce_dummy_block; + use futures::stream; + use lee::{ + PublicTransaction, + public_transaction::{Message, WitnessSet}, + }; + use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription}; + use logos_blockchain_zone_sdk::ZoneBlock; + use ping_core::{SenderInstruction, ping_record_pda}; + use storage::sequencer::{DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY, RocksDBIO}; + use tempfile::TempDir; + + use super::*; + + const SELF_ZONE: [u8; 32] = [1; 32]; + const PEER_ZONE: [u8; 32] = [2; 32]; + + fn peer_context() -> PeerContext { + PeerContext { + peer_zone: PEER_ZONE, + self_zone: SELF_ZONE, + allowed_targets: vec![programs::ping_receiver().id()], + expected_pubkey: None, + } + } + + /// A store backed by a temp dir. The dir is returned so it outlives the db. + fn store() -> (TempDir, RocksDBIO) { + let dir = tempfile::tempdir().expect("temp dir"); + let genesis = produce_dummy_block(0, None, vec![]); + let dbio = RocksDBIO::create(dir.path(), &genesis, &lee::V03State::new()).expect("db"); + (dir, dbio) + } + + /// A `ping_sender` emission addressed to `SELF_ZONE`. + fn emission() -> LeeTransaction { + let receiver_id = programs::ping_receiver().id(); + let send = SenderInstruction::Send { + outbox_program_id: programs::cross_zone_outbox().id(), + target_zone: SELF_ZONE, + target_program_id: receiver_id, + target_accounts: vec![ping_record_pda(receiver_id).into_value()], + payload: b"hi".to_vec(), + ordinal: 0, + }; + let message = Message::try_new(programs::ping_sender().id(), vec![], vec![], send) + .expect("emission serializes"); + LeeTransaction::Public(PublicTransaction::new( + message, + WitnessSet::from_raw_parts(vec![]), + )) + } + + fn peer_msg(data: Vec, slot: u64) -> (ZoneMessage, Slot) { + ( + ZoneMessage::Block(ZoneBlock { + id: MsgId::from([0; 32]), + data: Inscription::try_from(data).expect("test inscription is within bounds"), + }), + Slot::from(slot), + ) + } + + /// A stream item carrying block `block_id` with one emission for this zone. + fn peer_block_msg(block_id: u64, slot: u64) -> (ZoneMessage, Slot) { + let block = produce_dummy_block(block_id, None, vec![emission()]); + peer_msg(borsh::to_vec(&block).expect("block serializes"), slot) + } + + fn undecodable_msg(slot: u64) -> (ZoneMessage, Slot) { + peer_msg(b"not a block".to_vec(), slot) + } + + /// The message keys recorded so far, in insertion order. + fn recorded_keys(dbio: &RocksDBIO) -> Vec<[u8; 32]> { + dbio.get_pending_cross_zone_dispatches() + .expect("pending dispatches readable") + .into_iter() + .map(|record| record.message_key) + .collect() + } + + /// Makes every later pending-dispatch read fail, standing in for any store + /// failure between reading a peer block and the delivery being durable. + /// Recording reads the list before it writes it, so a value that will not + /// decode is enough. + fn break_the_dispatch_store(dbio: &RocksDBIO) { + let cf = dbio + .db + .cf_handle(storage::CF_META_NAME) + .expect("meta column family"); + let key = borsh::to_vec(&DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY).expect("key encodes"); + dbio.db + .put_cf(&cf, key, b"not a pending dispatch list") + .expect("write"); + } + + /// Drives the state machine over a sequence of pass outcomes, with the read + /// position after each, and returns the state it lands in. + fn run_passes(passes: &[(PassOutcome, Option)]) -> WatcherState { + let mut state = WatcherState::default(); + for (outcome, cursor) in passes { + state.after_pass(*outcome, cursor.map(Slot::from)); + } + state + } + + fn retry_limit() -> usize { + usize::try_from(DECODE_RETRY_LIMIT).expect("retry limit fits in usize") + } + + fn stall(slot: u64, cursor: Option) -> (PassOutcome, Option) { + (PassOutcome::Undecodable(Slot::from(slot)), cursor) + } + + #[test] + fn a_slot_is_skipped_only_after_the_retry_limit() { + let limit = retry_limit(); + let almost = vec![stall(4, Some(3)); limit - 1]; + assert_eq!( + run_passes(&almost).skip, + SkipPolicy::DeliverAll, + "a slot must not be given up on before the limit" + ); + + let enough = vec![stall(4, Some(3)); limit]; + assert_eq!( + run_passes(&enough).skip, + SkipPolicy::Skipping(Slot::from(4)) + ); + } + + #[test] + fn the_floor_stays_frozen_for_the_rest_of_the_run_after_a_skip() { + // Twenty failures at slot 4, then the pass that reads past it, then + // clean passes: the floor must never be persistable again, or the skip + // survives the next restart and those messages are gone for good. + let mut passes = vec![stall(4, Some(3)); retry_limit()]; + passes.push((PassOutcome::Drained, Some(9))); + passes.push((PassOutcome::Drained, Some(12))); + let state = run_passes(&passes); + + assert_eq!(state.skip, SkipPolicy::FloorFrozen); + assert!(!state.skip.persists_floor()); + assert_eq!(state.stalled, None); + } + + #[test] + fn a_stream_that_ended_before_the_stalled_slot_does_not_reset_the_count() { + // The zone-sdk ends a stream on a fetch failure exactly as it does on + // catching up. Treating that as a clean pass would reset the retry count + // for ever, and the watcher would never escape a slot it cannot decode. + let mut passes = vec![stall(4, Some(3)); 5]; + passes.push((PassOutcome::Drained, Some(3))); + let state = run_passes(&passes); + assert_eq!( + state.stalled, + Some((Slot::from(4), 5)), + "the count survives a pass that never reached the stalled slot" + ); + + // Reading past it is what actually clears the stall. + let mut read_past = vec![stall(4, Some(3)); 5]; + read_past.push((PassOutcome::Drained, Some(7))); + assert_eq!(run_passes(&read_past).stalled, None); + } + + #[test] + fn a_failed_handoff_does_not_spend_the_decode_budget() { + // A store or mempool failure is ours, not the peer's. Counting it here + // would read past a block that decodes perfectly well. + let passes = vec![(PassOutcome::Undelivered(Slot::from(4)), Some(3)); retry_limit() * 2]; + let state = run_passes(&passes); + assert_eq!(state.skip, SkipPolicy::DeliverAll); + assert_eq!(state.stalled, None); + } + + #[test] + fn a_truncated_pass_does_not_disarm_a_skip_before_it_is_used() { + // Arming a skip clears `stalled`, so a `Drained` pass that never reached + // the bad slot passes the stall check vacuously. Downgrading on that + // would disarm the skip before it read past anything, and the slot would + // have to be given up on again from scratch, so a peer endpoint that is + // flaky around one bad slot would never be read past. + let mut passes = vec![stall(4, Some(3)); retry_limit()]; + passes.push((PassOutcome::Drained, Some(3))); + let state = run_passes(&passes); + assert_eq!( + state.skip, + SkipPolicy::Skipping(Slot::from(4)), + "a pass that ended before the skipped slot must leave the skip armed" + ); + + // The pass that actually gets past it is what downgrades. + let mut used = vec![stall(4, Some(3)); retry_limit()]; + used.push((PassOutcome::Drained, Some(7))); + assert_eq!(run_passes(&used).skip, SkipPolicy::FloorFrozen); + } + + #[test] + fn a_stall_at_a_new_slot_starts_its_own_count() { + let passes = vec![stall(4, Some(3)), stall(4, Some(3)), stall(9, Some(8))]; + assert_eq!(run_passes(&passes).stalled, Some((Slot::from(9), 1))); + } + + #[test] + fn a_run_that_skipped_once_never_moves_its_floor_again() { + // The state that makes a skip recoverable: after the bad slot is read + // past, later passes decode cleanly, and the floor still must not move + // over the gap or the skip survives the next restart. + assert_eq!( + SkipPolicy::Skipping(Slot::from(4)).after_clean_pass(), + SkipPolicy::FloorFrozen + ); + assert_eq!( + SkipPolicy::FloorFrozen.after_clean_pass(), + SkipPolicy::FloorFrozen + ); + assert!(!SkipPolicy::FloorFrozen.persists_floor()); + assert_eq!(SkipPolicy::FloorFrozen.skip_slot(), None); + + // A run that has never skipped keeps moving. + assert_eq!( + SkipPolicy::DeliverAll.after_clean_pass(), + SkipPolicy::DeliverAll + ); + assert!(SkipPolicy::DeliverAll.persists_floor()); + } + + #[tokio::test] + async fn watcher_persists_its_cursor_as_it_consumes() { + let (_dir, dbio) = store(); + let mut cursor = None; + + let outcome = consume_peer_stream( + stream::iter(vec![peer_block_msg(1, 0), peer_block_msg(2, 1)]), + &peer_context(), + &dbio, + &mut cursor, + SkipPolicy::DeliverAll, + ) + .await; + + assert_eq!(outcome, PassOutcome::Drained); + assert_eq!(cursor, Some(Slot::from(1))); + assert_eq!( + get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(), + Some(Slot::from(1)), + "the cursor must be durable, not just in memory" + ); + assert_eq!( + recorded_keys(&dbio), + vec![message_key(&PEER_ZONE, 1, 0), message_key(&PEER_ZONE, 2, 0)] + ); + } + + #[tokio::test] + async fn watcher_records_every_delivery_it_reads() { + let (_dir, dbio) = store(); + let mut cursor = None; + + consume_peer_stream( + stream::iter(vec![peer_block_msg(1, 0)]), + &peer_context(), + &dbio, + &mut cursor, + SkipPolicy::DeliverAll, + ) + .await; + + // The read cursor is durable, so once it advances this peer block is + // never re-read. The record is the whole of what survives that: block + // production drains it, and it outlives a restart. It is dropped when + // the delivery itself becomes irreversible, not when it is included. + let records = dbio.get_pending_cross_zone_dispatches().unwrap(); + assert_eq!(records.len(), 1, "the delivery must be recorded"); + assert_eq!( + records[0].message_key, + message_key(&PEER_ZONE, 1, 0), + "the record is keyed by the message it delivers, so a replay is not double-tracked" + ); + assert!( + borsh::from_slice::(&records[0].transaction).is_ok(), + "the recorded bytes must decode, or the drain silently skips them" + ); + assert_eq!( + records[0].failed_attempts, 0, + "a delivery that has never been attempted starts with a clean count" + ); + } + + #[tokio::test] + async fn a_delivery_that_cannot_be_recorded_holds_the_floor() { + let (_dir, dbio) = store(); + break_the_dispatch_store(&dbio); + let mut cursor = None; + + let outcome = consume_peer_stream( + stream::iter(vec![peer_block_msg(1, 0)]), + &peer_context(), + &dbio, + &mut cursor, + SkipPolicy::DeliverAll, + ) + .await; + + // The floor is durable and nothing re-reads a slot below it, so a pass + // that failed to record must not let it move, or the delivery is lost + // rather than retried. + assert_eq!(outcome, PassOutcome::Undelivered(Slot::from(0))); + assert_eq!( + get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(), + None, + "the slot must stay re-readable" + ); + } + + #[tokio::test] + async fn watcher_resumes_from_the_persisted_cursor_without_rereading() { + let (_dir, dbio) = store(); + let mut cursor = None; + + consume_peer_stream( + stream::iter(vec![peer_block_msg(1, 0), peer_block_msg(2, 1)]), + &peer_context(), + &dbio, + &mut cursor, + SkipPolicy::DeliverAll, + ) + .await; + assert_eq!(recorded_keys(&dbio).len(), 2); + + // Restart: a fresh watcher seeds its cursor from the store rather than + // starting at `None`, which is what stops it re-reading the peer channel + // from genesis. + let resumed = get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(); + assert_eq!(resumed, Some(Slot::from(1))); + + // The sdk resumes the stream at cursor + 1, so only block 3 arrives. + let mut resumed_cursor = resumed; + consume_peer_stream( + stream::iter(vec![peer_block_msg(3, 2)]), + &peer_context(), + &dbio, + &mut resumed_cursor, + SkipPolicy::DeliverAll, + ) + .await; + + assert_eq!( + recorded_keys(&dbio), + vec![ + message_key(&PEER_ZONE, 1, 0), + message_key(&PEER_ZONE, 2, 0), + message_key(&PEER_ZONE, 3, 0) + ], + "only the unread block is recorded on the second pass" + ); + assert_eq!( + get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(), + Some(Slot::from(2)) + ); + } + + #[tokio::test] + async fn watcher_does_not_persist_past_an_undecodable_block() { + let (_dir, dbio) = store(); + let mut cursor = None; + + let outcome = consume_peer_stream( + stream::iter(vec![ + peer_block_msg(1, 0), + undecodable_msg(1), + peer_block_msg(3, 2), + ]), + &peer_context(), + &dbio, + &mut cursor, + SkipPolicy::DeliverAll, + ) + .await; + + // A durable cursor makes this load-bearing: advancing past the bad block + // would drop its messages permanently rather than until the next restart. + assert_eq!(outcome, PassOutcome::Undecodable(Slot::from(1))); + assert_eq!( + get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(), + Some(Slot::from(0)) + ); + assert_eq!( + recorded_keys(&dbio), + vec![message_key(&PEER_ZONE, 1, 0)], + "the block after the failure is unread" + ); + } + + #[tokio::test] + async fn watcher_does_not_persist_inside_a_partially_failed_slot() { + // One slot can carry several messages. Persisting after each message + // would store a cursor the retry resumes past, so the message that + // failed is never re-read and its delivery is lost for good. + let (_dir, dbio) = store(); + let mut cursor = None; + + let outcome = consume_peer_stream( + stream::iter(vec![peer_block_msg(1, 4), undecodable_msg(4)]), + &peer_context(), + &dbio, + &mut cursor, + SkipPolicy::DeliverAll, + ) + .await; + + assert_eq!(outcome, PassOutcome::Undecodable(Slot::from(4))); + assert_eq!(cursor, None, "slot 4 is re-read whole on the next pass"); + assert_eq!(get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(), None); + assert_eq!(recorded_keys(&dbio), vec![message_key(&PEER_ZONE, 1, 0)]); + } + + #[tokio::test] + async fn watcher_reads_past_a_slot_it_has_given_up_on() { + let (_dir, dbio) = store(); + let mut cursor = None; + + let outcome = consume_peer_stream( + stream::iter(vec![ + peer_block_msg(1, 0), + undecodable_msg(1), + peer_block_msg(3, 2), + ]), + &peer_context(), + &dbio, + &mut cursor, + SkipPolicy::Skipping(Slot::from(1)), + ) + .await; + + assert_eq!(outcome, PassOutcome::Drained, "the pass drains"); + assert_eq!( + recorded_keys(&dbio), + vec![message_key(&PEER_ZONE, 1, 0), message_key(&PEER_ZONE, 3, 0)], + "only the skipped block goes unrecorded" + ); + + // The cursor moves so later blocks are still read, but the durable floor + // does not follow it past the gap. + assert_eq!( + cursor, + Some(Slot::from(2)), + "the pass keeps reading forward" + ); + assert_eq!( + get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(), + None, + "the floor must not move past a slot this node could not decode" + ); + } + + #[tokio::test] + async fn a_restart_re_reads_a_skipped_slot() { + let (_dir, dbio) = store(); + let mut cursor = None; + + // Slot 0 is recorded, slot 1 is undecodable and eventually skipped, slot + // 2 is recorded on top of the gap. + consume_peer_stream( + stream::iter(vec![peer_block_msg(1, 0)]), + &peer_context(), + &dbio, + &mut cursor, + SkipPolicy::DeliverAll, + ) + .await; + consume_peer_stream( + stream::iter(vec![undecodable_msg(1), peer_block_msg(3, 2)]), + &peer_context(), + &dbio, + &mut cursor, + SkipPolicy::Skipping(Slot::from(1)), + ) + .await; + assert_eq!(recorded_keys(&dbio).len(), 2); + + // A fresh watcher seeds from the floor, so slot 1 comes back around + // rather than being skipped for the life of the store. That is what + // makes a decoder fix recover the messages instead of a store reset. + let resumed = get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(); + assert_eq!(resumed, Some(Slot::from(0))); + + let mut resumed_cursor = resumed; + consume_peer_stream( + stream::iter(vec![peer_block_msg(2, 1), peer_block_msg(3, 2)]), + &peer_context(), + &dbio, + &mut resumed_cursor, + SkipPolicy::DeliverAll, + ) + .await; + + // Three records, not four: the block at slot 2 was recorded on the + // earlier pass and the re-read does not double-track it, while the block + // at slot 1, skipped before, is recorded for the first time. + assert_eq!( + recorded_keys(&dbio), + vec![ + message_key(&PEER_ZONE, 1, 0), + message_key(&PEER_ZONE, 3, 0), + message_key(&PEER_ZONE, 2, 0) + ], + "the previously skipped block must be recorded after a restart, and nothing re-recorded" + ); + assert_eq!( + get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(), + Some(Slot::from(2)), + "with the gap filled the floor moves again" + ); + } +} diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 35c0c33b..34ca24a5 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -1,41 +1,78 @@ -use std::{path::Path, sync::Arc, time::Instant}; +use std::{ + collections::VecDeque, + path::Path, + sync::{Arc, Mutex}, + time::Instant, +}; use anyhow::{Context as _, Result, anyhow}; use borsh::BorshDeserialize; +use chain_state::{ + AcceptOutcome, Anchor, AnchorConsistencyCheck, ChainConsistency, ChainMismatch, ChainState, Tip, +}; use common::{ HashType, - block::{BedrockStatus, Block, HashableBlockData}, + block::{BedrockStatus, Block, BlockMeta, HashableBlockData}, transaction::{LeeTransaction, clock_invocation}, }; use config::{GenesisAction, SequencerConfig}; +use cross_zone_inbox_core::CrossZoneMessage; +use futures::StreamExt as _; +use itertools::Itertools as _; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::GENESIS_BLOCK_ID; use log::{error, info, warn}; use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key}; -use logos_blockchain_zone_sdk::sequencer::{DepositInfo, WithdrawArg}; +use logos_blockchain_zone_sdk::{ + Slot, ZoneMessage, + sequencer::{DepositInfo, WithdrawArg}, +}; use mempool::{MemPool, MemPoolHandle}; #[cfg(feature = "mock")] pub use mock::SequencerCoreWithMockClients; use num_bigint::BigUint; pub use storage::error::DbError; use storage::sequencer::{ - RocksDBIO, - sequencer_cells::{PendingDepositEventRecord, WithdrawalReconciliationKey}, + RocksDBIO, StoreUpdate, + sequencer_cells::{ + PendingCrossZoneDispatchRecord, PendingDepositEventRecord, WithdrawalReconciliationKey, + ZoneAnchorRecord, + }, }; use crate::{ - block_publisher::{BlockPublisherTrait, ZoneSdkPublisher}, + block_publisher::{BlockPublisherTrait, MsgId, NoteId, ZoneSdkPublisher}, block_store::SequencerStore, + task_group::{StoreRelease, TaskGroup}, }; pub mod block_publisher; pub mod block_store; pub mod config; +pub mod cross_zone_watcher; #[cfg(feature = "mock")] pub mod mock; +pub mod task_group; + +/// Failed production attempts before a cross-zone dispatch is given up on. +/// +/// One attempt per block, so this is tens of seconds of retrying. Enough for a +/// failure that is not the message's fault to clear, short enough that a message +/// which will never execute stops being retried. +const RETIRE_DISPATCH_AFTER_FAILURES: u32 = 3; + +/// Cross-zone deliveries one block may carry. +/// +/// Each one costs a guest execution whether it succeeds or fails, and what +/// queues them up is chosen by peer zones. Without a bound, a backlog decides +/// how long a block takes to build and leaves no room for user transactions, +/// since store-drained work is taken before the mempool. The rest wait one +/// block; nothing is dropped. +const MAX_DISPATCHES_PER_BLOCK: usize = 16; /// The origin of a transaction. +#[derive(Clone, Copy)] pub enum TransactionOrigin { /// Basic transactions submitted by users via RPC. User, @@ -48,19 +85,18 @@ struct DepositMetadata { recipient_id: lee::AccountId, } -impl DepositMetadata { - fn decode(bytes: &[u8]) -> Result { - Self::try_from_slice(bytes) - } -} - pub struct SequencerCore { - state: lee::V03State, + /// Two-tier chain state: production builds on its head; the publisher's + /// `on_follow` sink feeds adopted/orphaned/finalized peer blocks into it. + chain: Arc>, store: SequencerStore, mempool: MemPool<(TransactionOrigin, LeeTransaction)>, sequencer_config: SequencerConfig, - chain_height: u64, block_publisher: BP, + /// Cross-zone watchers, stopped when this sequencer is dropped. They hold a + /// store handle, so leaving them running would keep the `RocksDB` lock held + /// and make the home directory unopenable by a restarting sequencer. + watchers: TaskGroup, } impl SequencerCore { @@ -69,39 +105,28 @@ impl SequencerCore { /// assumed to represent the correct latest state consistent with Bedrock-finalized data. /// If no database is found, the sequencer performs a fresh start from genesis, /// initializing its state with the accounts defined in the configuration file. - pub async fn start_from_config( - config: SequencerConfig, - ) -> (Self, MemPoolHandle<(TransactionOrigin, LeeTransaction)>) { + fn open_or_create_store(config: &SequencerConfig) -> (SequencerStore, lee::V03State) { let signing_key = lee::PrivateKey::try_new(config.signing_key).unwrap(); - - let bedrock_signing_key = - load_or_create_signing_key(&config.home.join("bedrock_signing_key")) - .expect("Failed to load or create bedrock signing key"); - let db_path = config.home.join("rocksdb"); - let (store, state, genesis_block) = if db_path.exists() { - let store = - SequencerStore::open_db(&db_path, signing_key.clone()).unwrap_or_else(|err| { - panic!( - "Failed to open database at {} with error: {err}", - db_path.display() - ) - }); + + if db_path.exists() { + let store = SequencerStore::open_db(&db_path, signing_key).unwrap_or_else(|err| { + panic!( + "Failed to open database at {} with error: {err}", + db_path.display() + ) + }); let state = store .get_lee_state() .expect("Failed to read state from store"); - let genesis_block = store - .get_block_at_id(store.genesis_id()) - .expect("Failed to read genesis block from store") - .expect("Genesis block not found in store"); - (store, state, genesis_block) + (store, state) } else { warn!( "Database not found at {}, starting from genesis", db_path.display() ); - let (genesis_state, genesis_txs) = build_genesis_state(&config); + let (genesis_state, genesis_txs) = build_genesis_state(config); let hashable_data = HashableBlockData { block_id: GENESIS_BLOCK_ID, @@ -119,12 +144,70 @@ impl SequencerCore { ) .expect("Failed to create database with genesis block"); - (store, genesis_state, genesis_block) - }; + (store, genesis_state) + } + } - let latest_block_meta = store - .latest_block_meta() - .expect("Failed to read latest block meta from store"); + /// Rebuilds the two-tier [`ChainState`]: the final tier from the persisted + /// final snapshot (pre-genesis state when absent), the head tier by replaying + /// every stored block above it, so a post-restart orphan can still revert. + fn restore_chain_state( + config: &SequencerConfig, + store: &SequencerStore, + stored_head_state: &lee::V03State, + ) -> ChainState { + let final_snapshot = store + .dbio() + .get_final_snapshot() + .expect("Failed to read final snapshot from store"); + let (final_state, final_tip) = match final_snapshot { + Some((state, meta)) => (state, Some(Tip::from(meta))), + // Nothing finalized yet: replay the whole stored chain. + None => (build_initial_state(config), None), + }; + let boundary = final_tip.as_ref().map_or(0, |tip| tip.block_id); + + let mut head_blocks = store + .get_all_blocks() + .filter_ok(|block| block.header.block_id > boundary) + .collect::, _>>() + .expect("Failed to read blocks from store while restoring chain state"); + head_blocks.sort_unstable_by_key(|block| block.header.block_id); + + let mut chain = ChainState::from_final(final_state, final_tip); + for block in head_blocks { + let block_id = block.header.block_id; + chain.restore_head_block(block).unwrap_or_else(|err| { + panic!("Stored block {block_id} does not replay while restoring chain state: {err}") + }); + } + + // The replayed head must reproduce the persisted state, else store + // and config disagree (e.g. edited genesis actions). + assert!( + chain.head_state() == stored_head_state, + "Persisted state does not match the replayed chain; reset the store or restore the original config" + ); + + chain + } + + pub async fn start_from_config( + config: SequencerConfig, + ) -> (Self, MemPoolHandle<(TransactionOrigin, LeeTransaction)>) { + let bedrock_signing_key = + load_or_create_signing_key(&config.home.join("bedrock_signing_key")) + .expect("Failed to load or create bedrock signing key"); + info!( + "Bedrock signing public key: {}", + hex::encode(bedrock_signing_key.public_key().to_bytes()) + ); + + let (store, state) = Self::open_or_create_store(&config); + + let chain = Arc::new(Mutex::new(Self::restore_chain_state( + &config, &store, &state, + ))); let initial_checkpoint = store .get_zone_checkpoint() @@ -132,235 +215,606 @@ impl SequencerCore { let is_fresh_start = initial_checkpoint.is_none(); let (mempool, mempool_handle) = MemPool::new(config.mempool_max_size); - replay_unfulfilled_deposit_events(&store, mempool_handle.clone()); let block_publisher = BP::new( &config.bedrock_config, bedrock_signing_key, config.retry_pending_blocks_timeout, initial_checkpoint, - Self::on_checkpoint(store.dbio()), - Self::on_finalized_block(store.dbio()), - Self::on_deposit_event(store.dbio(), mempool_handle.clone()), - Self::on_withdraw_event(store.dbio()), + Self::on_follow(store.dbio(), Arc::clone(&chain), mempool_handle.clone()), ) .await .expect("Failed to initialize Block Publisher"); - // On a truly fresh start (no checkpoint persisted yet), publish the - // genesis block so the indexer can find the channel start. After the - // first publish, zone-sdk's checkpoint persistence covers further - // restarts. - if is_fresh_start { - block_publisher - .publish_block(&genesis_block, vec![]) + // Cross-zone messaging: start a watcher per configured peer. The inbox + // config account is seeded into genesis state in `build_genesis_state`. + let watchers = config + .cross_zone + .as_ref() + .map_or_else(TaskGroup::default, |cross_zone| { + cross_zone_watcher::spawn_watchers( + &config.bedrock_config, + cross_zone, + config.block_create_timeout, + &store.dbio(), + ) + }); + // Before producing, verify our local state still belongs to the chain + // the channel serves and replay any channel blocks we are missing + // (e.g. from other sequencers). + let channel_absent = + Self::verify_and_reconstruct(&block_publisher, &store, &chain, is_fresh_start) .await - .expect("Failed to publish genesis block"); + .expect("Failed to verify/reconstruct sequencer state from Bedrock"); + + // Publish our blocks only when we are bootstrapping a channel that does + // not exist yet (no channel tip). If the channel already exists (another + // sequencer created it), we adopted its blocks during reconstruction + // instead; republishing then would fork the channel with our own copies. + if is_fresh_start && channel_absent { + let mut pending_blocks = store + .get_all_blocks() + .filter_ok(|block| matches!(block.bedrock_status, BedrockStatus::Pending)) + .collect::, _>>() + .expect("Failed to read blocks from store while republishing on fresh start"); + pending_blocks.sort_unstable_by_key(|block| block.header.block_id); + + assert!( + pending_blocks + .first() + .is_none_or(|block| block.header.block_id == GENESIS_BLOCK_ID), + "First pending block on fresh start should be the genesis block" + ); + + let mut last_checkpoint = None; + for block in &pending_blocks { + let outcome = block_publisher + .publish_block(block, vec![]) + .await + .unwrap_or_else(|err| { + panic!( + "Failed to publish block {} on fresh start: {err:#}", + block.header.block_id + ) + }); + last_checkpoint = Some(outcome.checkpoint); + } + + // These blocks are already stored, so only the sdk's pending set + // moved. Checkpoints are cumulative — persisting just the last one + // is both sufficient and the only way to keep this loop linear. + if let Some(checkpoint) = last_checkpoint { + store + .set_zone_checkpoint(&checkpoint) + .expect("Failed to persist checkpoint after republishing on fresh start"); + } } let sequencer_core = Self { - state, + chain, store, mempool, - chain_height: latest_block_meta.id, sequencer_config: config, block_publisher, + watchers, }; (sequencer_core, mempool_handle) } - fn on_checkpoint(dbio: Arc) -> block_publisher::CheckpointSink { - Box::new(move |cp| { - let bytes = match serde_json::to_vec(&cp) { - Ok(b) => b, - Err(err) => { - error!("Failed to serialize zone-sdk checkpoint: {err:#}"); - return; - } + /// Verifies the local store still belongs to the chain the connected channel + /// serves and replays any finalized channel blocks missing locally into + /// `state`/`store`, recording each block's L1 inscription slot as the new + /// anchor. Fails (never parks) on any divergence. + /// + /// Returns whether the channel does not exist yet (has no tip), i.e. whether + /// this sequencer is the one that must bootstrap-publish its own blocks. + async fn verify_and_reconstruct( + publisher: &BP, + store: &SequencerStore, + chain: &Mutex, + is_fresh_start: bool, + ) -> Result { + let anchor_record = store + .get_zone_anchor() + .context("Failed to read zone anchor")?; + + let after_slot = anchor_record + .and_then(|record| record.slot.checked_sub(1)) + .map(Slot::from); + let channel_tip_slot = publisher + .channel_tip_slot() + .await + .context("Failed to read channel tip slot")?; + + // If this sequencer has already committed blocks to the channel, that + // channel must still exist. A missing channel then means a wiped/rewound + // Bedrock or a node pointing at a different chain, so refuse to resume + // onto a foreign channel. + // + // "Committed" requires *both* a non-genesis tip and a checkpoint that was + // persisted before this startup: the tip alone is set the moment we produce + // (before the channel confirms it), while a checkpoint alone is written by + // zone-sdk's cold-start backfill even on a brand-new empty channel before we + // publish genesis. We must read the checkpoint presence from before `BP::new` + // ran (`!is_fresh_start`), because its cold-start backfill re-persists a + // checkpoint by the time we reach here — reading the store now would always + // see one. Together they mean we produced blocks and zone-sdk processed + // channel activity in a prior run. + let local_tip = store + .latest_block_meta() + .context("Failed to read latest block meta")? + .map(|meta| meta.id); + let had_checkpoint_before_start = !is_fresh_start; + if let Some(local_tip) = local_tip + && had_checkpoint_before_start + && channel_tip_slot.is_none() + { + return Err(anyhow!( + "Sequencer holds committed blocks (tip {local_tip}) but the Bedrock channel \ + no longer exists on the connected chain — the channel was wiped or the node \ + points at a different chain. Refusing to resume onto a foreign channel." + )); + } + + let divergence_error = |mismatch: &ChainMismatch| { + anyhow!( + "Sequencer store diverges from the Bedrock channel ({mismatch}). \ + Delete the sequencer storage directory or point at the correct channel." + ) + }; + + // With a recorded anchor, probe the channel for positive evidence of a + // different chain: the frontier upfront (a missing/behind channel serves + // no messages to scan), then the anchor block as messages stream in. + let mut consistency_check = anchor_record.map(|record| { + let anchor = Anchor::new( + Slot::from(record.slot), + Some((record.block_id, record.hash)), + ); + let mut check = AnchorConsistencyCheck::new(anchor); + check.check_frontier(channel_tip_slot); + check + }); + if let Some(ChainConsistency::Inconsistent(mismatch)) = consistency_check + .as_ref() + .and_then(AnchorConsistencyCheck::verdict) + { + return Err(divergence_error(mismatch)); + } + + // Verify each message against the anchor and replay the + // blocks (applying the ones we miss, checking the ones we hold). + let messages = publisher + .read_channel_after(after_slot) + .await + .context("Failed to read channel history for reconstruction")?; + let mut messages = std::pin::pin!(messages); + while let Some((message, slot)) = messages.next().await { + if let Some(check) = &mut consistency_check + && let Some(ChainConsistency::Inconsistent(mismatch)) = + check.observe(&message, slot) + { + return Err(divergence_error(mismatch)); + } + + let ZoneMessage::Block(zone_block) = message else { + continue; }; - if let Err(err) = dbio.put_zone_sdk_checkpoint_bytes(&bytes) { - error!("Failed to persist zone-sdk checkpoint: {err:#}"); - } - }) + let block: Block = borsh::from_slice(&zone_block.data).map_err(|err| { + anyhow!( + "Failed to deserialize channel block at slot {}: {err}", + slot.into_inner() + ) + })?; + // Locked per message (not across the stream `await`): concurrent + // follow events interleave safely — both paths apply idempotently + // and persist under this same lock. + let mut chain = chain.lock().expect("chain state mutex poisoned"); + Self::apply_reconstructed_block(store, &mut chain, &block, slot)?; + } + + // The channel exists once it has a tip; only when it has none is this + // sequencer the one bootstrapping it. This is deliberately not the + // reconstruction scan's view above, which reads only finalized history + // (up to LIB) and so reports "empty" while finality lags even though the + // channel already holds unfinalized blocks from another sequencer. + Ok(channel_tip_slot.is_none()) } - fn on_finalized_block(dbio: Arc) -> block_publisher::FinalizedBlockSink { - Box::new(move |block_id| { - // NOTE: Theoretically Zone SDK may report finalization happening multiple times for the - // same block. In practice this is very unlikely to happen. For that to - // happen Sequencer should crash between receiving Finalized and Checkpoint events while - // these events happen very fast (because Checkpoints are generated by Zone SDK - // locally). + /// Applies a single channel block during reconstruction: idempotent for + /// blocks we already hold (verifying their hash), a validated continuation + /// for new ones. Advances the persisted anchor to the block's slot. + fn apply_reconstructed_block( + store: &SequencerStore, + chain: &mut ChainState, + block: &Block, + slot: Slot, + ) -> Result<()> { + let tip = store + .latest_block_meta() + .context("Failed to read latest block meta")?; + let block_id = block.header.block_id; + let block_hash = block.header.hash; - if let Err(err) = dbio.clean_pending_blocks_up_to(block_id) { - error!("Failed to mark pending blocks finalized up to {block_id}: {err:#}"); - } + let record = ZoneAnchorRecord { + slot: slot.into_inner(), + block_id, + hash: block_hash, + }; - match dbio.remove_fulfilled_pending_deposit_events_up_to_block(block_id) { - Ok(0) => {} - Ok(removed) => { - info!( - "Removed {removed} fulfilled pending deposit events up to finalized block {block_id}" - ); + // A block at/below the tip must match what we already stored, otherwise + // the channel is a different chain. + if let Some(tip) = &tip + && block_id <= tip.id + { + match store + .get_block_at_id(block_id) + .context("Failed to read stored block")? + { + Some(stored) if stored.header.hash == block_hash => { + // Already applied, but the channel serving it is what makes + // it irreversible, so its deliveries are settled and their + // records are owed nothing. Without this a restart leaves a + // record for every delivery it already published, and + // nothing downstream would ever remove them. + settle_reconstructed_deliveries(store, &stored); + store + .set_zone_anchor(&record) + .context("Failed to persist zone anchor")?; + return Ok(()); } - Err(err) => { - error!( - "Failed to remove fulfilled pending deposit events up to block {block_id}: {err:#}" - ); + Some(stored) => { + return Err(anyhow!( + "Channel block {block_id} hash {block_hash} does not match stored hash {}", + stored.header.hash + )); + } + None => { + return Err(anyhow!( + "Channel block {block_id} is at/below local tip {} but is missing locally", + tip.id + )); } } - }) + } + + // New continuation: channel history is finalized, so it goes through + // the final tier — validation happens inside `apply_finalized`. + match chain.apply_finalized(MsgId::from(block.header.hash.0), block, slot) { + AcceptOutcome::Applied | AcceptOutcome::AlreadyApplied => {} + AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) => { + return Err(anyhow!( + "Channel block {block_id} does not extend local tip {:?}: {err}", + tip.map(|tip| tip.id) + )); + } + } + + // A reconstructed block is finalized, so any deposit it mints is + // permanently reflected in state (its receipt PDA); drop the pending + // record backfill may have re-delivered, so the drain stops re-minting. + let finalized_deposit_ids: Vec<_> = block + .body + .transactions + .iter() + .filter_map(extract_bridge_deposit_id) + .collect(); + // The same for the deliveries it carries: the inbox has seen them, so + // the drain would skip them anyway, and the records are owed nothing. + let finalized_dispatch_keys = settled_dispatch_keys(&store.dbio(), block); + + // The tip meta stays pinned to the head tip even when the reconstructed + // block lands below it, and the anchor only advances if the block + // itself landed. + let head_tip = chain.head_tip().map(|head| BlockMeta::from(&head)); + let final_meta = chain.final_tip().map(|meta| BlockMeta::from(&meta)); + store + .dbio() + .store_update(&StoreUpdate { + blocks: &[(block, true)], + head_tip: head_tip.as_ref(), + final_snapshot: final_meta.as_ref().map(|meta| (chain.final_state(), meta)), + remove_deposit_records: &finalized_deposit_ids, + remove_dispatch_records: &finalized_dispatch_keys, + zone_anchor: Some(&record), + ..StoreUpdate::new(chain.head_state()) + }) + .context("Failed to persist reconstructed block")?; + + Ok(()) } - fn on_deposit_event( + /// Publisher sink adapter over [`apply_follow_update`]. + fn on_follow( dbio: Arc, + chain: Arc>, mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, - ) -> block_publisher::OnDepositEventSink { - Box::new(move |deposit| { - // NOTE: Theoretically Zone SDK may report multiple identical deposits. In practice this - // is very unlikely to happen. For that to happen Sequencer should crash - // between receiving Deposit and Checkpoint events while these events happen - // very fast (because Checkpoints are generated by Zone SDK locally). - - let dbio = Arc::clone(&dbio); - let mempool_handle = mempool_handle.clone(); - - Box::pin(async move { - let id_hex = hex::encode(deposit.op_id); - info!("Observed Bedrock Deposit event with id: {id_hex}"); - - let event_record = pending_deposit_event_record(&deposit); - - match dbio.add_pending_deposit_event(event_record.clone()) { - Ok(true) => {} - Ok(false) => { - info!( - "Deposit event {id_hex} already persisted as unfulfilled, skipping duplicate enqueue", - ); - return; - } - Err(err) => { - error!( - "Failed to persist unfulfilled deposit event {id_hex} before enqueue: {err:#}. Deposit will be lost.", - ); - return; - } - } - - let tx = match build_bridge_deposit_tx_from_event(&event_record) { - Ok(tx) => tx, - Err(err) => { - error!( - "Failed to build transaction from Bedrock deposit event {id_hex}: {err:#}. Deposit will be lost.", - ); - return; - } - }; - - if let Err(err) = mempool_handle - .push((TransactionOrigin::Sequencer, tx)) - .await - { - error!( - "Failed to queue sequencer transaction built from finalized Bedrock event: {err:#}. Deposit will be lost." - ); - } - }) - }) - } - - fn on_withdraw_event(dbio: Arc) -> block_publisher::OnWithdrawEventSink { - Box::new(move |withdraw| { - let dbio = Arc::clone(&dbio); - Box::pin(async move { - let hash_encoded = hex::encode(withdraw.tx_hash.as_ref()); - let withdraw_key = match withdraw_event_reconciliation_key(&withdraw.op.outputs) { - Ok(key) => key, - Err(err) => { - error!( - "Failed to build reconciliation key for Bedrock Withdraw event with tx_hash {hash_encoded}: {err:#}" - ); - return; - } - }; - - match dbio.consume_unseen_withdraw_count(withdraw_key) { - Ok(true) => { - info!("Validated Bedrock Withdraw event with tx_hash: {hash_encoded}"); - } - Ok(false) => warn!( - "Unexpected Bedrock Withdraw event with tx_hash {hash_encoded}: no matching unseen withdraw found" - ), - Err(err) => error!( - "Failed to reconcile Bedrock Withdraw event with tx_hash {hash_encoded}: {err:#}" - ), - } - }) + ) -> block_publisher::OnFollowSink { + Box::new(move |update: block_publisher::FollowUpdate| { + apply_follow_update(&dbio, &chain, &mempool_handle, update); }) } /// Produces a new block from mempool transactions and publishes it via zone-sdk. pub async fn produce_new_block(&mut self) -> Result { - let BlockWithMeta { - block, - deposit_event_ids, - withdrawals, - } = self + let BlockWithMeta { block, withdrawals } = self .build_block_from_mempool() .context("Failed to build block from mempool transactions")?; - let withdrawal_reconciliation_keys = withdrawals - .iter() - .map(|withdraw| withdraw_event_reconciliation_key(&withdraw.outputs)) - .collect::>() - .context("Failed to build reconciliation keys for block withdrawals")?; - - self.block_publisher + let block_publisher::PublishOutcome { + this_msg, + checkpoint, + released_notes, + } = self + .block_publisher .publish_block(&block, withdrawals) .await .context("Failed to publish block to Bedrock")?; - self.store.update( + let withdrawal_reconciliation_keys: Vec<_> = released_notes + .iter() + .map(withdrawal_reconciliation_key) + .collect(); + + self.record_produced_block( + this_msg, &block, - &deposit_event_ids, - withdrawal_reconciliation_keys, - &self.state, + &withdrawal_reconciliation_keys, + &checkpoint, )?; - Ok(self.chain_height) + Ok(block.header.block_id) + } + + /// Applies our own freshly-published block to the head with the [`MsgId`] the + /// publish assigned it, so the head advances and the later adopted + /// redelivery dedups, then persists it. + /// + /// Persistence is gated on the block actually becoming the head: if a peer + /// block won this height while we were publishing (`AlreadyApplied`, or + /// `Parked` when the head reorged to a different parent), the canonical + /// block is persisted by the follow path instead, and our invalidated + /// inscription comes back via `orphaned`. + fn record_produced_block( + &mut self, + this_msg: MsgId, + block: &Block, + withdrawal_reconciliation_keys: &[WithdrawalReconciliationKey], + checkpoint: &block_publisher::SequencerCheckpoint, + ) -> Result<()> { + let checkpoint_bytes = block_store::checkpoint_bytes(checkpoint)?; + + let mut chain = self.chain.lock().expect("chain state mutex poisoned"); + match chain.apply_produced(this_msg, block) { + AcceptOutcome::Applied => { + // Persisted under the lock so disk writes land in apply order + // with the follow path. + self.store.update( + block, + withdrawal_reconciliation_keys, + chain.head_state(), + Some(&checkpoint_bytes), + )?; + } + // Neither branch persists anything, checkpoint included: the + // inscription it holds as pending belongs to a block that is not + // ours to keep. + AcceptOutcome::AlreadyApplied => { + warn!( + "Produced block {} lost a competing-write race, skipping persistence", + block.header.block_id + ); + } + AcceptOutcome::Parked(err) | AcceptOutcome::RetryableFailure(err) => { + warn!( + "Produced block {} no longer chains on the head, skipping persistence: {err}", + block.header.block_id + ); + } + } + + Ok(()) + } + + /// Validates and applies a single mempool transaction to the current state. + /// Returns `Ok(true)` if the transaction was valid and applied, `Ok(false)` if + /// it was skipped due to validation failure. + fn apply_mempool_transaction( + state: &mut lee::V03State, + origin: TransactionOrigin, + tx: &LeeTransaction, + block_height: u64, + timestamp: u64, + withdrawals: &mut Vec, + ) -> bool { + let tx_hash = tx.hash(); + match origin { + TransactionOrigin::User => { + let validated_diff = match tx.validate_on_state(state, block_height, timestamp) { + Ok(diff) => diff, + Err(err) => { + error!( + "Transaction with hash {tx_hash} failed execution check with error: {err:#?}, skipping it", + ); + return false; + } + }; + + if let Some(withdraw_data) = extract_bridge_withdraw_data(tx) { + withdrawals.push(withdraw_data); + } + + state.apply_state_diff(validated_diff); + } + TransactionOrigin::Sequencer => { + let LeeTransaction::Public(public_tx) = tx else { + panic!("Sequencer may only generate Public transactions, found {tx:#?}"); + }; + + // Bridge deposits are deduped by their receipt PDA in chain + // state (drained only when unminted, no-op on replay), so no + // node-local guard is needed here. + // + // Skip-and-log rather than propagate: a drained deposit is + // re-fed from the store every turn and only finality removes it, + // so a `?` here would let a single unexecutable mint (e.g. a + // bridge escrow under-funded relative to the L1 deposit, which + // every sequencer hits identically) abort production on all of + // them forever. Skipping keeps the record queued for retry + // without halting the node. + if let Err(err) = + state.transition_from_public_transaction(public_tx, block_height, timestamp) + { + error!( + "Sequencer-generated transaction {tx_hash} failed execution: {err:#?}, skipping it", + ); + return false; + } + } + } + + info!("Validated transaction with hash {tx_hash}, including it in block"); + true } - /// Builds a new block from transactions in the mempool. - /// Does NOT publish or store the block — the caller is responsible for that. fn build_block_from_mempool(&mut self) -> Result { let now = Instant::now(); - let new_block_height = self.next_block_id(); + // Decoded outside the chain lock, and read before it is taken: the usual + // case is no delivery records at all, and decoding is the expensive part. + // One that does not decode is dropped rather than kept, since nothing + // will ever turn those bytes into a block transaction. + let mut settled = Vec::new(); + let recorded_dispatches: Vec<_> = self + .store + .dbio() + .get_pending_cross_zone_dispatches() + .context("Failed to load pending cross-zone dispatches")? + .into_iter() + .filter_map( + |record| match borsh::from_slice::(&record.transaction) { + Ok(tx) => { + let message = extract_cross_zone_dispatch(&tx); + Some((record.message_key, message, tx)) + } + Err(err) => { + warn!( + "Dropping pending cross-zone dispatch {} that does not decode: {err:#}", + hex::encode(record.message_key) + ); + settled.push(record.message_key); + None + } + }, + ) + .collect(); + + // Build on the head: its tip is the parent, its state the validation + // base. + // + // The delivery records are classified in here rather than after, so the + // final state can be read by reference. Cloning it cost a full state + // copy on every block of every zone, cross-zone or not. + let (prev_block_hash, new_block_height, mut working_state, pending_dispatches) = { + let chain = self.chain.lock().expect("chain state mutex poisoned"); + let tip = chain.head_tip(); + let height = tip.as_ref().map_or(GENESIS_BLOCK_ID, |head| { + head.block_id + .checked_add(1) + .expect("block id should not overflow") + }); + let prev = tip.map_or(HashType([0; 32]), |head| head.hash); + + // Three outcomes per record. Already in the final state means the + // delivery is irreversible, so the record is dropped; that is the + // only thing that removes a record the watcher re-added after its + // delivery had already settled, which it does whenever it re-reads a + // slot it has consumed. Already in the head state but not the final + // one means the delivery is on this chain but could still orphan, so + // the record is skipped and kept. Otherwise it goes in this block. + let mut pending: VecDeque = VecDeque::new(); + for (key, message, tx) in recorded_dispatches { + match message { + Some(message) if dispatch_already_delivered(chain.final_state(), &message) => { + settled.push(key); + } + Some(message) if dispatch_already_delivered(chain.head_state(), &message) => {} + _ if pending.len() >= MAX_DISPATCHES_PER_BLOCK => {} + _ => pending.push_back(tx), + } + } + + (prev, height, chain.head_state().clone(), pending) + }; + + if !settled.is_empty() + && let Err(err) = self + .store + .dbio() + .drop_settled_cross_zone_dispatches(&settled) + { + // Only bookkeeping: the deliveries themselves are irreversible, and + // the next turn tries again. + warn!( + "Failed to drop {} settled delivery record(s): {err:#}", + settled.len() + ); + } let mut valid_transactions = Vec::new(); - let mut deposit_event_ids = Vec::new(); let mut withdrawals = Vec::new(); + // Bridge deposit mints are drained from the store, not the mempool: the + // follow path records the event durably but cannot enqueue the mint + // itself (it runs on the publisher's drive task, where an await stalls + // the very task production needs). Draining here also subsumes the old + // startup replay. + // + // Skip any deposit whose receipt PDA already exists in the state we + // build on — it was minted by us or by a peer whose block we adopted. + // An orphan reverts the receipt with the block, so the next turn + // re-mints without any bookkeeping of our own. + let pending_deposits: VecDeque = self + .store + .get_pending_deposit_events() + .context("Failed to load pending deposit events")? + .into_iter() + .filter(|record| !deposit_already_minted(&working_state, record.deposit_op_id)) + .filter_map(|record| { + build_bridge_deposit_tx_from_event(&record) + .inspect_err(|err| { + warn!( + "Skipping pending deposit event {} due to tx build failure: {err:#}", + hex::encode(record.deposit_op_id) + ); + }) + .ok() + }) + .collect(); + let max_block_size = usize::try_from(self.sequencer_config.max_block_size.as_u64()) .expect("`max_block_size` should fit into usize"); - let latest_block_meta = self - .store - .latest_block_meta() - .context("Failed to get latest block meta from store")?; - let new_block_timestamp = u64::try_from(chrono::Utc::now().timestamp_millis()) .expect("Timestamp must be positive"); - // Pre-create the mandatory clock tx so its size is included in the block size check. let clock_tx = clock_invocation(new_block_timestamp); let clock_lee_tx = LeeTransaction::Public(clock_tx.clone()); - while let Some((origin, tx)) = self.mempool.pop() { + // Everything drained from the store first, then user work. `from_store` + // is not the same as a `Sequencer` origin: it says the transaction has a + // record behind it and so needs no requeue, where the origin only says + // it was not submitted by a user. + let mut pending_from_store = pending_deposits; + pending_from_store.extend(pending_dispatches); + while let Some((origin, tx, from_store)) = pending_from_store + .pop_front() + .map(|tx| (TransactionOrigin::Sequencer, tx, true)) + .or_else(|| self.mempool.pop().map(|(origin, tx)| (origin, tx, false))) + { let tx_hash = tx.hash(); - // Check if block size exceeds limit (including the mandatory clock tx). let temp_valid_transactions = [ valid_transactions.as_slice(), std::slice::from_ref(&tx), @@ -370,7 +824,7 @@ impl SequencerCore { let temp_hashable_data = HashableBlockData { block_id: new_block_height, transactions: temp_valid_transactions, - prev_block_hash: latest_block_meta.hash, + prev_block_hash, timestamp: new_block_timestamp, }; @@ -379,67 +833,67 @@ impl SequencerCore { .len(); if block_size > max_block_size { - // Block would exceed size limit, remove last transaction and push back + // Would a block carrying nothing but this still be too big? Then + // it does not fit in any block and deferring it defers it for + // ever. A store-drained transaction is at the head of the queue + // every turn, so breaking here would stop production reaching + // anything behind it, including the whole mempool, permanently. + // Count it against the delivery instead so it is given up on. + // + // Measured on its own rather than from `block_size`, which also + // counts whatever this block already holds: a transaction that + // merely does not fit *today* is the ordinary deferral below. + if from_store + && !self.fits_in_an_empty_block( + &tx, + &clock_lee_tx, + new_block_height, + prev_block_hash, + new_block_timestamp, + )? + { + error!( + "Sequencer-drained transaction {tx_hash} cannot fit in any block under the \ + {max_block_size} byte limit; giving up on it rather than stalling production", + ); + self.count_dispatch_failure(&tx); + continue; + } + warn!( "Transaction with hash {tx_hash} deferred to next block: \ block size {block_size} bytes would exceed limit of {max_block_size} bytes", ); - - self.mempool.push_front((origin, tx)); + // Anything drained from the store needs no requeue: its record + // stays there and is drained again on the next turn. + if !from_store { + self.mempool.push_front((origin, tx)); + } break; } - match origin { - TransactionOrigin::User => { - let validated_diff = match tx.validate_on_state( - &self.state, - new_block_height, - new_block_timestamp, - ) { - Ok(diff) => diff, - Err(err) => { - error!( - "Transaction with hash {tx_hash} failed execution check with error: {err:#?}, skipping it", - ); - continue; - } - }; - - if let Some(withdraw_data) = extract_bridge_withdraw_data(&tx) { - withdrawals.push(withdraw_data); - } - - self.state.apply_state_diff(validated_diff); - } - TransactionOrigin::Sequencer => { - let LeeTransaction::Public(public_tx) = &tx else { - panic!("Sequencer may only generate Public transactions, found {tx:#?}"); - }; - - if let Some(deposit_op_id) = extract_bridge_deposit_id(&tx) { - deposit_event_ids.push(deposit_op_id); - } - - self.state - .transition_from_public_transaction( - public_tx, - new_block_height, - new_block_timestamp, - ) - .context("Failed to execute sequencer-generated transaction")?; - } + if Self::apply_mempool_transaction( + &mut working_state, + origin, + &tx, + new_block_height, + new_block_timestamp, + &mut withdrawals, + ) { + valid_transactions.push(tx); + } else { + // A failed transaction is simply left out of the block, except a + // dispatch: that one is re-fed from the store every turn, so one + // that can never execute would fail on every block for ever. + self.count_dispatch_failure(&tx); } - valid_transactions.push(tx); - - info!("Validated transaction with hash {tx_hash}, including it in block"); if valid_transactions.len() >= self.sequencer_config.max_num_tx_in_block { break; } } - // Append the Clock Program invocation as the mandatory last transaction. - self.state + working_state .transition_from_public_transaction(&clock_tx, new_block_height, new_block_timestamp) .context("Clock transaction failed. Aborting block production.")?; valid_transactions.push(clock_lee_tx); @@ -447,7 +901,7 @@ impl SequencerCore { let hashable_data = HashableBlockData { block_id: new_block_height, transactions: valid_transactions, - prev_block_hash: latest_block_meta.hash, + prev_block_hash, timestamp: new_block_timestamp, }; @@ -455,31 +909,36 @@ impl SequencerCore { .clone() .into_pending_block(self.store.signing_key()); - self.chain_height = new_block_height; - log::info!( "Created block with {} transactions in {} seconds", hashable_data.transactions.len(), now.elapsed().as_secs() ); - Ok(BlockWithMeta { - block, - deposit_event_ids, - withdrawals, - }) + Ok(BlockWithMeta { block, withdrawals }) } - pub const fn state(&self) -> &lee::V03State { - &self.state + /// Reads the current head state under the lock without cloning it, so callers + /// reuse `V03State`'s own API (accounts, nonces, proofs) with no whole-state copy. + pub fn with_state(&self, f: impl FnOnce(&lee::V03State) -> R) -> R { + f(self + .chain + .lock() + .expect("chain state mutex poisoned") + .head_state()) } pub const fn block_store(&self) -> &SequencerStore { &self.store } - pub const fn chain_height(&self) -> u64 { - self.chain_height + #[must_use] + pub fn chain_height(&self) -> u64 { + self.chain + .lock() + .expect("chain state mutex poisoned") + .head_tip() + .map_or(0, |tip| tip.block_id) } pub const fn sequencer_config(&self) -> &SequencerConfig { @@ -487,10 +946,9 @@ impl SequencerCore { } /// Marks all pending blocks with `block_id <= last_finalized_block_id` as - /// finalized. Idempotent. Production callers don't invoke this directly — - /// it's wired up in `start_from_config` to the publisher's - /// `on_finalized_block` sink, which fires on `Event::TxsFinalized` / - /// `Event::FinalizedInscriptions`. Kept on the type for tests. + /// finalized. Idempotent. Production no longer calls this: finalization + /// flips now ride the follow path's atomic write via + /// [`StoreUpdate::finalized_up_to`]. Kept on the type for tests. // TODO: Delete blocks instead of marking them as finalized. Current // approach is used because we still have `GetBlockDataRequest`. pub fn clean_finalized_blocks_from_db(&self, last_finalized_block_id: u64) -> Result<()> { @@ -512,94 +970,390 @@ impl SequencerCore { .collect()) } - pub fn block_publisher(&self) -> BP { - self.block_publisher.clone() + pub const fn block_publisher(&self) -> &BP { + &self.block_publisher } - fn next_block_id(&self) -> u64 { - self.chain_height - .checked_add(1) - .unwrap_or_else(|| panic!("Max block height reached: {}", self.chain_height)) + /// Whether a block carrying nothing but `tx` and the clock would be within + /// the size limit. + /// + /// Distinguishes "does not fit in this block" from "does not fit in any + /// block". The first is an ordinary deferral; the second, for a transaction + /// the store re-feeds every turn, is a permanent stall unless it is given up + /// on. + fn fits_in_an_empty_block( + &self, + tx: &LeeTransaction, + clock_tx: &LeeTransaction, + block_id: u64, + prev_block_hash: HashType, + timestamp: u64, + ) -> Result { + let alone = HashableBlockData { + block_id, + transactions: vec![tx.clone(), clock_tx.clone()], + prev_block_hash, + timestamp, + }; + let size = borsh::to_vec(&alone) + .context("Failed to serialize block for size check")? + .len(); + let max = usize::try_from(self.sequencer_config.max_block_size.as_u64()) + .expect("`max_block_size` should fit into usize"); + Ok(size <= max) + } + + /// Counts one failed production attempt against `tx` if it is a cross-zone + /// delivery, giving up on it once too many accumulate. + /// + /// A delivery's payload and target accounts are chosen on the peer zone and + /// validated by nobody in between, so one can fail for good; but a failure + /// can equally be a property of the moment, so give up only after several. + /// Giving up drops the record, which is also what keeps a peer from growing + /// the pending list with deliveries that can never execute. + fn count_dispatch_failure(&self, tx: &LeeTransaction) { + let Some(message) = extract_cross_zone_dispatch(tx) else { + return; + }; + let key = cross_zone_inbox_core::message_key( + &message.src_zone, + message.src_block_id, + message.src_tx_index, + ); + match self + .store + .dbio() + .record_dispatch_failure(key, RETIRE_DISPATCH_AFTER_FAILURES) + { + Ok(true) => error!( + "Giving up on cross-zone delivery {} after {RETIRE_DISPATCH_AFTER_FAILURES} failed attempts; it will not be retried", + hex::encode(key) + ), + Ok(false) => warn!( + "Cross-zone delivery {} failed to execute, will retry next block", + hex::encode(key) + ), + Err(err) => error!( + "Failed to count the failed attempt for cross-zone delivery {}: {err:#}", + hex::encode(key) + ), + } + } + + /// A weak reference to this sequencer's store, for a shutdown path that + /// needs to observe the database actually closing rather than infer it. + #[must_use] + pub fn store_release(&self) -> StoreRelease { + StoreRelease::new(&self.store.dbio()) + } + + /// Every background task that holds this sequencer's store handle. + /// + /// Taken before the core is shared, so a shutdown path can wait for them + /// without owning the core. Until all of them have stopped the `RocksDB` + /// lock is still held and the home directory cannot be reopened, which is + /// what a restart does. + #[must_use] + pub fn background_tasks(&self) -> Vec { + vec![ + self.watchers.clone(), + self.block_publisher.background_tasks(), + ] + } + + /// Whether this sequencer is currently authorized to write to the channel. + #[must_use] + pub fn is_our_turn(&self) -> bool { + self.block_publisher.is_our_turn() + } + + /// Shared handle to the two-tier follow state, for tests to drive the + /// follow path directly. + #[cfg(all(test, feature = "mock"))] + fn chain(&self) -> Arc> { + Arc::clone(&self.chain) } } struct BlockWithMeta { block: Block, - deposit_event_ids: Vec, withdrawals: Vec, } -/// Checks the database for any pending deposit events that have not yet been marked as submitted in -/// a block, and re-queues them in the mempool in a separate async task for inclusion in the next -/// block. -fn replay_unfulfilled_deposit_events( - store: &SequencerStore, - mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, -) { - let replay_records: Vec = store - .get_unfulfilled_deposit_events() - .expect("Failed to load unfulfilled deposit events") - .into_iter() - .filter(|record| record.submitted_in_block_id.is_none()) - .collect(); - - if replay_records.is_empty() { - return; - } - - info!( - "Found {} unfulfilled deposit events in DB, re-queueing", - replay_records.len() - ); - tokio::spawn(async move { - for record in replay_records { - let tx = match build_bridge_deposit_tx_from_event(&record) { - Ok(tx) => tx, - Err(err) => { - warn!( - "Skipping replay of pending deposit event {} due to tx build failure: {err:#}", - hex::encode(record.deposit_op_id) - ); - continue; - } - }; - - if let Err(err) = mempool_handle - .push((TransactionOrigin::Sequencer, tx)) - .await - { - error!( - "Failed to re-queue unfulfilled deposit event {} from DB: {err:#}", - hex::encode(record.deposit_op_id) - ); - break; - } - } - }); +/// Whether `deposit_op_id`'s mint is already reflected in `state` — its receipt +/// PDA exists. The receipt is the exactly-once ledger the bridge program keeps. +fn deposit_already_minted(state: &lee::V03State, deposit_op_id: HashType) -> bool { + let receipt_id = + bridge_core::deposit_receipt_account_id(programs::bridge().id(), deposit_op_id.0); + state + .get_account_by_id_ref(receipt_id) + .is_some_and(|receipt| *receipt != lee::Account::default()) } -/// Builds the initial genesis state from `testnet_initial_state` plus configured genesis -/// transactions. Returns the final state and the list of [`LeeTransaction`]s that should be -/// committed to the genesis block so external observers can replay them. -fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec) { +/// Whether a cross-zone delivery is already on the chain we are building on. +/// +/// The inbox records every delivered message key in a seen shard and no-ops a +/// replay, so that shard is the same kind of answer the deposit receipt gives: +/// state, not bookkeeping. An orphan reverts the entry with the block, so the +/// next turn re-delivers with nothing of ours to unwind. +fn dispatch_already_delivered(state: &lee::V03State, message: &CrossZoneMessage) -> bool { + let shard_id = cross_zone_inbox_core::inbox_seen_shard_account_id( + programs::cross_zone_inbox().id(), + &message.src_zone, + message.src_block_id, + ); + state.get_account_by_id_ref(shard_id).is_some_and(|shard| { + cross_zone_inbox_core::SeenShard::from_bytes(shard.data.as_ref()).is_ok_and(|seen| { + seen.contains(&cross_zone_inbox_core::message_key( + &message.src_zone, + message.src_block_id, + message.src_tx_index, + )) + }) + }) +} + +/// Feed one channel delta into the follow state and mirror it to the store: +/// revert orphaned, then apply and persist adopted and finalized blocks. +/// Production builds on this same head. Wired to the publisher via +/// [`SequencerCore::on_follow`]; a free function so tests can drive it directly. +/// +/// Everything the event produced lands in one write — see [`StoreUpdate`]. +/// +/// TODO: unlike the indexer's ingest loop, this path does not retry +/// `is_retryable` (transient) apply failures — a failed block just parks and +/// relies on a valid successor or a restart. `ChainState` never emits +/// `AcceptOutcome::RetryableFailure` yet; adding retry parity here is a +/// follow-up. +fn apply_follow_update( + dbio: &RocksDBIO, + chain: &Mutex, + mempool_handle: &MemPoolHandle<(TransactionOrigin, LeeTransaction)>, + update: block_publisher::FollowUpdate, +) { + let block_publisher::FollowUpdate { + checkpoint, + adopted, + orphaned, + finalized, + deposits, + withdrawals, + } = update; + + let checkpoint_bytes = block_store::checkpoint_bytes(&checkpoint) + .unwrap_or_else(|err| panic!("Failed to serialize zone-sdk checkpoint: {err:#}")); + + // NOTE: Theoretically Zone SDK may re-deliver an already seen deposit or + // finalization. Both are idempotent here: a deposit already on record is + // not re-appended, and a finalization only ever moves the tier forward. + let deposit_records: Vec = + deposits.iter().map(pending_deposit_event_record).collect(); + + // One reconciliation unit per released note, matching how the intents were + // recorded at publish time. + let consumed_withdrawals: Vec = withdrawals + .iter() + .flat_map(|withdraw| withdraw.op.inputs.iter()) + .map(withdrawal_reconciliation_key) + .collect(); + + // The lock is held across the persist below so disk writes land in apply + // order — the produce path persists under this same lock. + let (resubmit_txs, outcome) = { + let mut chain = chain.lock().expect("chain state mutex poisoned"); + + // Outcomes align with `adopted`. + let outcomes = chain.apply_channel_update(&orphaned, &adopted); + let mut to_persist: Vec<(&Block, bool)> = adopted + .iter() + .zip(&outcomes) + .filter(|(_, outcome)| matches!(outcome, AcceptOutcome::Applied)) + .map(|((_, block), _)| (block, false)) + .collect(); + + // Only blocks the final tier holds drive the bookkeeping below: a parked + // one never became irreversible, so marking blocks finalized through it + // or dropping its deposit records would lose them for good. + let mut irreversible: Vec<&Block> = Vec::new(); + let mut final_advanced = false; + for (this_msg, block) in &finalized { + // FIXME: thread the finalized inscription's L1 slot instead of + // `Slot::from(0)`; only used for the invalid-finalized stall. + // logos-blockchain PR #3147 surfaces it as `FinalizedTx.l1_slot` — + // wire it through `FollowUpdate::finalized` once the zone-sdk pin is + // bumped past that (a separate PR). + match chain.apply_finalized(*this_msg, block, Slot::from(0)) { + AcceptOutcome::Applied => { + to_persist.push((block, true)); + irreversible.push(block); + final_advanced = true; + } + // A re-delivery of a block the final tier already holds: no new + // payload and the tier does not move, but it is irreversible all + // the same, so it still settles its deposits. + AcceptOutcome::AlreadyApplied => irreversible.push(block), + AcceptOutcome::Parked(_) | AcceptOutcome::RetryableFailure(_) => {} + } + } + + // User txs of orphaned blocks, returned to the mempool below. + // + // Computed after the finalized tier has advanced, and only for blocks + // above it: the zone-sdk reports a block as orphaned once LIB pruning + // drops its inscription from the channel lineage, so every block of + // ours is orphaned a poll or two after it finalizes. Those transactions + // are irreversibly included, and returning them to the mempool puts + // them back in every block we produce from then on. + let final_height = chain.final_tip().map(|tip| tip.block_id); + let resubmit_txs: Vec = orphaned + .iter() + .filter(|(_, block)| final_height.is_none_or(|id| block.header.block_id > id)) + .flat_map(|(_, block)| resubmittable_txs(block)) + .collect(); + + // Snapshot the advanced final tier so a restart re-anchors on it. + let final_meta = final_advanced.then(|| { + let tip = chain.final_tip().expect("advanced final tier has a tip"); + BlockMeta::from(&tip) + }); + let head_tip = chain.head_tip().map(|tip| BlockMeta::from(&tip)); + + // Every block at or below the highest finalized one is irreversible, so + // stored blocks there can be marked finalized. + let last_finalized = irreversible.iter().map(|block| block.header.block_id).max(); + + // A deposit observed in a finalized block is permanently minted (its + // receipt is now in the irreversible tier), so its pending record can be + // dropped. Keyed by op id, not block id: a record only goes once its own + // deposit finalizes, never because some other block finalized at its + // height. + let finalized_deposit_ids: Vec = irreversible + .iter() + .flat_map(|block| block.body.transactions.iter()) + .filter_map(extract_bridge_deposit_id) + .collect(); + + // The same for cross-zone deliveries, keyed by message key: a record + // goes once its own delivery is irreversible, never because another + // block finalized at its height. + let finalized_dispatch_keys: Vec<[u8; 32]> = irreversible + .iter() + .flat_map(|block| settled_dispatch_keys(dbio, block)) + .collect(); + + // A persist failure is fatal: the in-memory chain has already advanced, + // and continuing would leave a permanent gap in the store. The `panic!` + // ends the drive task, whose cancellation halts the node. + let outcome = dbio + .store_update(&StoreUpdate { + checkpoint: Some(&checkpoint_bytes), + blocks: &to_persist, + head_tip: head_tip.as_ref(), + final_snapshot: final_meta.as_ref().map(|meta| (chain.final_state(), meta)), + finalized_up_to: last_finalized, + new_deposit_events: &deposit_records, + remove_deposit_records: &finalized_deposit_ids, + remove_dispatch_records: &finalized_dispatch_keys, + consumed_withdrawals: &consumed_withdrawals, + ..StoreUpdate::new(chain.head_state()) + }) + .unwrap_or_else(|err| panic!("Failed to persist follow update: {err:#}")); + + (resubmit_txs, outcome) + }; + + if outcome.accepted_deposits > 0 { + info!( + "Recorded {} Bedrock Deposit event(s); their mints are drained from the store on our next turn", + outcome.accepted_deposits + ); + } + for withdrawal in &outcome.unmatched_withdrawals { + warn!( + "Unexpected Bedrock Withdraw event releasing channel note {}: no matching unseen withdraw found", + hex::encode(withdrawal.released_note_id) + ); + } + + // Rebuild orphaned work: return its user txs to the mempool so the + // next on-turn production re-includes them on the new head. + // + // We use [`try_push`] here because this is called from the publisher's + // drive task, and only block production drains the mempool. A blocking + // push would stall the drive task, and a sequencer that is not on turn + // never produces — so nothing would ever drain it again. + // + // TODO: a full mempool still drops the transaction; a durable resubmit + // queue is a follow-up. + for tx in resubmit_txs { + let tx_hash = tx.hash(); + if let Err(err) = mempool_handle.try_push((TransactionOrigin::User, tx)) { + warn!("Dropping orphaned transaction {tx_hash} on resubmit: {err}"); + } + } +} + +/// The pre-genesis state: `testnet_initial_state` plus the bridge-lock holdings, +/// the only accounts seeded outside any transaction. Cross-zone config is seeded +/// by genesis `InitConfig` transactions and reconstructed by replaying them. +fn build_initial_state(config: &SequencerConfig) -> lee::V03State { #[cfg(not(feature = "testnet"))] - let mut state = testnet_initial_state::initial_state(); + let base = testnet_initial_state::initial_state(); #[cfg(feature = "testnet")] - let mut state = testnet_initial_state::initial_state_testnet(); + let base = testnet_initial_state::initial_state_testnet(); - let genesis_txs = config - .genesis - .iter() - .map(|genesis_tx| match genesis_tx { - GenesisAction::SupplyAccount { - account_id, - balance, - } => build_supply_account_genesis_transaction(account_id, *balance), - GenesisAction::SupplyBridgeAccount { balance } => { - build_supply_bridge_account_genesis_transaction(*balance) - } - }) + // Bridge-lock holder balances belong to the source side and are not produced by + // any transaction, so seed them directly. Cross-zone config is seeded by genesis + // InitConfig transactions in `build_genesis_state`, not here. + let holdings = bridge_lock_holdings(&config.genesis) + .map(|(holder, amount)| cross_zone::build_holding_account(holder, amount)); + base.with_public_accounts(holdings) +} + +/// Builds the initial genesis state from [`build_initial_state`] plus configured +/// genesis transactions. Returns the final state and the list of +/// [`LeeTransaction`]s that should be committed to the genesis block so external +/// observers can replay them. +fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec) { + let mut state = build_initial_state(config); + + // Fingerprint the directly-seeded state, before genesis txs, so it matches the indexer's. + info!( + "Genesis fingerprint: {}", + hex::encode(state.genesis_fingerprint()) + ); + + // Config txs seed the config accounts by transaction, so every node + // reconstructs them by replaying the genesis block. The wrapped-token minter is + // initialized on every zone (wrapped_token is a builtin), since its InitConfig + // is user-callable and a config PDA left default would be claimable by anyone as + // the first initializer (a minter hijack). The inbox allowlist is initialized + // only on receiving zones; the inbox is sequencer-only, so its default config + // PDA is not user-claimable, merely unused until the zone receives. + let wrapped_token_config_tx = std::iter::once(cross_zone::build_wrapped_token_init_config_tx()); + let inbox_config_tx = config.cross_zone.as_ref().map(|cross_zone| { + let self_zone = *config.bedrock_config.channel_id.as_ref(); + cross_zone::build_inbox_init_config_tx(self_zone, cross_zone) + }); + let supply_txs = config.genesis.iter().filter_map(|action| match action { + GenesisAction::SupplyAccount { + account_id, + balance, + } => Some(build_supply_account_genesis_transaction( + account_id, *balance, + )), + GenesisAction::SupplyBridgeAccount { balance } => { + Some(build_supply_bridge_account_genesis_transaction(*balance)) + } + // Seeded directly in `build_initial_state` (holdings via `build_holding_account`), not a + // genesis tx. + GenesisAction::SupplyBridgeLockHolding { .. } => None, + }); + + let genesis_txs = wrapped_token_config_tx + .chain(inbox_config_tx) + .chain(supply_txs) .chain(std::iter::once(clock_invocation(0))) .inspect(|tx| { state @@ -612,6 +1366,26 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec impl Iterator + '_ { + genesis.iter().filter_map(|action| match action { + GenesisAction::SupplyBridgeLockHolding { holder, amount } => Some((*holder, *amount)), + GenesisAction::SupplyAccount { .. } | GenesisAction::SupplyBridgeAccount { .. } => None, + }) +} + +/// Whether a program may only be invoked by sequencer-origin transactions. +/// +/// The cross-zone inbox is injected solely by the watcher; a user-submitted call +/// must be rejected at ingress, since `TransactionOrigin` is not carried in the +/// block. +#[must_use] +pub fn is_sequencer_only_program(program_id: lee::ProgramId) -> bool { + cross_zone::is_sequencer_only_program(program_id) +} + fn build_supply_account_genesis_transaction( account_id: &AccountId, balance: u128, @@ -658,22 +1432,29 @@ fn pending_deposit_event_record(deposit: &DepositInfo) -> PendingDepositEventRec source_tx_hash: HashType(deposit.tx_hash.0), amount: deposit.amount, metadata: deposit.metadata.clone().into(), - submitted_in_block_id: None, } } fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Result { - let metadata = DepositMetadata::decode(&event.metadata) + let metadata = DepositMetadata::try_from_slice(&event.metadata) .context("Failed to decode finalized Bedrock deposit metadata")?; let bridge_program_id = programs::bridge().id(); let vault_program_id = programs::vault().id(); let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, metadata.recipient_id); + // The receipt PDA carries the exactly-once check: the program reads it to + // detect a replay, so it must be in the tx's account list. + let receipt_id = + bridge_core::deposit_receipt_account_id(bridge_program_id, event.deposit_op_id.0); let message = Message::try_new( bridge_program_id, - vec![system_accounts::bridge_account_id(), recipient_vault_id], + vec![ + system_accounts::bridge_account_id(), + recipient_vault_id, + receipt_id, + ], vec![], bridge_core::Instruction::Deposit { l1_deposit_op_id: event.deposit_op_id.0, @@ -691,6 +1472,124 @@ fn build_bridge_deposit_tx_from_event(event: &PendingDepositEventRecord) -> Resu ))) } +/// User transactions of an orphaned block to return to the mempool: everything +/// except the trailing clock tx, sequencer-generated bridge deposits (replayed +/// from their own bedrock events) and sequencer-only cross-zone txs (replayed +/// by the watcher; the ingress guard rejects them as `User`). +fn resubmittable_txs(block: &Block) -> Vec { + let Some((_clock, rest)) = block.body.transactions.split_last() else { + return Vec::new(); + }; + rest.iter() + .filter(|tx| extract_bridge_deposit_id(tx).is_none() && !is_sequencer_only_tx(tx)) + .cloned() + .collect() +} + +#[must_use] +fn is_sequencer_only_tx(tx: &LeeTransaction) -> bool { + matches!(tx, LeeTransaction::Public(tx) + if is_sequencer_only_program(tx.message().program_id)) +} + +/// The cross-zone message an inbox dispatch delivers, or `None` if `tx` is not +/// a dispatch. +#[must_use] +fn extract_cross_zone_dispatch(tx: &LeeTransaction) -> Option { + let LeeTransaction::Public(tx) = tx else { + return None; + }; + + let message = tx.message(); + if message.program_id != programs::cross_zone_inbox().id() { + return None; + } + + match risc0_zkvm::serde::from_slice::( + &message.instruction_data, + ) { + Ok(cross_zone_inbox_core::Instruction::Dispatch(msg)) => Some(msg), + Ok(cross_zone_inbox_core::Instruction::InitConfig(_)) | Err(_) => None, + } +} + +/// The content-addressed key of the message an inbox dispatch delivers. +/// +/// A delivery in an irreversible block settles its pending record, so the record +/// is dropped by identity rather than by the height it happened to land at. +#[must_use] +fn extract_cross_zone_dispatch_key(tx: &LeeTransaction) -> Option<[u8; 32]> { + extract_cross_zone_dispatch(tx).map(|msg| { + cross_zone_inbox_core::message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index) + }) +} + +/// The keys of the deliveries `block` carries, reporting any whose transaction +/// is not the one we recorded for that key. +/// +/// The key covers `(src_zone, src_block_id, src_tx_index)` and nothing about the +/// payload, and so does the inbox's own replay check, so a sequencer that +/// publishes a dispatch with the right key and a forged payload settles our +/// correct record along with it. The forgery is caught downstream by the +/// indexer, which re-derives every delivery and halts, but the local record is +/// the last copy of what we believed and it is about to be dropped either way. +/// Saying so in the log is what makes the halt diagnosable. +fn settled_dispatch_keys(dbio: &RocksDBIO, block: &Block) -> Vec<[u8; 32]> { + let recorded = dbio.get_pending_cross_zone_dispatches().unwrap_or_default(); + let (keys, forged) = classify_settled_deliveries(&recorded, block); + for key in forged { + error!( + "Cross-zone delivery {} settled with a transaction that is not the one this node recorded for that key. The message key does not cover the payload, so a peer's sequencer can publish a different delivery under it.", + hex::encode(key) + ); + } + keys +} + +/// Splits the deliveries `block` carries into every settled key, and the subset +/// whose transaction is not the one `recorded` holds for that key. +/// +/// Separated from the logging so the detection is testable: a forged delivery +/// leaves no trace in state that differs from an honest one, precisely because +/// the key does not cover the payload. +fn classify_settled_deliveries( + recorded: &[PendingCrossZoneDispatchRecord], + block: &Block, +) -> (Vec<[u8; 32]>, Vec<[u8; 32]>) { + let mut keys = Vec::new(); + let mut forged = Vec::new(); + for tx in &block.body.transactions { + let Some(key) = extract_cross_zone_dispatch_key(tx) else { + continue; + }; + let mismatched = recorded + .iter() + .find(|record| record.message_key == key) + .is_some_and(|record| { + borsh::to_vec(tx).is_ok_and(|encoded| encoded != record.transaction) + }); + if mismatched { + forged.push(key); + } + keys.push(key); + } + (keys, forged) +} + +/// Drops the records of deliveries carried by a reconstructed block. +/// +/// A persist failure is only logged: the deliveries are already irreversible, so +/// the worst case is a record the next drain drops instead. +fn settle_reconstructed_deliveries(store: &SequencerStore, block: &Block) { + let keys = settled_dispatch_keys(&store.dbio(), block); + if keys.is_empty() { + return; + } + if let Err(err) = store.dbio().drop_settled_cross_zone_dispatches(&keys) { + warn!("Failed to settle reconstructed delivery records: {err:#}"); + } +} + #[must_use] fn extract_bridge_deposit_id(tx: &LeeTransaction) -> Option { let LeeTransaction::Public(tx) = tx else { @@ -748,39 +1647,25 @@ fn extract_bridge_withdraw_data(tx: &LeeTransaction) -> Option { }) } -fn withdraw_event_reconciliation_key( - outputs: &logos_blockchain_core::mantle::ledger::Outputs, -) -> Result { - let [note] = outputs.as_ref().as_slice() else { - return Err(anyhow!( - "Unsupported withdraw output count for reconciliation: {}", - outputs.len() - )); - }; - - // `extract_bridge_withdraw_data` maps [u8;32] LE -> BigUint -> ZkPublicKey. - // Reconcile by reversing that direction here. - let mut bedrock_account_pk = BigUint::from(note.pk.into_inner()).to_bytes_le(); - if bedrock_account_pk.len() > 32 { - return Err(anyhow!( - "Withdraw recipient public key is too large: {} bytes", - bedrock_account_pk.len() - )); - } - bedrock_account_pk.resize(32, 0); - - let bedrock_account_pk: [u8; 32] = bedrock_account_pk +/// The reconciliation identity of one released channel note. +/// +/// A `ChannelWithdrawOp` releases notes the channel already owns and carries +/// only their ids — the recipient key and value live in the note itself, which +/// neither the op nor the Bedrock Withdraw event reports. The note id is +/// therefore the one handle both sides share, and it is unique: a note is spent +/// once. +fn withdrawal_reconciliation_key(note_id: &NoteId) -> WithdrawalReconciliationKey { + let released_note_id: [u8; 32] = note_id + .as_bytes() + .as_ref() .try_into() - .expect("Public key bytes were padded/truncated to 32 bytes"); + .expect("`NoteId` is a 32-byte field element"); - Ok(WithdrawalReconciliationKey { - amount: note.value, - bedrock_account_pk, - }) + WithdrawalReconciliationKey { released_note_id } } /// Load signing key from file or generate a new one if it doesn't exist. -fn load_or_create_signing_key(path: &Path) -> Result { +pub fn load_or_create_signing_key(path: &Path) -> Result { if path.exists() { let key_bytes = std::fs::read(path)?; @@ -803,1248 +1688,4 @@ fn load_or_create_signing_key(path: &Path) -> Result { #[cfg(test)] #[cfg(feature = "mock")] -mod tests { - #![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")] - - use std::{pin::pin, time::Duration}; - - use common::{ - HashType, - block::HashableBlockData, - test_utils::sequencer_sign_key_for_testing, - transaction::{LeeTransaction, clock_invocation}, - }; - use key_protocol::key_management::KeyChain; - use lee::{ - Account, AccountId, Data, EphemeralPublicKey, PrivacyPreservingTransaction, PrivateKey, - PublicKey, PublicTransaction, SharedSecretKey, V03State, - error::LeeError, - execute_and_prove, - privacy_preserving_transaction::{Message, circuit::ProgramWithDependencies}, - program::Program, - }; - use lee_core::{ - Commitment, EncryptedAccountData, InputAccountIdentity, Nullifier, - account::{AccountWithMetadata, Nonce}, - program::PdaSeed, - }; - use logos_blockchain_core::mantle::ops::channel::ChannelId; - use mempool::MemPoolHandle; - use storage::sequencer::sequencer_cells::PendingDepositEventRecord; - use tempfile::tempdir; - use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts}; - - use crate::{ - TransactionOrigin, - block_store::SequencerStore, - build_genesis_state, - config::{BedrockConfig, SequencerConfig}, - mock::SequencerCoreWithMockClients, - }; - - #[derive(borsh::BorshSerialize)] - struct DepositMetadataForEncoding { - recipient_id: lee::AccountId, - } - - fn setup_sequencer_config() -> SequencerConfig { - let tempdir = tempfile::tempdir().unwrap(); - let home = tempdir.path().to_path_buf(); - - SequencerConfig { - home, - max_num_tx_in_block: 10, - max_block_size: bytesize::ByteSize::mib(1), - mempool_max_size: 10000, - block_create_timeout: Duration::from_secs(1), - signing_key: *sequencer_sign_key_for_testing().value(), - bedrock_config: BedrockConfig { - channel_id: ChannelId::from([0; 32]), - node_url: "http://not-used-in-unit-tests".parse().unwrap(), - auth: None, - }, - retry_pending_blocks_timeout: Duration::from_mins(4), - genesis: vec![], - } - } - - fn create_signing_key_for_account1() -> lee::PrivateKey { - initial_pub_accounts_private_keys()[0].pub_sign_key.clone() - } - - fn create_signing_key_for_account2() -> lee::PrivateKey { - initial_pub_accounts_private_keys()[1].pub_sign_key.clone() - } - - async fn common_setup() -> ( - SequencerCoreWithMockClients, - MemPoolHandle<(TransactionOrigin, LeeTransaction)>, - ) { - let config = setup_sequencer_config(); - common_setup_with_config(config).await - } - - async fn common_setup_with_config( - config: SequencerConfig, - ) -> ( - SequencerCoreWithMockClients, - MemPoolHandle<(TransactionOrigin, LeeTransaction)>, - ) { - let (mut sequencer, mempool_handle) = - SequencerCoreWithMockClients::start_from_config(config).await; - - let tx = common::test_utils::produce_dummy_empty_transaction(); - mempool_handle - .push((TransactionOrigin::User, tx)) - .await - .unwrap(); - - sequencer.produce_new_block().await.unwrap(); - - (sequencer, mempool_handle) - } - - fn tx_is_bridge_deposit( - tx: &LeeTransaction, - deposit_op_id: [u8; 32], - expected_amount: u64, - ) -> bool { - let LeeTransaction::Public(public_tx) = tx else { - return false; - }; - - if public_tx.message.program_id != programs::bridge().id() { - return false; - } - - let instruction: bridge_core::Instruction = - match risc0_zkvm::serde::from_slice(&public_tx.message.instruction_data) { - Ok(instruction) => instruction, - Err(_err) => return false, - }; - - matches!( - instruction, - bridge_core::Instruction::Deposit { - l1_deposit_op_id, - amount, - .. - } if l1_deposit_op_id == deposit_op_id && amount == expected_amount - ) - } - - #[tokio::test] - async fn start_from_config() { - let config = setup_sequencer_config(); - let (sequencer, _mempool_handle) = - SequencerCoreWithMockClients::start_from_config(config.clone()).await; - - assert_eq!(sequencer.chain_height, 1); - assert_eq!(sequencer.sequencer_config.max_num_tx_in_block, 10); - - let acc1_account_id = initial_public_user_accounts()[0].account_id; - let acc2_account_id = initial_public_user_accounts()[1].account_id; - - let balance_acc_1 = sequencer.state.get_account_by_id(acc1_account_id).balance; - let balance_acc_2 = sequencer.state.get_account_by_id(acc2_account_id).balance; - - assert_eq!(10000, balance_acc_1); - assert_eq!(20000, balance_acc_2); - } - - #[tokio::test] - async fn start_from_config_opens_existing_db_if_it_exists() { - let config = setup_sequencer_config(); - let temp_dir = tempdir().unwrap(); - let mut config = config; - config.home = temp_dir.path().to_path_buf(); - - let signing_key = lee::PrivateKey::try_new(config.signing_key).unwrap(); - let (genesis_state, genesis_txs) = build_genesis_state(&config); - let genesis_hashable_data = HashableBlockData { - block_id: 1, - transactions: genesis_txs, - prev_block_hash: HashType([0; 32]), - timestamp: 0, - }; - let genesis_block = genesis_hashable_data.into_pending_block(&signing_key); - - SequencerStore::create_db_with_genesis( - &config.home.join("rocksdb"), - &genesis_block, - &genesis_state, - signing_key, - ) - .unwrap(); - - let (sequencer, _mempool_handle) = - SequencerCoreWithMockClients::start_from_config(config).await; - assert_eq!(sequencer.chain_height, 1); - assert!(sequencer.store.latest_block_meta().is_ok()); - } - - #[should_panic(expected = "Failed to open database")] - #[tokio::test] - async fn start_from_config_panics_when_db_open_returns_non_not_found_error() { - let mut config = setup_sequencer_config(); - let temp_dir = tempdir().unwrap(); - config.home = temp_dir.path().to_path_buf(); - - let db_path = config.home.join("rocksdb"); - - std::fs::create_dir_all(&config.home).unwrap(); - // Force RocksDB open to fail with an IO error by placing a file at DB path. - std::fs::write(&db_path, b"not-a-directory").unwrap(); - - let _ = SequencerCoreWithMockClients::start_from_config(config).await; - } - - #[tokio::test] - async fn start_from_config_replays_unfulfilled_deposit_events_from_db() { - let config = setup_sequencer_config(); - let deposit_op_id = [13_u8; 32]; - let expected_amount = 1_u64; - let recipient_id = initial_public_user_accounts()[0].account_id; - - { - let (_sequencer, _mempool_handle) = - SequencerCoreWithMockClients::start_from_config(config.clone()).await; - } - - let pending_event = PendingDepositEventRecord { - deposit_op_id: HashType(deposit_op_id), - source_tx_hash: HashType([7_u8; 32]), - amount: expected_amount, - metadata: borsh::to_vec(&DepositMetadataForEncoding { recipient_id }).unwrap(), - submitted_in_block_id: None, - }; - - { - let signing_key = lee::PrivateKey::try_new(config.signing_key).unwrap(); - let store = SequencerStore::open_db(&config.home.join("rocksdb"), signing_key).unwrap(); - - let inserted = store - .dbio() - .add_pending_deposit_event(pending_event) - .unwrap(); - assert!(inserted); - } - - let (mut sequencer, _mempool_handle) = - SequencerCoreWithMockClients::start_from_config(config).await; - - let (origin, tx) = tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some((origin, tx)) = sequencer.mempool.pop() { - return (origin, tx); - } - - tokio::time::sleep(Duration::from_millis(100)).await; - } - }) - .await - .expect("Timed out waiting for pending deposit event to be replayed into mempool"); - - match origin { - TransactionOrigin::Sequencer => {} - TransactionOrigin::User => { - panic!("Unexpected user transaction in empty mempool replay test") - } - } - - assert!(tx_is_bridge_deposit(&tx, deposit_op_id, expected_amount)); - - let pending_events = sequencer.store.get_unfulfilled_deposit_events().unwrap(); - let replayed_event = pending_events - .into_iter() - .find(|event| event.deposit_op_id == HashType(deposit_op_id)) - .expect("Pending deposit event should remain in DB until included in a block"); - assert!(replayed_event.submitted_in_block_id.is_none()); - } - - #[test] - fn transaction_pre_check_pass() { - let tx = common::test_utils::produce_dummy_empty_transaction(); - let result = tx.transaction_stateless_check(); - - assert!(result.is_ok()); - } - - #[tokio::test] - async fn transaction_pre_check_native_transfer_valid() { - let (_sequencer, _mempool_handle) = common_setup().await; - - let acc1 = initial_public_user_accounts()[0].account_id; - let acc2 = initial_public_user_accounts()[1].account_id; - - let sign_key1 = create_signing_key_for_account1(); - - let tx = common::test_utils::create_transaction_native_token_transfer( - acc1, 0, acc2, 10, &sign_key1, - ); - let result = tx.transaction_stateless_check(); - - assert!(result.is_ok()); - } - - #[tokio::test] - async fn transaction_pre_check_native_transfer_other_signature() { - let (mut sequencer, _mempool_handle) = common_setup().await; - - let acc1 = initial_public_user_accounts()[0].account_id; - let acc2 = initial_public_user_accounts()[1].account_id; - - let sign_key2 = create_signing_key_for_account2(); - - let tx = common::test_utils::create_transaction_native_token_transfer( - acc1, 0, acc2, 10, &sign_key2, - ); - - // Signature is valid, stateless check pass - let tx = tx.transaction_stateless_check().unwrap(); - - // Signature is not from sender. Execution fails - let result = tx.execute_check_on_state(&mut sequencer.state, 0, 0); - - assert!(matches!( - result, - Err(lee::error::LeeError::ProgramExecutionFailed(_)) - )); - } - - #[tokio::test] - async fn transaction_pre_check_native_transfer_sent_too_much() { - let (mut sequencer, _mempool_handle) = common_setup().await; - - let acc1 = initial_public_user_accounts()[0].account_id; - let acc2 = initial_public_user_accounts()[1].account_id; - - let sign_key1 = create_signing_key_for_account1(); - - let tx = common::test_utils::create_transaction_native_token_transfer( - acc1, 0, acc2, 10_000_000, &sign_key1, - ); - - let result = tx.transaction_stateless_check(); - - // Passed pre-check - assert!(result.is_ok()); - - let result = result - .unwrap() - .execute_check_on_state(&mut sequencer.state, 0, 0); - let is_failed_at_balance_mismatch = matches!( - result.err().unwrap(), - lee::error::LeeError::ProgramExecutionFailed(_) - ); - - assert!(is_failed_at_balance_mismatch); - } - - #[tokio::test] - async fn transaction_execute_native_transfer() { - let (mut sequencer, _mempool_handle) = common_setup().await; - - let acc1 = initial_public_user_accounts()[0].account_id; - let acc2 = initial_public_user_accounts()[1].account_id; - - let sign_key1 = create_signing_key_for_account1(); - - let tx = common::test_utils::create_transaction_native_token_transfer( - acc1, 0, acc2, 100, &sign_key1, - ); - - tx.execute_check_on_state(&mut sequencer.state, 0, 0) - .unwrap(); - - let bal_from = sequencer.state.get_account_by_id(acc1).balance; - let bal_to = sequencer.state.get_account_by_id(acc2).balance; - - assert_eq!(bal_from, 9900); - assert_eq!(bal_to, 20100); - } - - #[tokio::test] - async fn push_tx_into_mempool_blocks_until_mempool_is_full() { - let config = SequencerConfig { - mempool_max_size: 1, - ..setup_sequencer_config() - }; - let (mut sequencer, mempool_handle) = common_setup_with_config(config).await; - - let tx = common::test_utils::produce_dummy_empty_transaction(); - - // Fill the mempool - mempool_handle - .push((TransactionOrigin::User, tx.clone())) - .await - .unwrap(); - - // Check that pushing another transaction will block - let mut push_fut = pin!(mempool_handle.push((TransactionOrigin::User, tx.clone()))); - let poll = futures::poll!(push_fut.as_mut()); - assert!(poll.is_pending()); - - // Empty the mempool by producing a block - sequencer.produce_new_block().await.unwrap(); - - // Resolve the pending push - assert!(push_fut.await.is_ok()); - } - - #[tokio::test] - async fn build_block_from_mempool() { - let (mut sequencer, mempool_handle) = common_setup().await; - let genesis_height = sequencer.chain_height; - - let tx = common::test_utils::produce_dummy_empty_transaction(); - mempool_handle - .push((TransactionOrigin::User, tx)) - .await - .unwrap(); - - let result = sequencer.build_block_from_mempool(); - assert!(result.is_ok()); - assert_eq!(sequencer.chain_height, genesis_height + 1); - } - - #[tokio::test] - async fn replay_transactions_are_rejected_in_the_same_block() { - let (mut sequencer, mempool_handle) = common_setup().await; - - let acc1 = initial_public_user_accounts()[0].account_id; - let acc2 = initial_public_user_accounts()[1].account_id; - - let sign_key1 = create_signing_key_for_account1(); - - let tx = common::test_utils::create_transaction_native_token_transfer( - acc1, 0, acc2, 100, &sign_key1, - ); - - let tx_original = tx.clone(); - let tx_replay = tx.clone(); - // Pushing two copies of the same tx to the mempool - mempool_handle - .push((TransactionOrigin::User, tx_original)) - .await - .unwrap(); - mempool_handle - .push((TransactionOrigin::User, tx_replay)) - .await - .unwrap(); - - // Create block - sequencer.produce_new_block().await.unwrap(); - let block = sequencer - .store - .get_block_at_id(sequencer.chain_height) - .unwrap() - .unwrap(); - - // Only one user tx should be included; the clock tx is always appended last. - assert_eq!( - block.body.transactions, - vec![ - tx.clone(), - LeeTransaction::Public(clock_invocation(block.header.timestamp)) - ] - ); - } - - #[tokio::test] - async fn replay_transactions_are_rejected_in_different_blocks() { - let (mut sequencer, mempool_handle) = common_setup().await; - - let acc1 = initial_public_user_accounts()[0].account_id; - let acc2 = initial_public_user_accounts()[1].account_id; - - let sign_key1 = create_signing_key_for_account1(); - - let tx = common::test_utils::create_transaction_native_token_transfer( - acc1, 0, acc2, 100, &sign_key1, - ); - - // The transaction should be included the first time - mempool_handle - .push((TransactionOrigin::User, tx.clone())) - .await - .unwrap(); - sequencer.produce_new_block().await.unwrap(); - let block = sequencer - .store - .get_block_at_id(sequencer.chain_height) - .unwrap() - .unwrap(); - assert_eq!( - block.body.transactions, - vec![ - tx.clone(), - LeeTransaction::Public(clock_invocation(block.header.timestamp)) - ] - ); - - // Add same transaction should fail - mempool_handle - .push((TransactionOrigin::User, tx.clone())) - .await - .unwrap(); - sequencer.produce_new_block().await.unwrap(); - let block = sequencer - .store - .get_block_at_id(sequencer.chain_height) - .unwrap() - .unwrap(); - // The replay is rejected, so only the clock tx is in the block. - assert_eq!( - block.body.transactions, - vec![LeeTransaction::Public(clock_invocation( - block.header.timestamp - ))] - ); - } - - #[tokio::test] - async fn restart_from_storage() { - let config = setup_sequencer_config(); - let acc1_account_id = initial_public_user_accounts()[0].account_id; - let acc2_account_id = initial_public_user_accounts()[1].account_id; - let balance_to_move = 13; - - // In the following code block a transaction will be processed that moves `balance_to_move` - // from `acc_1` to `acc_2`. The block created with that transaction will be kept stored in - // the temporary directory for the block storage of this test. - { - let (mut sequencer, mempool_handle) = - SequencerCoreWithMockClients::start_from_config(config.clone()).await; - let signing_key = create_signing_key_for_account1(); - - let tx = common::test_utils::create_transaction_native_token_transfer( - acc1_account_id, - 0, - acc2_account_id, - balance_to_move, - &signing_key, - ); - - mempool_handle - .push((TransactionOrigin::User, tx.clone())) - .await - .unwrap(); - sequencer.produce_new_block().await.unwrap(); - let block = sequencer - .store - .get_block_at_id(sequencer.chain_height) - .unwrap() - .unwrap(); - assert_eq!( - block.body.transactions, - vec![ - tx.clone(), - LeeTransaction::Public(clock_invocation(block.header.timestamp)) - ] - ); - } - - // Instantiating a new sequencer from the same config. This should load the existing block - // with the above transaction and update the state to reflect that. - let (sequencer, _mempool_handle) = - SequencerCoreWithMockClients::start_from_config(config.clone()).await; - let balance_acc_1 = sequencer.state.get_account_by_id(acc1_account_id).balance; - let balance_acc_2 = sequencer.state.get_account_by_id(acc2_account_id).balance; - - // Balances should be consistent with the stored block - assert_eq!( - balance_acc_1, - initial_public_user_accounts()[0].balance - balance_to_move - ); - assert_eq!( - balance_acc_2, - initial_public_user_accounts()[1].balance + balance_to_move - ); - } - - #[tokio::test] - async fn get_pending_blocks() { - let config = setup_sequencer_config(); - let (mut sequencer, _mempool_handle) = - SequencerCoreWithMockClients::start_from_config(config).await; - sequencer.produce_new_block().await.unwrap(); - sequencer.produce_new_block().await.unwrap(); - sequencer.produce_new_block().await.unwrap(); - assert_eq!(sequencer.get_pending_blocks().unwrap().len(), 4); - } - - #[tokio::test] - async fn delete_blocks() { - let config = setup_sequencer_config(); - let (mut sequencer, _mempool_handle) = - SequencerCoreWithMockClients::start_from_config(config).await; - sequencer.produce_new_block().await.unwrap(); - sequencer.produce_new_block().await.unwrap(); - sequencer.produce_new_block().await.unwrap(); - - let last_finalized_block = 3; - sequencer - .clean_finalized_blocks_from_db(last_finalized_block) - .unwrap(); - - assert_eq!(sequencer.get_pending_blocks().unwrap().len(), 1); - } - - #[tokio::test] - async fn produce_block_with_correct_prev_meta_after_restart() { - let config = setup_sequencer_config(); - let acc1_account_id = initial_public_user_accounts()[0].account_id; - let acc2_account_id = initial_public_user_accounts()[1].account_id; - - // Step 1: Create initial database with some block metadata - let expected_prev_meta = { - let (mut sequencer, mempool_handle) = - SequencerCoreWithMockClients::start_from_config(config.clone()).await; - - let signing_key = create_signing_key_for_account1(); - - // Add a transaction and produce a block to set up block metadata - let tx = common::test_utils::create_transaction_native_token_transfer( - acc1_account_id, - 0, - acc2_account_id, - 100, - &signing_key, - ); - - mempool_handle - .push((TransactionOrigin::User, tx)) - .await - .unwrap(); - sequencer.produce_new_block().await.unwrap(); - - // Get the metadata of the last block produced - sequencer.store.latest_block_meta().unwrap() - }; - - // Step 2: Restart sequencer from the same storage - let (mut sequencer, mempool_handle) = - SequencerCoreWithMockClients::start_from_config(config.clone()).await; - - // Step 3: Submit a new transaction - let signing_key = create_signing_key_for_account1(); - let tx = common::test_utils::create_transaction_native_token_transfer( - acc1_account_id, - 1, // Next nonce - acc2_account_id, - 50, - &signing_key, - ); - - mempool_handle - .push((TransactionOrigin::User, tx.clone())) - .await - .unwrap(); - - // Step 4: Produce new block - sequencer.produce_new_block().await.unwrap(); - - // Step 5: Verify the new block has correct previous block metadata - let new_block = sequencer - .store - .get_block_at_id(sequencer.chain_height) - .unwrap() - .unwrap(); - - assert_eq!( - new_block.header.prev_block_hash, expected_prev_meta.hash, - "New block's prev_block_hash should match the stored metadata hash" - ); - assert_eq!( - new_block.body.transactions, - vec![ - tx, - LeeTransaction::Public(clock_invocation(new_block.header.timestamp)) - ], - "New block should contain the submitted transaction and the clock invocation" - ); - } - - #[tokio::test] - async fn transactions_touching_clock_account_are_dropped_from_block() { - let (mut sequencer, mempool_handle) = common_setup().await; - - // Canonical clock invocation and a crafted variant with a different timestamp — both must - // be dropped because their diffs touch the clock accounts. - let crafted_clock_tx = { - let message = lee::public_transaction::Message::try_new( - programs::clock().id(), - system_accounts::clock_account_ids().to_vec(), - vec![], - 42_u64, - ) - .unwrap(); - LeeTransaction::Public(lee::PublicTransaction::new( - message, - lee::public_transaction::WitnessSet::from_raw_parts(vec![]), - )) - }; - mempool_handle - .push(( - TransactionOrigin::User, - LeeTransaction::Public(clock_invocation(0)), - )) - .await - .unwrap(); - mempool_handle - .push((TransactionOrigin::User, crafted_clock_tx)) - .await - .unwrap(); - sequencer.produce_new_block().await.unwrap(); - - let block = sequencer - .store - .get_block_at_id(sequencer.chain_height) - .unwrap() - .unwrap(); - - // Both transactions were dropped. Only the system-appended clock tx remains. - assert_eq!( - block.body.transactions, - vec![LeeTransaction::Public(clock_invocation( - block.header.timestamp - ))] - ); - } - - #[tokio::test] - async fn user_tx_that_chain_calls_clock_is_dropped() { - let (mut sequencer, mempool_handle) = common_setup().await; - - let clock_chain_caller = test_programs::clock_chain_caller(); - // Deploy the clock_chain_caller test program. - let deploy_tx = LeeTransaction::ProgramDeployment(lee::ProgramDeploymentTransaction::new( - lee::program_deployment_transaction::Message::new(clock_chain_caller.elf().to_owned()), - )); - mempool_handle - .push((TransactionOrigin::User, deploy_tx)) - .await - .unwrap(); - sequencer.produce_new_block().await.unwrap(); - - // Build a user transaction that invokes clock_chain_caller, which in turn chain-calls the - // clock program with the clock accounts. The sequencer should detect that the resulting - // state diff modifies clock accounts and drop the transaction. - let clock_chain_caller_id = test_programs::clock_chain_caller().id(); - let clock_program_id = programs::clock().id(); - let timestamp: u64 = 0; - - let message = lee::public_transaction::Message::try_new( - clock_chain_caller_id, - system_accounts::clock_account_ids().to_vec(), - vec![], // no signers - (clock_program_id, timestamp), - ) - .unwrap(); - let user_tx = LeeTransaction::Public(lee::PublicTransaction::new( - message, - lee::public_transaction::WitnessSet::from_raw_parts(vec![]), - )); - - mempool_handle - .push((TransactionOrigin::User, user_tx)) - .await - .unwrap(); - sequencer.produce_new_block().await.unwrap(); - - let block = sequencer - .store - .get_block_at_id(sequencer.chain_height) - .unwrap() - .unwrap(); - - // The user tx must have been dropped; only the mandatory clock invocation remains. - assert_eq!( - block.body.transactions, - vec![LeeTransaction::Public(clock_invocation( - block.header.timestamp - ))] - ); - } - - #[tokio::test] - async fn block_production_aborts_when_clock_account_data_is_corrupted() { - let (mut sequencer, mempool_handle) = common_setup().await; - - // Corrupt the clock 01 account data so the clock program panics on deserialization. - let clock_account_id = system_accounts::clock_account_ids()[0]; - let mut corrupted = sequencer.state.get_account_by_id(clock_account_id); - corrupted.data = vec![0xff; 3].try_into().unwrap(); - sequencer - .state - .force_insert_account(clock_account_id, corrupted); - - // Push a dummy transaction so the mempool is non-empty. - let tx = common::test_utils::produce_dummy_empty_transaction(); - mempool_handle - .push((TransactionOrigin::User, tx)) - .await - .unwrap(); - - // Block production must fail because the appended clock tx cannot execute. - let result = sequencer.produce_new_block().await; - assert!( - result.is_err(), - "Block production should abort when clock account data is corrupted" - ); - } - - #[test] - fn private_bridge_withdraw_invocation_is_dropped() { - let sender_keys = KeyChain::new_os_random(); - let sender_account_id = - AccountId::for_regular_private_account(&sender_keys.nullifier_public_key, 0); - let sender_private_account = Account { - program_owner: programs::authenticated_transfer().id(), - balance: 100, - nonce: Nonce(0xdead_beef), - data: Data::default(), - }; - let bridge_account_id = system_accounts::bridge_account_id(); - - let mut state = V03State::new() - .with_public_accounts([(bridge_account_id, system_accounts::bridge_account())]) - .with_private_accounts([( - Commitment::new(&sender_account_id, &sender_private_account), - Nullifier::for_account_initialization(&sender_account_id), - )]); - - let sender_commitment = Commitment::new(&sender_account_id, &sender_private_account); - - let sender_pre = AccountWithMetadata::new( - sender_private_account, - true, - (&sender_keys.nullifier_public_key, 0), - ); - let bridge_pre = AccountWithMetadata::new( - state.get_account_by_id(bridge_account_id), - false, - bridge_account_id, - ); - - let shared_secret = SharedSecretKey::encapsulate(&sender_keys.viewing_public_key).0; - - let instruction = Program::serialize_instruction(bridge_core::Instruction::Withdraw { - amount: 1, - bedrock_account_pk: [0; 32], - }) - .unwrap(); - - let program_with_deps = ProgramWithDependencies::new( - programs::bridge(), - [( - programs::authenticated_transfer().id(), - programs::authenticated_transfer(), - )] - .into(), - ); - - let (output, proof) = execute_and_prove( - vec![sender_pre, bridge_pre], - instruction, - vec![ - InputAccountIdentity::PrivateAuthorizedUpdate { - epk: EphemeralPublicKey(vec![12_u8; 1088]), - view_tag: EncryptedAccountData::compute_view_tag( - &sender_keys.nullifier_public_key, - &sender_keys.viewing_public_key, - ), - ssk: shared_secret, - nsk: sender_keys.private_key_holder.nullifier_secret_key, - membership_proof: state - .get_proof_for_commitment(&sender_commitment) - .expect("sender commitment must be in state"), - identifier: 0, - }, - InputAccountIdentity::Public, - ], - &program_with_deps, - ) - .expect("Execution should succeed"); - - let message = Message::try_from_circuit_output(vec![bridge_account_id], vec![], output) - .expect("Message construction should succeed"); - let witness_set = - lee::privacy_preserving_transaction::WitnessSet::for_message(&message, proof, &[]); - let tx = LeeTransaction::PrivacyPreserving(PrivacyPreservingTransaction::new( - message, - witness_set, - )); - let res = tx.execute_check_on_state(&mut state, 1, 0); - - assert!( - matches!(res, Err(LeeError::InvalidInput(_))), - "Bridge withdraw invocation should be rejected in private execution" - ); - } - - /// Builds a [`V03State`] with the clock program and `program` registered, the three clock - /// accounts initialized, and the clock advanced to `clock_timestamp` so that reads of the - /// `CLOCK_01` account observe it. - fn state_with_clock_and_program(program: Program, clock_timestamp: u64) -> V03State { - let mut state = V03State::new().with_programs([programs::clock(), program]); - for clock_id in system_accounts::clock_account_ids() { - state.force_insert_account(clock_id, system_accounts::clock_account()); - } - state - .transition_from_public_transaction( - &clock_invocation(clock_timestamp), - 1, - clock_timestamp, - ) - .expect("Clock invocation should advance the clock"); - state - } - - fn time_locked_transfer_transaction( - from: AccountId, - from_key: &PrivateKey, - from_nonce: u128, - to: AccountId, - clock_account_id: AccountId, - amount: u128, - deadline: u64, - ) -> PublicTransaction { - let program_id = test_programs::time_locked_transfer().id(); - let message = lee::public_transaction::Message::try_new( - program_id, - vec![from, to, clock_account_id], - vec![Nonce(from_nonce)], - (amount, deadline), - ) - .unwrap(); - let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[from_key]); - PublicTransaction::new(message, witness_set) - } - - #[test] - fn time_locked_transfer_succeeds_when_deadline_has_passed() { - let clock_timestamp = 600; - let mut state = - state_with_clock_and_program(test_programs::time_locked_transfer(), clock_timestamp); - - // The recipient must be a non-default account so the program may credit it without - // claiming it. - let recipient_id = AccountId::new([42; 32]); - state.force_insert_account( - recipient_id, - Account { - program_owner: programs::authenticated_transfer().id(), - ..Account::default() - }, - ); - - let key1 = PrivateKey::try_new([1; 32]).unwrap(); - let sender_id = AccountId::from(&PublicKey::new_from_private_key(&key1)); - state.force_insert_account( - sender_id, - Account { - program_owner: test_programs::time_locked_transfer().id(), - balance: 100, - ..Account::default() - }, - ); - - let amount = 100; - // Deadline is in the past relative to the clock, so the transfer is unlocked. - let deadline = 0; - - let tx = time_locked_transfer_transaction( - sender_id, - &key1, - 0, - recipient_id, - system_accounts::clock_account_ids()[0], - amount, - deadline, - ); - - state - .transition_from_public_transaction(&tx, 2, clock_timestamp) - .unwrap(); - - // Balances changed. - assert_eq!(state.get_account_by_id(sender_id).balance, 0); - assert_eq!(state.get_account_by_id(recipient_id).balance, 100); - } - - #[test] - fn time_locked_transfer_fails_when_deadline_is_in_the_future() { - let clock_timestamp = 600; - let mut state = - state_with_clock_and_program(test_programs::time_locked_transfer(), clock_timestamp); - - let recipient_id = AccountId::new([42; 32]); - state.force_insert_account( - recipient_id, - Account { - program_owner: programs::authenticated_transfer().id(), - ..Account::default() - }, - ); - - let key1 = PrivateKey::try_new([1; 32]).unwrap(); - let sender_id = AccountId::from(&PublicKey::new_from_private_key(&key1)); - state.force_insert_account( - sender_id, - Account { - program_owner: test_programs::time_locked_transfer().id(), - balance: 100, - ..Account::default() - }, - ); - - let amount = 100; - // Far-future deadline: the program panics because the clock has not reached it. - let deadline = u64::MAX; - - let tx = time_locked_transfer_transaction( - sender_id, - &key1, - 0, - recipient_id, - system_accounts::clock_account_ids()[0], - amount, - deadline, - ); - - let result = state.transition_from_public_transaction(&tx, 2, clock_timestamp); - - assert!( - result.is_err(), - "Transfer should fail when deadline is in the future" - ); - // Balances unchanged. - assert_eq!(state.get_account_by_id(sender_id).balance, 100); - assert_eq!(state.get_account_by_id(recipient_id).balance, 0); - } - - fn pinata_cooldown_data(prize: u128, cooldown_ms: u64, last_claim_timestamp: u64) -> Vec { - let mut buf = Vec::with_capacity(32); - buf.extend_from_slice(&prize.to_le_bytes()); - buf.extend_from_slice(&cooldown_ms.to_le_bytes()); - buf.extend_from_slice(&last_claim_timestamp.to_le_bytes()); - buf - } - - fn pinata_cooldown_transaction( - pinata_id: AccountId, - winner_id: AccountId, - clock_account_id: AccountId, - ) -> PublicTransaction { - let program_id = test_programs::pinata_cooldown().id(); - let message = lee::public_transaction::Message::try_new( - program_id, - vec![pinata_id, winner_id, clock_account_id], - vec![], - (), - ) - .unwrap(); - let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[]); - PublicTransaction::new(message, witness_set) - } - - #[test] - fn pinata_cooldown_claim_succeeds_after_cooldown() { - let winner_id = AccountId::new([11; 32]); - let pinata_id = AccountId::new([99; 32]); - - let genesis_timestamp = 1000; - let prize = 50; - let cooldown_ms = 500; - // Last claim was at genesis, so any timestamp >= genesis + cooldown should work. - let last_claim_timestamp = genesis_timestamp; - - // Advance the clock so the cooldown check reads an updated timestamp. - let block_timestamp = genesis_timestamp + cooldown_ms; - let mut state = - state_with_clock_and_program(test_programs::pinata_cooldown(), block_timestamp); - - // The winner must be a non-default account so the program may credit it without claiming. - state.force_insert_account( - winner_id, - Account { - program_owner: programs::authenticated_transfer().id(), - ..Account::default() - }, - ); - state.force_insert_account( - pinata_id, - Account { - program_owner: test_programs::pinata_cooldown().id(), - balance: 1000, - data: pinata_cooldown_data(prize, cooldown_ms, last_claim_timestamp) - .try_into() - .unwrap(), - ..Account::default() - }, - ); - - let tx = pinata_cooldown_transaction( - pinata_id, - winner_id, - system_accounts::clock_account_ids()[0], - ); - - state - .transition_from_public_transaction(&tx, 2, block_timestamp) - .unwrap(); - - assert_eq!(state.get_account_by_id(pinata_id).balance, 1000 - prize); - assert_eq!(state.get_account_by_id(winner_id).balance, prize); - } - - #[test] - fn pinata_cooldown_claim_fails_during_cooldown() { - let winner_id = AccountId::new([11; 32]); - let pinata_id = AccountId::new([99; 32]); - - let genesis_timestamp = 1000; - let prize = 50; - let cooldown_ms = 500; - let last_claim_timestamp = genesis_timestamp; - - // Timestamp is only 100ms after the last claim, well within the 500ms cooldown. - let block_timestamp = genesis_timestamp + 100; - let mut state = - state_with_clock_and_program(test_programs::pinata_cooldown(), block_timestamp); - - state.force_insert_account( - winner_id, - Account { - program_owner: programs::authenticated_transfer().id(), - ..Account::default() - }, - ); - state.force_insert_account( - pinata_id, - Account { - program_owner: test_programs::pinata_cooldown().id(), - balance: 1000, - data: pinata_cooldown_data(prize, cooldown_ms, last_claim_timestamp) - .try_into() - .unwrap(), - ..Account::default() - }, - ); - - let tx = pinata_cooldown_transaction( - pinata_id, - winner_id, - system_accounts::clock_account_ids()[0], - ); - - let result = state.transition_from_public_transaction(&tx, 2, block_timestamp); - - assert!(result.is_err(), "Claim should fail during cooldown period"); - assert_eq!(state.get_account_by_id(pinata_id).balance, 1000); - assert_eq!(state.get_account_by_id(winner_id).balance, 0); - } - - #[test] - fn pda_mechanism_with_pinata_token_program() { - let pinata_token = programs::pinata_token(); - let token = programs::token(); - - let pinata_definition_id = AccountId::new([1; 32]); - let pinata_token_definition_id = AccountId::new([2; 32]); - // Total supply of pinata token will be in an account under a PDA. - let pinata_token_holding_id = - AccountId::for_public_pda(&pinata_token.id(), &PdaSeed::new([0; 32])); - let winner_token_holding_id = AccountId::new([3; 32]); - - let expected_winner_account_holding = token_core::TokenHolding::Fungible { - definition_id: pinata_token_definition_id, - balance: 150, - }; - let expected_winner_token_holding_post = Account { - program_owner: token.id(), - data: Data::from(&expected_winner_account_holding), - ..Account::default() - }; - - // Register the pinata-token and token programs and create the pinata definition account. - // This replaces the removed `add_pinata_token_program` helper. - let mut state = V03State::new().with_programs([pinata_token.clone(), token.clone()]); - state.force_insert_account( - pinata_definition_id, - Account { - program_owner: pinata_token.id(), - // Difficulty: 3 - data: vec![3; 33].try_into().unwrap(), - ..Account::default() - }, - ); - - // Set up the token accounts directly (bypassing public transactions which - // would require signers for Claim::Authorized). The focus of this test is - // the PDA mechanism in the pinata program's chained call, not token creation. - let total_supply: u128 = 10_000_000; - let token_definition = token_core::TokenDefinition::Fungible { - name: String::from("PINATA"), - total_supply, - metadata_id: None, - }; - let token_holding = token_core::TokenHolding::Fungible { - definition_id: pinata_token_definition_id, - balance: total_supply, - }; - let winner_holding = token_core::TokenHolding::Fungible { - definition_id: pinata_token_definition_id, - balance: 0, - }; - state.force_insert_account( - pinata_token_definition_id, - Account { - program_owner: token.id(), - data: Data::from(&token_definition), - ..Account::default() - }, - ); - state.force_insert_account( - pinata_token_holding_id, - Account { - program_owner: token.id(), - data: Data::from(&token_holding), - ..Account::default() - }, - ); - state.force_insert_account( - winner_token_holding_id, - Account { - program_owner: token.id(), - data: Data::from(&winner_holding), - ..Account::default() - }, - ); - - // Submit a solution to the pinata program to claim the prize - let solution: u128 = 989_106; - let message = lee::public_transaction::Message::try_new( - pinata_token.id(), - vec![ - pinata_definition_id, - pinata_token_holding_id, - winner_token_holding_id, - ], - vec![], - solution, - ) - .unwrap(); - let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[]); - let tx = PublicTransaction::new(message, witness_set); - state.transition_from_public_transaction(&tx, 1, 0).unwrap(); - - let winner_token_holding_post = state.get_account_by_id(winner_token_holding_id); - assert_eq!( - winner_token_holding_post, - expected_winner_token_holding_post - ); - } -} +mod tests; diff --git a/lez/sequencer/core/src/mock.rs b/lez/sequencer/core/src/mock.rs index 39f635f9..b35e3be3 100644 --- a/lez/sequencer/core/src/mock.rs +++ b/lez/sequencer/core/src/mock.rs @@ -2,15 +2,20 @@ use std::time::Duration; use anyhow::Result; use common::block::Block; -use logos_blockchain_core::mantle::ops::channel::ChannelId; +use futures::Stream; +use logos_blockchain_core::{ + header::HeaderId, + mantle::{ + ledger::{NoteId, Utxo}, + ops::channel::{ChannelId, MsgId}, + }, +}; use logos_blockchain_key_management_system_service::keys::Ed25519Key; -use logos_blockchain_zone_sdk::sequencer::WithdrawArg; +use logos_blockchain_zone_sdk::{Slot, ZoneMessage, sequencer::WithdrawArg}; +use tokio_util::sync::CancellationToken; use crate::{ - block_publisher::{ - BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, OnDepositEventSink, - OnWithdrawEventSink, SequencerCheckpoint, - }, + block_publisher::{BlockPublisherTrait, OnFollowSink, PublishOutcome, SequencerCheckpoint}, config::BedrockConfig, }; @@ -19,6 +24,31 @@ pub type SequencerCoreWithMockClients = crate::SequencerCore #[derive(Clone)] pub struct MockBlockPublisher { channel_id: ChannelId, + // Never cancelled: the mock driver never dies. + driver_cancellation: CancellationToken, + /// Canned channel frontier returned by [`Self::channel_tip_slot`]. + tip_slot: Option, + /// Canned finalized channel history returned by [`Self::read_channel_after`]. + messages: Vec<(ZoneMessage, Slot)>, +} + +impl MockBlockPublisher { + /// Builds a mock publisher backed by a canned channel, for reconstruction + /// and consistency tests. The default (via [`BlockPublisherTrait::new`]) + /// serves an empty channel. + #[must_use] + pub fn with_canned_channel( + channel_id: ChannelId, + tip_slot: Option, + messages: Vec<(ZoneMessage, Slot)>, + ) -> Self { + Self { + channel_id, + driver_cancellation: CancellationToken::new(), + tip_slot, + messages, + } + } } impl BlockPublisherTrait for MockBlockPublisher { @@ -27,25 +57,87 @@ impl BlockPublisherTrait for MockBlockPublisher { _bedrock_signing_key: Ed25519Key, _resubmit_interval: Duration, _initial_checkpoint: Option, - _on_checkpoint: CheckpointSink, - _on_finalized_block: FinalizedBlockSink, - _on_deposit_event: OnDepositEventSink, - _on_withdraw_event: OnWithdrawEventSink, + _on_follow: OnFollowSink, ) -> Result { Ok(Self { channel_id: config.channel_id, + driver_cancellation: CancellationToken::new(), + // An existing but empty channel: `None` means *missing*, which the + // startup guard reads as a wiped Bedrock. Tests that want that say + // so via [`Self::with_canned_channel`]. + tip_slot: Some(Slot::from(0)), + messages: Vec::new(), }) } async fn publish_block( &self, - _block: &Block, - _bridge_withdrawals: Vec, - ) -> Result<()> { - Ok(()) + block: &Block, + withdrawals: Vec, + ) -> Result { + // Deterministic per-block id so head dedup behaves in tests. + // + // TODO: should we allow more "mockability" here? + Ok(PublishOutcome { + this_msg: MsgId::from(block.header.hash.0), + checkpoint: mock_checkpoint(), + released_notes: mock_released_notes(&withdrawals), + }) } fn channel_id(&self) -> ChannelId { self.channel_id } + + fn is_our_turn(&self) -> bool { + true + } + + fn driver_cancellation(&self) -> CancellationToken { + self.driver_cancellation.clone() + } + + async fn channel_tip_slot(&self) -> Result> { + Ok(self.tip_slot) + } + + async fn read_channel_after( + &self, + after_slot: Option, + ) -> Result + '_> { + // Mirror `next_messages`: `after_slot` is exclusive. + let messages = self + .messages + .iter() + .filter(move |(_, slot)| after_slot.is_none_or(|after| *slot > after)) + .cloned(); + Ok(futures::stream::iter(messages)) + } +} + +/// The notes the mock reports as released by `withdrawals`. +/// +/// Zone-sdk picks the actual channel notes to release, so a mock has to invent +/// them: one note id per requested output, derived from the output itself so +/// tests can recompute the reconciliation keys of a block they produced. +#[must_use] +pub(crate) fn mock_released_notes(withdrawals: &[WithdrawArg]) -> Vec { + withdrawals + .iter() + .flat_map(|withdraw| withdraw.outputs.into_iter().enumerate()) + .map(|(output_index, note)| Utxo::new([0; 32], output_index, *note).id()) + .collect() +} + +/// A zeroed checkpoint, for [`MockBlockPublisher::publish_block`] and for tests +/// building a [`crate::block_publisher::FollowUpdate`]. Tests only assert *that* +/// a checkpoint was persisted alongside its effects, never what is in it. +#[must_use] +pub(crate) fn mock_checkpoint() -> SequencerCheckpoint { + SequencerCheckpoint { + last_msg_id: MsgId::from([0; 32]), + pending_txs: Vec::new(), + lib: HeaderId::from([0; 32]), + lib_slot: Slot::from(0), + } } diff --git a/lez/sequencer/core/src/task_group.rs b/lez/sequencer/core/src/task_group.rs new file mode 100644 index 00000000..8572a62f --- /dev/null +++ b/lez/sequencer/core/src/task_group.rs @@ -0,0 +1,137 @@ +//! A set of background tasks that can be stopped and waited on. + +use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak}; + +use log::warn; +use storage::sequencer::RocksDBIO; +use tokio::task::JoinHandle; + +/// Background tasks owned by one component, stoppable on demand and stopped +/// anyway when the last handle goes away. +/// +/// `JoinHandle::abort` only *requests* cancellation, and dropping a handle +/// detaches rather than cancels, so neither on its own says when a task has +/// actually stopped. That matters because these tasks hold a store handle: +/// until they are gone the `RocksDB` lock is still held and a restarting +/// sequencer cannot reopen its home directory. [`TaskGroup::shutdown`] is the +/// answer to "have they stopped yet"; the `Drop` below stays as the best-effort +/// path for panics and tests that never call it. +/// +/// Cloneable so the owner can keep it (tying task lifetime to its own) while a +/// shutdown path elsewhere holds a clone. +#[derive(Clone, Default)] +pub struct TaskGroup(Arc); + +#[derive(Default)] +struct TaskGroupInner(Mutex>>); + +/// A weak handle to the store, for observing when it is finally closed. +/// +/// Every strong reference lives inside a task or a server that shutdown stops, +/// but the last drop runs on whichever thread owned it, not on the one awaiting +/// shutdown. Watching the count is the difference between knowing the database +/// file is closed and assuming it from another crate's drop order. +pub struct StoreRelease(Weak); + +impl StoreRelease { + #[must_use] + pub fn new(store: &Arc) -> Self { + Self(Arc::downgrade(store)) + } + + /// How many holders are left. Zero means the store is closed. + #[must_use] + pub fn holders(&self) -> usize { + self.0.strong_count() + } +} + +impl Drop for TaskGroupInner { + fn drop(&mut self) { + for task in Self::take(&self.0) { + task.abort(); + } + } +} + +impl TaskGroupInner { + /// Empties the handle list, so a second shutdown (or a drop after one) is a + /// no-op rather than a second abort. + fn handles(handles: &Mutex>>) -> MutexGuard<'_, Vec>> { + handles.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn take(handles: &Mutex>>) -> Vec> { + std::mem::take(&mut *Self::handles(handles)) + } +} + +impl TaskGroup { + /// Takes ownership of already-spawned tasks. + #[must_use] + pub fn new(handles: Vec>) -> Self { + Self(Arc::new(TaskGroupInner(Mutex::new(handles)))) + } + + /// Whether any task has ended on its own. + /// + /// These tasks run for the lifetime of the sequencer, so a finished one is a + /// task that panicked, and whatever it was doing is not happening any more. + #[must_use] + pub fn any_finished(&self) -> bool { + TaskGroupInner::handles(&self.0.0) + .iter() + .any(JoinHandle::is_finished) + } + + /// Stops every task and waits for it to finish. + /// + /// Returns only once the runtime has dropped each task's future, so whatever + /// they held (a store handle, a network client) is released by the time this + /// returns. Cancellation is the expected outcome, so it is not reported; a + /// panic is, since it means the task died on its own terms earlier. + pub async fn shutdown(&self) { + let handles = TaskGroupInner::take(&self.0.0); + for handle in handles { + handle.abort(); + if let Err(err) = handle.await + && err.is_panic() + { + warn!("Background task panicked before shutdown: {err}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn a_task_that_ends_on_its_own_is_visible() { + let group = TaskGroup::new(vec![tokio::spawn(async {})]); + // A watcher only ends by panicking, so "finished" is the signal that a + // peer's deliveries have stopped happening. + tokio::task::yield_now().await; + assert!(group.any_finished()); + + let running = TaskGroup::new(vec![tokio::spawn(std::future::pending())]); + assert!(!running.any_finished()); + } + + #[tokio::test] + async fn shutdown_ends_a_task_that_would_never_end_on_its_own() { + let group = TaskGroup::new(vec![tokio::spawn(std::future::pending())]); + + // The watchers and the drive task are infinite loops, so awaiting one + // without cancelling it first hangs here for ever. + tokio::time::timeout(std::time::Duration::from_secs(5), group.shutdown()) + .await + .expect("shutdown must not hang on a task that never finishes by itself"); + + // Shutting down twice is a no-op rather than a second abort. + tokio::time::timeout(std::time::Duration::from_secs(5), group.shutdown()) + .await + .expect("a second shutdown must return immediately"); + } +} diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs new file mode 100644 index 00000000..44778a22 --- /dev/null +++ b/lez/sequencer/core/src/tests.rs @@ -0,0 +1,2715 @@ +#![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")] + +use std::{pin::pin, time::Duration}; + +use common::{ + HashType, + block::{BedrockStatus, Block, HashableBlockData}, + test_utils::sequencer_sign_key_for_testing, + transaction::{LeeTransaction, clock_invocation}, +}; +use lee::{ + Account, AccountId, Data, PrivateKey, PublicKey, PublicTransaction, V03State, program::Program, +}; +use lee_core::{account::Nonce, program::PdaSeed}; +use logos_blockchain_core::{ + events::DepositRecreatedNotes, + mantle::{ + TxHash, + ledger::Inputs, + ops::channel::{ChannelId, MsgId, deposit::Metadata}, + }, +}; +use logos_blockchain_key_management_system_service::keys::ZkPublicKey; +use logos_blockchain_zone_sdk::sequencer::DepositInfo; +use mempool::MemPoolHandle; +use ping_core::{ReceiverInstruction, ping_record_pda}; +use storage::sequencer::sequencer_cells::{ + PendingCrossZoneDispatchRecord, PendingDepositEventRecord, +}; +use tempfile::tempdir; +use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts}; + +use crate::{ + MAX_DISPATCHES_PER_BLOCK, RETIRE_DISPATCH_AFTER_FAILURES, TransactionOrigin, + apply_follow_update, + block_publisher::FollowUpdate, + block_store::SequencerStore, + build_bridge_deposit_tx_from_event, build_genesis_state, classify_settled_deliveries, + config::{BedrockConfig, CrossZoneConfig, CrossZonePeer, GenesisAction, SequencerConfig}, + deposit_already_minted, dispatch_already_delivered, extract_cross_zone_dispatch, + extract_cross_zone_dispatch_key, is_sequencer_only_program, + mock::{SequencerCoreWithMockClients, mock_checkpoint}, + resubmittable_txs, +}; + +mod reconstruction; + +/// The peer zone a cross-zone test receives from. Distinct from the test +/// channel id (`[0; 32]`), which the inbox guest rejects as a source. +const PEER_ZONE: [u8; 32] = [0xbe_u8; 32]; + +#[derive(borsh::BorshSerialize)] +struct DepositMetadataForEncoding { + recipient_id: lee::AccountId, +} + +/// A follow update carrying nothing, to fill in the fields a test does not +/// exercise via `..empty_follow_update()`. +fn empty_follow_update() -> FollowUpdate { + FollowUpdate { + checkpoint: mock_checkpoint(), + adopted: Vec::new(), + orphaned: Vec::new(), + finalized: Vec::new(), + deposits: Vec::new(), + withdrawals: Vec::new(), + } +} + +fn setup_sequencer_config() -> SequencerConfig { + let tempdir = tempfile::tempdir().unwrap(); + let home = tempdir.path().to_path_buf(); + + SequencerConfig { + home, + max_num_tx_in_block: 10, + max_block_size: bytesize::ByteSize::mib(1), + mempool_max_size: 10000, + block_create_timeout: Duration::from_secs(1), + signing_key: *sequencer_sign_key_for_testing().value(), + bedrock_config: BedrockConfig { + channel_id: ChannelId::from([0; 32]), + node_url: "http://not-used-in-unit-tests".parse().unwrap(), + auth: None, + funding_key: ZkPublicKey::zero(), + }, + retry_pending_blocks_timeout: Duration::from_mins(4), + genesis: vec![], + cross_zone: None, + } +} + +#[test] +fn only_the_cross_zone_inbox_is_sequencer_only() { + assert!(is_sequencer_only_program(programs::cross_zone_inbox().id())); + assert!(!is_sequencer_only_program( + programs::cross_zone_outbox().id() + )); + assert!(!is_sequencer_only_program(programs::wrapped_token().id())); + assert!(!is_sequencer_only_program(programs::ping_sender().id())); + assert!(!is_sequencer_only_program(programs::clock().id())); +} + +fn create_signing_key_for_account1() -> lee::PrivateKey { + initial_pub_accounts_private_keys()[0].pub_sign_key.clone() +} + +fn create_signing_key_for_account2() -> lee::PrivateKey { + initial_pub_accounts_private_keys()[1].pub_sign_key.clone() +} + +async fn common_setup() -> ( + SequencerCoreWithMockClients, + MemPoolHandle<(TransactionOrigin, LeeTransaction)>, +) { + let config = setup_sequencer_config(); + common_setup_with_config(config).await +} + +async fn common_setup_with_config( + config: SequencerConfig, +) -> ( + SequencerCoreWithMockClients, + MemPoolHandle<(TransactionOrigin, LeeTransaction)>, +) { + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + + let tx = common::test_utils::produce_dummy_empty_transaction(); + mempool_handle + .push((TransactionOrigin::User, tx)) + .await + .unwrap(); + + sequencer.produce_new_block().await.unwrap(); + + (sequencer, mempool_handle) +} + +fn tx_is_bridge_deposit( + tx: &LeeTransaction, + deposit_op_id: [u8; 32], + expected_amount: u64, +) -> bool { + let LeeTransaction::Public(public_tx) = tx else { + return false; + }; + + if public_tx.message.program_id != programs::bridge().id() { + return false; + } + + let instruction: bridge_core::Instruction = + match risc0_zkvm::serde::from_slice(&public_tx.message.instruction_data) { + Ok(instruction) => instruction, + Err(_err) => return false, + }; + + matches!( + instruction, + bridge_core::Instruction::Deposit { + l1_deposit_op_id, + amount, + .. + } if l1_deposit_op_id == deposit_op_id && amount == expected_amount + ) +} + +/// A config that receives `ping_receiver` messages from [`PEER_ZONE`], so +/// `build_genesis_state` seeds the inbox config PDA and a delivery has an +/// allowlist to pass. +fn cross_zone_test_config() -> SequencerConfig { + SequencerConfig { + cross_zone: Some(CrossZoneConfig { + peers: vec![CrossZonePeer { + channel_id: PEER_ZONE, + allowed_targets: vec![programs::ping_receiver().id()], + expected_block_signing_pubkey: None, + }], + }), + ..setup_sequencer_config() + } +} + +/// A `ping_receiver::Record` instruction as risc0 words, little-endian: the wire +/// form an emitter on the peer zone puts in the message payload. +fn ping_payload(payload: &[u8]) -> Vec { + risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { + payload: payload.to_vec(), + }) + .expect("ping instruction serializes") + .iter() + .flat_map(|word| word.to_le_bytes()) + .collect() +} + +/// The dispatch transaction for a message at index 0 of [`PEER_ZONE`] block +/// `src_block_id`. Built through the same builder the watcher uses, so a change +/// to the encoding shows up here rather than passing silently. +fn dispatch_tx(src_block_id: u64, payload: Vec) -> LeeTransaction { + let receiver_id = programs::ping_receiver().id(); + LeeTransaction::Public(cross_zone::build_dispatch_from_emission( + PEER_ZONE, + src_block_id, + 0, + programs::ping_sender().id(), + receiver_id, + &[ping_record_pda(receiver_id).into_value()], + payload, + )) +} + +/// The pending record the watcher would leave behind for that dispatch. +fn dispatch_record(src_block_id: u64, payload: Vec) -> PendingCrossZoneDispatchRecord { + let tx = dispatch_tx(src_block_id, payload); + PendingCrossZoneDispatchRecord::recorded( + cross_zone_inbox_core::message_key(&PEER_ZONE, src_block_id, 0), + borsh::to_vec(&tx).expect("dispatch encodes"), + ) +} + +/// The message keys of the deliveries a block carries. +fn dispatches_in(block: &Block) -> Vec<[u8; 32]> { + block + .body + .transactions + .iter() + .filter_map(extract_cross_zone_dispatch_key) + .collect() +} + +/// The pending dispatch records a sequencer still holds. +fn pending_dispatches( + sequencer: &SequencerCoreWithMockClients, +) -> Vec { + sequencer + .store + .dbio() + .get_pending_cross_zone_dispatches() + .expect("pending dispatches readable") +} + +#[tokio::test] +async fn start_from_config() { + let config = setup_sequencer_config(); + let (sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + + assert_eq!(sequencer.chain_height(), 1); + assert_eq!(sequencer.sequencer_config.max_num_tx_in_block, 10); + + let acc1_account_id = initial_public_user_accounts()[0].account_id; + let acc2_account_id = initial_public_user_accounts()[1].account_id; + + let balance_acc_1 = sequencer.with_state(|s| s.get_account_by_id(acc1_account_id).balance); + let balance_acc_2 = sequencer.with_state(|s| s.get_account_by_id(acc2_account_id).balance); + + assert_eq!(10000, balance_acc_1); + assert_eq!(20000, balance_acc_2); +} + +#[tokio::test] +async fn start_from_config_opens_existing_db_if_it_exists() { + let config = setup_sequencer_config(); + let temp_dir = tempdir().unwrap(); + let mut config = config; + config.home = temp_dir.path().to_path_buf(); + + let signing_key = lee::PrivateKey::try_new(config.signing_key).unwrap(); + let (genesis_state, genesis_txs) = build_genesis_state(&config); + let genesis_hashable_data = HashableBlockData { + block_id: 1, + transactions: genesis_txs, + prev_block_hash: HashType([0; 32]), + timestamp: 0, + }; + let genesis_block = genesis_hashable_data.into_pending_block(&signing_key); + + SequencerStore::create_db_with_genesis( + &config.home.join("rocksdb"), + &genesis_block, + &genesis_state, + signing_key, + ) + .unwrap(); + + let (sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + assert_eq!(sequencer.chain_height(), 1); + assert!(sequencer.store.latest_block_meta().is_ok()); +} + +#[should_panic(expected = "Failed to open database")] +#[tokio::test] +async fn start_from_config_panics_when_db_open_returns_non_not_found_error() { + let mut config = setup_sequencer_config(); + let temp_dir = tempdir().unwrap(); + config.home = temp_dir.path().to_path_buf(); + + let db_path = config.home.join("rocksdb"); + + std::fs::create_dir_all(&config.home).unwrap(); + // Force RocksDB open to fail with an IO error by placing a file at DB path. + std::fs::write(&db_path, b"not-a-directory").unwrap(); + + let _ = SequencerCoreWithMockClients::start_from_config(config).await; +} + +#[tokio::test] +async fn unfulfilled_deposit_events_are_drained_from_the_store_on_production() { + let mut config = setup_sequencer_config(); + // The mint moves funds out of the bridge account, so it has to hold some. + config.genesis = vec![GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }]; + let deposit_op_id = [13_u8; 32]; + let expected_amount = 1_u64; + let recipient_id = initial_public_user_accounts()[0].account_id; + + { + let (_sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + } + + let pending_event = PendingDepositEventRecord { + deposit_op_id: HashType(deposit_op_id), + source_tx_hash: HashType([7_u8; 32]), + amount: expected_amount, + metadata: borsh::to_vec(&DepositMetadataForEncoding { recipient_id }).unwrap(), + }; + + { + let signing_key = lee::PrivateKey::try_new(config.signing_key).unwrap(); + let store = SequencerStore::open_db(&config.home.join("rocksdb"), signing_key).unwrap(); + + let inserted = store + .dbio() + .add_pending_deposit_event(pending_event) + .unwrap(); + assert!(inserted); + } + + // The mint never goes through the mempool: the record is the queue, and + // production drains it. That is what makes a restart — or a follow event + // arriving while a full mempool would have dropped the push — lossless. + let (mut sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + assert!( + sequencer.mempool.pop().is_none(), + "deposit mints are drained from the store, never queued in the mempool" + ); + + let block_id = sequencer.produce_new_block().await.unwrap(); + let block = sequencer + .store + .get_block_at_id(block_id) + .unwrap() + .expect("produced block is stored"); + assert!( + block + .body + .transactions + .iter() + .any(|tx| tx_is_bridge_deposit(tx, deposit_op_id, expected_amount)), + "the drained deposit mint should be included in the produced block" + ); + + // The record stays until its deposit finalizes; exactly-once is enforced by + // the receipt PDA now in head state, not by any marker on the record. + assert!( + sequencer + .store + .get_pending_deposit_events() + .unwrap() + .iter() + .any(|event| event.deposit_op_id == HashType(deposit_op_id)), + "the record remains until the deposit finalizes" + ); + assert!( + sequencer.with_state(|state| deposit_already_minted(state, HashType(deposit_op_id))), + "the deposit's receipt PDA marks it minted in head state" + ); +} + +#[tokio::test] +async fn a_drained_deposit_is_not_minted_twice_across_turns() { + let mut config = setup_sequencer_config(); + config.genesis = vec![GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }]; + let deposit_op_id = [17_u8; 32]; + let recipient_id = initial_public_user_accounts()[0].account_id; + + let (mut sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + sequencer + .store + .dbio() + .add_pending_deposit_event(PendingDepositEventRecord { + deposit_op_id: HashType(deposit_op_id), + source_tx_hash: HashType([7_u8; 32]), + amount: 1, + metadata: borsh::to_vec(&DepositMetadataForEncoding { recipient_id }).unwrap(), + }) + .unwrap(); + + let first = sequencer.produce_new_block().await.unwrap(); + let second = sequencer.produce_new_block().await.unwrap(); + + let minted_in = |block_id: u64| { + sequencer + .store + .get_block_at_id(block_id) + .unwrap() + .expect("produced block is stored") + .body + .transactions + .iter() + .filter(|tx| tx_is_bridge_deposit(tx, deposit_op_id, 1)) + .count() + }; + + assert_eq!(minted_in(first), 1); + assert_eq!( + minted_in(second), + 0, + "the receipt PDA from the first mint must keep the drain from re-minting" + ); +} + +#[tokio::test] +async fn an_orphaned_deposit_is_reminted_exactly_once_in_the_replacement() { + // Manifestation 2 from #639: a deposit-carrying block is orphaned. Recovery + // rests entirely on the receipt PDA reverting with the block — no requeue, + // no bookkeeping of our own — so the still-pending record is drained again + // on the next turn and the vault is credited exactly once across the reorg. + let mut config = setup_sequencer_config(); + config.genesis = vec![GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }]; + let recipient_id = initial_public_user_accounts()[0].account_id; + let deposit_op_id = [0x2c_u8; 32]; + let amount = 500_u64; + + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + sequencer + .store + .dbio() + .add_pending_deposit_event(PendingDepositEventRecord { + deposit_op_id: HashType(deposit_op_id), + source_tx_hash: HashType([7_u8; 32]), + amount, + metadata: borsh::to_vec(&DepositMetadataForEncoding { recipient_id }).unwrap(), + }) + .unwrap(); + + // Produce the block that mints the deposit; its receipt marks it minted. + sequencer.produce_new_block().await.unwrap(); + let minted_block = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + assert!( + sequencer.with_state(|s| deposit_already_minted(s, HashType(deposit_op_id))), + "the first mint claims the receipt in head state" + ); + + // Orphan that block. The receipt reverts with it — nothing else tracks the + // mint — so the deposit reads as unminted again. + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![], + orphaned: vec![(MsgId::from(minted_block.header.hash.0), minted_block)], + ..empty_follow_update() + }, + ); + assert_eq!(sequencer.chain_height(), 1, "the minting block is orphaned"); + assert!( + !sequencer.with_state(|s| deposit_already_minted(s, HashType(deposit_op_id))), + "the receipt reverts with the orphaned block" + ); + + // Next turn: the still-pending record is drained and re-minted on the new + // head, exactly once. + let replacement = sequencer.produce_new_block().await.unwrap(); + let mints = sequencer + .store + .get_block_at_id(replacement) + .unwrap() + .expect("replacement block is stored") + .body + .transactions + .iter() + .filter(|tx| tx_is_bridge_deposit(tx, deposit_op_id, amount)) + .count(); + assert_eq!( + mints, 1, + "the deposit is re-minted exactly once after the orphan" + ); + let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient_id); + assert_eq!( + sequencer.with_state(|s| s.get_account_by_id(vault_id).balance), + u128::from(amount), + "the vault is credited exactly once across the reorg" + ); +} + +#[tokio::test] +async fn a_replayed_deposit_mint_no_ops_in_the_guest() { + // Runs the bridge guest directly with a pre-existing receipt — the replay + // no-op branch the exactly-once guarantee rests on. The store drain filters + // duplicates out before the program executes, so this is the only test that + // reaches that branch; applying the same mint twice asserts the second is a + // no-op (credited once) rather than an error. + let mut config = setup_sequencer_config(); + config.genesis = vec![GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }]; + let recipient_id = initial_public_user_accounts()[0].account_id; + let deposit_op_id = [0x5a_u8; 32]; + let amount = 500_u64; + + let (sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + + let deposit_tx = build_bridge_deposit_tx_from_event(&PendingDepositEventRecord { + deposit_op_id: HashType(deposit_op_id), + source_tx_hash: HashType([7_u8; 32]), + amount, + metadata: borsh::to_vec(&DepositMetadataForEncoding { recipient_id }).unwrap(), + }) + .unwrap(); + let LeeTransaction::Public(public_tx) = &deposit_tx else { + panic!("bridge deposit tx is public"); + }; + + let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient_id); + let mut state = sequencer.chain().lock().unwrap().head_state().clone(); + + // First mint: claims the receipt and credits the recipient vault. + state + .transition_from_public_transaction(public_tx, 1, 0) + .expect("first mint executes"); + assert_eq!( + state.get_account_by_id(vault_id).balance, + u128::from(amount) + ); + assert!( + deposit_already_minted(&state, HashType(deposit_op_id)), + "the first mint claims the receipt PDA" + ); + + // Replay the identical mint. The guest sees the receipt already exists and + // no-ops instead of failing, so the vault is credited exactly once. + state + .transition_from_public_transaction(public_tx, 2, 0) + .expect("a replayed deposit is a no-op, not an error"); + assert_eq!( + state.get_account_by_id(vault_id).balance, + u128::from(amount), + "a replayed deposit must not re-credit the vault" + ); +} + +#[tokio::test] +async fn recorded_dispatches_are_drained_from_the_store_on_production() { + let payload = b"hello-cross-zone".to_vec(); + let record = dispatch_record(7, ping_payload(&payload)); + let key = record.message_key; + + let (mut sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await; + assert_eq!( + sequencer + .store + .dbio() + .add_pending_cross_zone_dispatches(vec![record]) + .unwrap(), + 1 + ); + + // The delivery never goes through the mempool: the record is the queue, and + // production drains it. That is what makes the window between the watcher's + // durable read cursor and a block carrying the dispatch survivable. + assert!( + sequencer.mempool.pop().is_none(), + "deliveries are drained from the store, never queued in the mempool" + ); + + let block_id = sequencer.produce_new_block().await.unwrap(); + let block = sequencer + .store + .get_block_at_id(block_id) + .unwrap() + .expect("produced block is stored"); + assert_eq!( + dispatches_in(&block), + vec![key], + "the drained delivery should be included in the produced block" + ); + + let record_id = ping_record_pda(programs::ping_receiver().id()); + assert_eq!( + sequencer.with_state(|state| state.get_account_by_id(record_id).data.into_inner()), + payload, + "the dispatch must reach its target program, not just sit in the block" + ); + + // The record stays until the delivery finalizes; re-delivery is prevented by + // the inbox seen-set now in head state, not by any marker on the record. + assert_eq!( + pending_dispatches(&sequencer) + .iter() + .map(|record| record.message_key) + .collect::>(), + vec![key], + "the record remains until the delivery becomes irreversible" + ); +} + +#[tokio::test] +async fn a_delivered_dispatch_is_skipped_on_the_next_turn() { + // The seen-set is what replaces the submitted mark: the drain asks the state + // it is building on whether the inbox has already taken this message, so a + // record that outlives its delivery costs one skipped drain, not a replay. + let record = dispatch_record(11, ping_payload(b"once")); + let key = record.message_key; + + let (mut sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await; + sequencer + .store + .dbio() + .add_pending_cross_zone_dispatches(vec![record]) + .unwrap(); + + let first = sequencer.produce_new_block().await.unwrap(); + let second = sequencer.produce_new_block().await.unwrap(); + + let delivered_in = |block_id: u64| { + dispatches_in( + &sequencer + .store + .get_block_at_id(block_id) + .unwrap() + .expect("produced block is stored"), + ) + }; + assert_eq!(delivered_in(first), vec![key]); + assert!( + delivered_in(second).is_empty(), + "the inbox seen-set must keep the drain from re-delivering" + ); + + let message = extract_cross_zone_dispatch(&dispatch_tx(11, ping_payload(b"once"))) + .expect("the dispatch carries a cross-zone message"); + assert!( + sequencer.with_state(|state| dispatch_already_delivered(state, &message)), + "the seen shard in head state is what the skip reads" + ); +} + +#[tokio::test] +async fn a_dispatch_that_never_executes_is_given_up_on_after_repeated_failures() { + // A payload that is not `u32`-aligned: the inbox guest rejects it outright, + // so this is a delivery that can never execute however often it is retried. + // Its content is chosen on the peer zone and validated by nobody in between, + // so without a give-up policy it would fail on every block for ever. + let record = dispatch_record(13, b"odd".to_vec()); + + let (mut sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await; + sequencer + .store + .dbio() + .add_pending_cross_zone_dispatches(vec![record]) + .unwrap(); + + for attempt in 1..RETIRE_DISPATCH_AFTER_FAILURES { + let block_id = sequencer.produce_new_block().await.unwrap(); + let block = sequencer + .store + .get_block_at_id(block_id) + .unwrap() + .expect("produced block is stored"); + assert!( + dispatches_in(&block).is_empty(), + "a dispatch that fails to execute must not reach the block" + ); + + let records = pending_dispatches(&sequencer); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].failed_attempts, attempt, + "the counter advances once per block, not once per process start" + ); + } + + // The attempt at the limit gives up on it, and giving up drops the record. + // Anything else leaves an entry no later block can ever remove, which is how + // a peer that can make deliveries fail would grow this list without bound. + sequencer.produce_new_block().await.unwrap(); + assert!( + pending_dispatches(&sequencer).is_empty(), + "giving up on a delivery must drop its record, not flag it" + ); + + // And nothing re-feeds it, so it stops costing a guest execution per block. + let block_id = sequencer.produce_new_block().await.unwrap(); + let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap(); + assert!(dispatches_in(&block).is_empty()); + assert!(pending_dispatches(&sequencer).is_empty()); +} + +#[tokio::test] +async fn a_redelivered_record_is_dropped_once_its_delivery_is_irreversible() { + // The watcher persists its floor only at slot boundaries, so a crash inside + // a slot makes the next run re-read it and re-record deliveries that have + // already settled. Their keys are in the inbox seen-set for good, so no + // future block will ever carry them and the settlement path cannot reach + // them. The drain dropping them is the only thing that does. + let record = dispatch_record(29, ping_payload(b"again")); + let key = record.message_key; + + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await; + sequencer + .store + .dbio() + .add_pending_cross_zone_dispatches(vec![record.clone()]) + .unwrap(); + + let block_id = sequencer.produce_new_block().await.unwrap(); + let delivery_block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap(); + assert_eq!(dispatches_in(&delivery_block), vec![key]); + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + finalized: vec![(MsgId::from(delivery_block.header.hash.0), delivery_block)], + ..empty_follow_update() + }, + ); + assert!(pending_dispatches(&sequencer).is_empty()); + + // The watcher re-reads the slot and records it again. + sequencer + .store + .dbio() + .add_pending_cross_zone_dispatches(vec![record]) + .unwrap(); + assert_eq!(pending_dispatches(&sequencer).len(), 1); + + let block_id = sequencer.produce_new_block().await.unwrap(); + let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap(); + assert!( + dispatches_in(&block).is_empty(), + "the delivery is already on the chain, so it must not be delivered again" + ); + assert!( + pending_dispatches(&sequencer).is_empty(), + "a record whose delivery is already irreversible must be dropped, not kept for ever" + ); +} + +#[tokio::test] +async fn a_delivery_still_reversible_keeps_its_record() { + // The counterpart to the test above, and the reason the drain checks two + // states rather than one. In head but not yet final means the delivery can + // still orphan, so skipping it is right but dropping its record would lose + // the delivery when it does. + let record = dispatch_record(31, ping_payload(b"pending")); + let key = record.message_key; + + let (mut sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await; + sequencer + .store + .dbio() + .add_pending_cross_zone_dispatches(vec![record]) + .unwrap(); + + sequencer.produce_new_block().await.unwrap(); + sequencer.produce_new_block().await.unwrap(); + + assert_eq!( + pending_dispatches(&sequencer) + .iter() + .map(|record| record.message_key) + .collect::>(), + vec![key], + "nothing has finalized, so the record must survive in case the block orphans" + ); +} + +#[test] +fn a_settled_delivery_that_is_not_the_one_we_recorded_is_reported() { + // The message key covers (src_zone, src_block_id, src_tx_index) and nothing + // about the payload, and so does the inbox's own replay check. So a peer's + // sequencer can publish a delivery under a key we hold with a payload we + // never saw, and it settles our correct record along with it. The indexer + // catches the forgery and halts; this record is the last local copy of what + // we believed, so the mismatch has to be reported before it is dropped. + let honest = dispatch_record(53, ping_payload(b"honest")); + let key = honest.message_key; + let forged = dispatch_tx(53, ping_payload(b"forged")); + assert_eq!( + extract_cross_zone_dispatch_key(&forged), + Some(key), + "the forged delivery must share the key, or it proves nothing" + ); + + let block = common::test_utils::produce_dummy_block(2, None, vec![forged]); + let (keys, mismatched) = classify_settled_deliveries(std::slice::from_ref(&honest), &block); + assert_eq!(keys, vec![key], "the record is settled either way"); + assert_eq!( + mismatched, + vec![key], + "a delivery that differs from the one recorded under that key must be reported" + ); + + // The honest case must stay quiet, or the report is noise. + let honest_block = common::test_utils::produce_dummy_block( + 2, + None, + vec![dispatch_tx(53, ping_payload(b"honest"))], + ); + let (keys, mismatched) = classify_settled_deliveries(&[honest], &honest_block); + assert_eq!(keys, vec![key]); + assert!(mismatched.is_empty()); +} + +#[tokio::test] +async fn a_delivery_too_large_for_any_block_does_not_stall_production() { + // A store-drained transaction is at the head of the queue every turn, so one + // that cannot fit in any block would defer itself for ever and, because the + // deferral breaks the loop, stop production ever reaching the mempool behind + // it. The peer chooses the payload, so this is theirs to trigger. + let record = dispatch_record(41, ping_payload(&[7_u8; 8192])); + + let mut config = cross_zone_test_config(); + config.max_block_size = bytesize::ByteSize::kib(4); + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + sequencer + .store + .dbio() + .add_pending_cross_zone_dispatches(vec![record]) + .unwrap(); + + let user_tx = common::test_utils::create_transaction_native_token_transfer( + initial_public_user_accounts()[0].account_id, + 0, + initial_public_user_accounts()[1].account_id, + 10, + &create_signing_key_for_account1(), + ); + mempool_handle + .push((TransactionOrigin::User, user_tx.clone())) + .await + .unwrap(); + + // Production must get past it to the mempool in the very first block. + let block_id = sequencer.produce_new_block().await.unwrap(); + let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap(); + assert!( + block.body.transactions.contains(&user_tx), + "an oversized drained delivery must not stop production reaching the mempool" + ); + assert!(dispatches_in(&block).is_empty()); + + // And it is given up on rather than retried for ever. + for _ in 1..RETIRE_DISPATCH_AFTER_FAILURES { + sequencer.produce_new_block().await.unwrap(); + } + assert!( + pending_dispatches(&sequencer).is_empty(), + "a delivery that fits in no block must be given up on" + ); +} + +#[tokio::test] +async fn a_delivery_backlog_is_spread_across_blocks() { + // Each delivery costs a guest execution and peers decide how many queue up, + // so an unbounded drain would let a backlog decide how long a block takes to + // build and leave no room for user work, since store-drained transactions + // are taken before the mempool. + let backlog = MAX_DISPATCHES_PER_BLOCK + 3; + let records: Vec<_> = (0..backlog) + .map(|index| { + let src_block_id = 100 + u64::try_from(index).expect("test index fits"); + dispatch_record(src_block_id, ping_payload(b"backlog")) + }) + .collect(); + + let mut config = cross_zone_test_config(); + config.max_num_tx_in_block = backlog + 10; + let (mut sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + sequencer + .store + .dbio() + .add_pending_cross_zone_dispatches(records) + .unwrap(); + + let block_id = sequencer.produce_new_block().await.unwrap(); + let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap(); + assert_eq!( + dispatches_in(&block).len(), + MAX_DISPATCHES_PER_BLOCK, + "one block must not carry an unbounded number of deliveries" + ); + + // Deferred, not dropped: the rest go in the next block. + let block_id = sequencer.produce_new_block().await.unwrap(); + let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap(); + assert_eq!(dispatches_in(&block).len(), 3); +} + +#[test] +fn transaction_pre_check_pass() { + let tx = common::test_utils::produce_dummy_empty_transaction(); + let result = tx.transaction_stateless_check(); + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn transaction_pre_check_native_transfer_valid() { + let (_sequencer, _mempool_handle) = common_setup().await; + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + + let sign_key1 = create_signing_key_for_account1(); + + let tx = + common::test_utils::create_transaction_native_token_transfer(acc1, 0, acc2, 10, &sign_key1); + let result = tx.transaction_stateless_check(); + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn transaction_pre_check_native_transfer_other_signature() { + let (sequencer, _mempool_handle) = common_setup().await; + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + + let sign_key2 = create_signing_key_for_account2(); + + let tx = + common::test_utils::create_transaction_native_token_transfer(acc1, 0, acc2, 10, &sign_key2); + + // Signature is valid, stateless check pass + let tx = tx.transaction_stateless_check().unwrap(); + + // Signature is not from sender. Execution fails + let result = tx.execute_check_on_state( + sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .head_state_mut(), + 0, + 0, + ); + + assert!(matches!( + result, + Err(lee::error::LeeError::ProgramExecutionFailed(_)) + )); +} + +#[tokio::test] +async fn transaction_pre_check_native_transfer_sent_too_much() { + let (sequencer, _mempool_handle) = common_setup().await; + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + + let sign_key1 = create_signing_key_for_account1(); + + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, 0, acc2, 10_000_000, &sign_key1, + ); + + let result = tx.transaction_stateless_check(); + + // Passed pre-check + assert!(result.is_ok()); + + let result = result.unwrap().execute_check_on_state( + sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .head_state_mut(), + 0, + 0, + ); + let is_failed_at_balance_mismatch = matches!( + result.err().unwrap(), + lee::error::LeeError::ProgramExecutionFailed(_) + ); + + assert!(is_failed_at_balance_mismatch); +} + +#[tokio::test] +async fn transaction_execute_native_transfer() { + let (sequencer, _mempool_handle) = common_setup().await; + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + + let sign_key1 = create_signing_key_for_account1(); + + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, 0, acc2, 100, &sign_key1, + ); + + tx.execute_check_on_state( + sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .head_state_mut(), + 0, + 0, + ) + .unwrap(); + + let bal_from = sequencer.with_state(|s| s.get_account_by_id(acc1).balance); + let bal_to = sequencer.with_state(|s| s.get_account_by_id(acc2).balance); + + assert_eq!(bal_from, 9900); + assert_eq!(bal_to, 20100); +} + +#[tokio::test] +async fn push_tx_into_mempool_blocks_until_mempool_is_full() { + let config = SequencerConfig { + mempool_max_size: 1, + ..setup_sequencer_config() + }; + let (mut sequencer, mempool_handle) = common_setup_with_config(config).await; + + let tx = common::test_utils::produce_dummy_empty_transaction(); + + // Fill the mempool + mempool_handle + .push((TransactionOrigin::User, tx.clone())) + .await + .unwrap(); + + // Check that pushing another transaction will block + let mut push_fut = pin!(mempool_handle.push((TransactionOrigin::User, tx.clone()))); + let poll = futures::poll!(push_fut.as_mut()); + assert!(poll.is_pending()); + + // Empty the mempool by producing a block + sequencer.produce_new_block().await.unwrap(); + + // Resolve the pending push + assert!(push_fut.await.is_ok()); +} + +#[tokio::test] +async fn build_block_from_mempool() { + let (mut sequencer, mempool_handle) = common_setup().await; + let genesis_height = sequencer.chain_height(); + + let tx = common::test_utils::produce_dummy_empty_transaction(); + mempool_handle + .push((TransactionOrigin::User, tx)) + .await + .unwrap(); + + let result = sequencer.build_block_from_mempool(); + assert!(result.is_ok()); + // Building itself does not advance the head; only apply-after-publish does. + assert_eq!(sequencer.chain_height(), genesis_height); +} + +#[tokio::test] +async fn replay_transactions_are_rejected_in_the_same_block() { + let (mut sequencer, mempool_handle) = common_setup().await; + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + + let sign_key1 = create_signing_key_for_account1(); + + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, 0, acc2, 100, &sign_key1, + ); + + let tx_original = tx.clone(); + let tx_replay = tx.clone(); + // Pushing two copies of the same tx to the mempool + mempool_handle + .push((TransactionOrigin::User, tx_original)) + .await + .unwrap(); + mempool_handle + .push((TransactionOrigin::User, tx_replay)) + .await + .unwrap(); + + // Create block + sequencer.produce_new_block().await.unwrap(); + let block = sequencer + .store + .get_block_at_id(sequencer.chain_height()) + .unwrap() + .unwrap(); + + // Only one user tx should be included; the clock tx is always appended last. + assert_eq!( + block.body.transactions, + vec![ + tx.clone(), + LeeTransaction::Public(clock_invocation(block.header.timestamp)) + ] + ); +} + +#[tokio::test] +async fn replay_transactions_are_rejected_in_different_blocks() { + let (mut sequencer, mempool_handle) = common_setup().await; + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + + let sign_key1 = create_signing_key_for_account1(); + + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, 0, acc2, 100, &sign_key1, + ); + + // The transaction should be included the first time + mempool_handle + .push((TransactionOrigin::User, tx.clone())) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + let block = sequencer + .store + .get_block_at_id(sequencer.chain_height()) + .unwrap() + .unwrap(); + assert_eq!( + block.body.transactions, + vec![ + tx.clone(), + LeeTransaction::Public(clock_invocation(block.header.timestamp)) + ] + ); + + // Add same transaction should fail + mempool_handle + .push((TransactionOrigin::User, tx.clone())) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + let block = sequencer + .store + .get_block_at_id(sequencer.chain_height()) + .unwrap() + .unwrap(); + // The replay is rejected, so only the clock tx is in the block. + assert_eq!( + block.body.transactions, + vec![LeeTransaction::Public(clock_invocation( + block.header.timestamp + ))] + ); +} + +#[tokio::test] +async fn restart_from_storage() { + let config = setup_sequencer_config(); + let acc1_account_id = initial_public_user_accounts()[0].account_id; + let acc2_account_id = initial_public_user_accounts()[1].account_id; + let balance_to_move = 13; + + // In the following code block a transaction will be processed that moves `balance_to_move` + // from `acc_1` to `acc_2`. The block created with that transaction will be kept stored in + // the temporary directory for the block storage of this test. + { + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + let signing_key = create_signing_key_for_account1(); + + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1_account_id, + 0, + acc2_account_id, + balance_to_move, + &signing_key, + ); + + mempool_handle + .push((TransactionOrigin::User, tx.clone())) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + let block = sequencer + .store + .get_block_at_id(sequencer.chain_height()) + .unwrap() + .unwrap(); + assert_eq!( + block.body.transactions, + vec![ + tx.clone(), + LeeTransaction::Public(clock_invocation(block.header.timestamp)) + ] + ); + } + + // Instantiating a new sequencer from the same config. This should load the existing block + // with the above transaction and update the state to reflect that. + let (sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + let balance_acc_1 = sequencer.with_state(|s| s.get_account_by_id(acc1_account_id).balance); + let balance_acc_2 = sequencer.with_state(|s| s.get_account_by_id(acc2_account_id).balance); + + // Balances should be consistent with the stored block + assert_eq!( + balance_acc_1, + initial_public_user_accounts()[0].balance - balance_to_move + ); + assert_eq!( + balance_acc_2, + initial_public_user_accounts()[1].balance + balance_to_move + ); +} + +#[tokio::test] +async fn get_pending_blocks() { + let config = setup_sequencer_config(); + let (mut sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + sequencer.produce_new_block().await.unwrap(); + sequencer.produce_new_block().await.unwrap(); + sequencer.produce_new_block().await.unwrap(); + assert_eq!(sequencer.get_pending_blocks().unwrap().len(), 4); +} + +#[tokio::test] +async fn delete_blocks() { + let config = setup_sequencer_config(); + let (mut sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + sequencer.produce_new_block().await.unwrap(); + sequencer.produce_new_block().await.unwrap(); + sequencer.produce_new_block().await.unwrap(); + + let last_finalized_block = 3; + sequencer + .clean_finalized_blocks_from_db(last_finalized_block) + .unwrap(); + + assert_eq!(sequencer.get_pending_blocks().unwrap().len(), 1); +} + +#[tokio::test] +async fn produce_block_with_correct_prev_meta_after_restart() { + let config = setup_sequencer_config(); + let acc1_account_id = initial_public_user_accounts()[0].account_id; + let acc2_account_id = initial_public_user_accounts()[1].account_id; + + // Step 1: Create initial database with some block metadata + let expected_prev_meta = { + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + + let signing_key = create_signing_key_for_account1(); + + // Add a transaction and produce a block to set up block metadata + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1_account_id, + 0, + acc2_account_id, + 100, + &signing_key, + ); + + mempool_handle + .push((TransactionOrigin::User, tx)) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + + // Get the metadata of the last block produced + sequencer.store.latest_block_meta().unwrap().unwrap() + }; + + // Step 2: Restart sequencer from the same storage + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + + // Step 3: Submit a new transaction + let signing_key = create_signing_key_for_account1(); + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1_account_id, + 1, // Next nonce + acc2_account_id, + 50, + &signing_key, + ); + + mempool_handle + .push((TransactionOrigin::User, tx.clone())) + .await + .unwrap(); + + // Step 4: Produce new block + sequencer.produce_new_block().await.unwrap(); + + // Step 5: Verify the new block has correct previous block metadata + let new_block = sequencer + .store + .get_block_at_id(sequencer.chain_height()) + .unwrap() + .unwrap(); + + assert_eq!( + new_block.header.prev_block_hash, expected_prev_meta.hash, + "New block's prev_block_hash should match the stored metadata hash" + ); + assert_eq!( + new_block.body.transactions, + vec![ + tx, + LeeTransaction::Public(clock_invocation(new_block.header.timestamp)) + ], + "New block should contain the submitted transaction and the clock invocation" + ); +} + +#[tokio::test] +async fn transactions_touching_clock_account_are_dropped_from_block() { + let (mut sequencer, mempool_handle) = common_setup().await; + + // Canonical clock invocation and a crafted variant with a different timestamp — both must + // be dropped because their diffs touch the clock accounts. + let crafted_clock_tx = { + let message = lee::public_transaction::Message::try_new( + programs::clock().id(), + system_accounts::clock_account_ids().to_vec(), + vec![], + 42_u64, + ) + .unwrap(); + LeeTransaction::Public(lee::PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + )) + }; + mempool_handle + .push(( + TransactionOrigin::User, + LeeTransaction::Public(clock_invocation(0)), + )) + .await + .unwrap(); + mempool_handle + .push((TransactionOrigin::User, crafted_clock_tx)) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + + let block = sequencer + .store + .get_block_at_id(sequencer.chain_height()) + .unwrap() + .unwrap(); + + // Both transactions were dropped. Only the system-appended clock tx remains. + assert_eq!( + block.body.transactions, + vec![LeeTransaction::Public(clock_invocation( + block.header.timestamp + ))] + ); +} + +#[tokio::test] +async fn user_tx_that_chain_calls_clock_is_dropped() { + let (mut sequencer, mempool_handle) = common_setup().await; + + let clock_chain_caller = test_programs::clock_chain_caller(); + // Deploy the clock_chain_caller test program. + let deploy_tx = LeeTransaction::ProgramDeployment(lee::ProgramDeploymentTransaction::new( + lee::program_deployment_transaction::Message::new(clock_chain_caller.elf().to_owned()), + )); + mempool_handle + .push((TransactionOrigin::User, deploy_tx)) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + + // Build a user transaction that invokes clock_chain_caller, which in turn chain-calls the + // clock program with the clock accounts. The sequencer should detect that the resulting + // state diff modifies clock accounts and drop the transaction. + let clock_chain_caller_id = test_programs::clock_chain_caller().id(); + let clock_program_id = programs::clock().id(); + let timestamp: u64 = 0; + + let message = lee::public_transaction::Message::try_new( + clock_chain_caller_id, + system_accounts::clock_account_ids().to_vec(), + vec![], // no signers + (clock_program_id, timestamp), + ) + .unwrap(); + let user_tx = LeeTransaction::Public(lee::PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + )); + + mempool_handle + .push((TransactionOrigin::User, user_tx)) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + + let block = sequencer + .store + .get_block_at_id(sequencer.chain_height()) + .unwrap() + .unwrap(); + + // The user tx must have been dropped; only the mandatory clock invocation remains. + assert_eq!( + block.body.transactions, + vec![LeeTransaction::Public(clock_invocation( + block.header.timestamp + ))] + ); +} + +#[tokio::test] +async fn block_production_aborts_when_clock_account_data_is_corrupted() { + let (mut sequencer, mempool_handle) = common_setup().await; + + // Corrupt the clock 01 account data so the clock program panics on deserialization. + let clock_account_id = system_accounts::clock_account_ids()[0]; + let mut corrupted = sequencer.with_state(|s| s.get_account_by_id(clock_account_id)); + corrupted.data = vec![0xff; 3].try_into().unwrap(); + sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .head_state_mut() + .force_insert_account(clock_account_id, corrupted); + + // Push a dummy transaction so the mempool is non-empty. + let tx = common::test_utils::produce_dummy_empty_transaction(); + mempool_handle + .push((TransactionOrigin::User, tx)) + .await + .unwrap(); + + // Block production must fail because the appended clock tx cannot execute. + let result = sequencer.produce_new_block().await; + assert!( + result.is_err(), + "Block production should abort when clock account data is corrupted" + ); +} + +// #[test] +// fn private_bridge_withdraw_invocation_is_dropped() { +// let sender_keys = KeyChain::new_os_random(); +// let sender_account_id = AccountId::for_regular_private_account( +// &sender_keys.nullifier_public_key, +// &sender_keys.viewing_public_key, +// 0, +// ); +// let sender_private_account = Account { +// program_owner: programs::authenticated_transfer().id(), +// balance: 100, +// nonce: Nonce(0xdead_beef), +// data: Data::default(), +// }; +// let bridge_account_id = system_accounts::bridge_account_id(); + +// let mut state = V03State::new() +// .with_public_accounts([(bridge_account_id, system_accounts::bridge_account())]) +// .with_private_accounts([( +// Commitment::new(&sender_account_id, &sender_private_account), +// Nullifier::for_account_initialization(&sender_account_id), +// )]); + +// let sender_commitment = Commitment::new(&sender_account_id, &sender_private_account); + +// let sender_pre = AccountWithMetadata::new( +// sender_private_account, +// true, +// ( +// &sender_keys.nullifier_public_key, +// &sender_keys.viewing_public_key, +// 0, +// ), +// ); +// let bridge_pre = AccountWithMetadata::new( +// state.get_account_by_id(bridge_account_id), +// false, +// bridge_account_id, +// ); + +// let instruction = Program::serialize_instruction(bridge_core::Instruction::Withdraw { +// amount: 1, +// bedrock_account_pk: [0; 32], +// }) +// .unwrap(); + +// let program_with_deps = ProgramWithDependencies::new( +// programs::bridge(), +// [( +// programs::authenticated_transfer().id(), +// programs::authenticated_transfer(), +// )] +// .into(), +// ); + +// let (output, proof) = execute_and_prove( +// vec![sender_pre, bridge_pre], +// instruction, +// vec![ +// InputAccountIdentity::PrivateAuthorizedUpdate { +// vpk: sender_keys.viewing_public_key.clone(), +// random_seed: [0; 32], +// view_tag: 0, +// nsk: sender_keys.private_key_holder.nullifier_secret_key, +// membership_proof: state +// .get_proof_for_commitment(&sender_commitment) +// .expect("sender commitment must be in state"), +// identifier: 0, +// }, +// InputAccountIdentity::Public, +// ], +// &program_with_deps, +// ) +// .expect("Execution should succeed"); + +// let message = Message::try_from_circuit_output(vec![bridge_account_id], vec![], output) +// .expect("Message construction should succeed"); +// let witness_set = +// lee::privacy_preserving_transaction::WitnessSet::for_message(&message, proof, &[]); +// let tx = +// LeeTransaction::PrivacyPreserving(PrivacyPreservingTransaction::new(message, +// witness_set)); let res = tx.execute_check_on_state(&mut state, 1, 0); + +// assert!( +// matches!(res, Err(LeeError::InvalidInput(_))), +// "Bridge withdraw invocation should be rejected in private execution" +// ); +// } + +/// Builds a [`V03State`] with the clock program and `program` registered, the three clock +/// accounts initialized, and the clock advanced to `clock_timestamp` so that reads of the +/// `CLOCK_01` account observe it. +fn state_with_clock_and_program(program: Program, clock_timestamp: u64) -> V03State { + let mut state = V03State::new().with_programs([programs::clock(), program]); + for clock_id in system_accounts::clock_account_ids() { + state.force_insert_account(clock_id, system_accounts::clock_account()); + } + state + .transition_from_public_transaction(&clock_invocation(clock_timestamp), 1, clock_timestamp) + .expect("Clock invocation should advance the clock"); + state +} + +fn time_locked_transfer_transaction( + from: AccountId, + from_key: &PrivateKey, + from_nonce: u128, + to: AccountId, + clock_account_id: AccountId, + amount: u128, + deadline: u64, +) -> PublicTransaction { + let program_id = test_programs::time_locked_transfer().id(); + let message = lee::public_transaction::Message::try_new( + program_id, + vec![from, to, clock_account_id], + vec![Nonce(from_nonce)], + (amount, deadline), + ) + .unwrap(); + let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[from_key]); + PublicTransaction::new(message, witness_set) +} + +#[test] +fn time_locked_transfer_succeeds_when_deadline_has_passed() { + let clock_timestamp = 600; + let mut state = + state_with_clock_and_program(test_programs::time_locked_transfer(), clock_timestamp); + + // The recipient must be a non-default account so the program may credit it without + // claiming it. + let recipient_id = AccountId::new([42; 32]); + state.force_insert_account( + recipient_id, + Account { + program_owner: programs::authenticated_transfer().id(), + ..Account::default() + }, + ); + + let key1 = PrivateKey::try_new([1; 32]).unwrap(); + let sender_id = AccountId::from(&PublicKey::new_from_private_key(&key1)); + state.force_insert_account( + sender_id, + Account { + program_owner: test_programs::time_locked_transfer().id(), + balance: 100, + ..Account::default() + }, + ); + + let amount = 100; + // Deadline is in the past relative to the clock, so the transfer is unlocked. + let deadline = 0; + + let tx = time_locked_transfer_transaction( + sender_id, + &key1, + 0, + recipient_id, + system_accounts::clock_account_ids()[0], + amount, + deadline, + ); + + state + .transition_from_public_transaction(&tx, 2, clock_timestamp) + .unwrap(); + + // Balances changed. + assert_eq!(state.get_account_by_id(sender_id).balance, 0); + assert_eq!(state.get_account_by_id(recipient_id).balance, 100); +} + +#[test] +fn time_locked_transfer_fails_when_deadline_is_in_the_future() { + let clock_timestamp = 600; + let mut state = + state_with_clock_and_program(test_programs::time_locked_transfer(), clock_timestamp); + + let recipient_id = AccountId::new([42; 32]); + state.force_insert_account( + recipient_id, + Account { + program_owner: programs::authenticated_transfer().id(), + ..Account::default() + }, + ); + + let key1 = PrivateKey::try_new([1; 32]).unwrap(); + let sender_id = AccountId::from(&PublicKey::new_from_private_key(&key1)); + state.force_insert_account( + sender_id, + Account { + program_owner: test_programs::time_locked_transfer().id(), + balance: 100, + ..Account::default() + }, + ); + + let amount = 100; + // Far-future deadline: the program panics because the clock has not reached it. + let deadline = u64::MAX; + + let tx = time_locked_transfer_transaction( + sender_id, + &key1, + 0, + recipient_id, + system_accounts::clock_account_ids()[0], + amount, + deadline, + ); + + let result = state.transition_from_public_transaction(&tx, 2, clock_timestamp); + + assert!( + result.is_err(), + "Transfer should fail when deadline is in the future" + ); + // Balances unchanged. + assert_eq!(state.get_account_by_id(sender_id).balance, 100); + assert_eq!(state.get_account_by_id(recipient_id).balance, 0); +} + +fn pinata_cooldown_data(prize: u128, cooldown_ms: u64, last_claim_timestamp: u64) -> Vec { + let mut buf = Vec::with_capacity(32); + buf.extend_from_slice(&prize.to_le_bytes()); + buf.extend_from_slice(&cooldown_ms.to_le_bytes()); + buf.extend_from_slice(&last_claim_timestamp.to_le_bytes()); + buf +} + +fn pinata_cooldown_transaction( + pinata_id: AccountId, + winner_id: AccountId, + clock_account_id: AccountId, +) -> PublicTransaction { + let program_id = test_programs::pinata_cooldown().id(); + let message = lee::public_transaction::Message::try_new( + program_id, + vec![pinata_id, winner_id, clock_account_id], + vec![], + (), + ) + .unwrap(); + let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[]); + PublicTransaction::new(message, witness_set) +} + +#[test] +fn pinata_cooldown_claim_succeeds_after_cooldown() { + let winner_id = AccountId::new([11; 32]); + let pinata_id = AccountId::new([99; 32]); + + let genesis_timestamp = 1000; + let prize = 50; + let cooldown_ms = 500; + // Last claim was at genesis, so any timestamp >= genesis + cooldown should work. + let last_claim_timestamp = genesis_timestamp; + + // Advance the clock so the cooldown check reads an updated timestamp. + let block_timestamp = genesis_timestamp + cooldown_ms; + let mut state = state_with_clock_and_program(test_programs::pinata_cooldown(), block_timestamp); + + // The winner must be a non-default account so the program may credit it without claiming. + state.force_insert_account( + winner_id, + Account { + program_owner: programs::authenticated_transfer().id(), + ..Account::default() + }, + ); + state.force_insert_account( + pinata_id, + Account { + program_owner: test_programs::pinata_cooldown().id(), + balance: 1000, + data: pinata_cooldown_data(prize, cooldown_ms, last_claim_timestamp) + .try_into() + .unwrap(), + ..Account::default() + }, + ); + + let tx = pinata_cooldown_transaction( + pinata_id, + winner_id, + system_accounts::clock_account_ids()[0], + ); + + state + .transition_from_public_transaction(&tx, 2, block_timestamp) + .unwrap(); + + assert_eq!(state.get_account_by_id(pinata_id).balance, 1000 - prize); + assert_eq!(state.get_account_by_id(winner_id).balance, prize); +} + +#[test] +fn pinata_cooldown_claim_fails_during_cooldown() { + let winner_id = AccountId::new([11; 32]); + let pinata_id = AccountId::new([99; 32]); + + let genesis_timestamp = 1000; + let prize = 50; + let cooldown_ms = 500; + let last_claim_timestamp = genesis_timestamp; + + // Timestamp is only 100ms after the last claim, well within the 500ms cooldown. + let block_timestamp = genesis_timestamp + 100; + let mut state = state_with_clock_and_program(test_programs::pinata_cooldown(), block_timestamp); + + state.force_insert_account( + winner_id, + Account { + program_owner: programs::authenticated_transfer().id(), + ..Account::default() + }, + ); + state.force_insert_account( + pinata_id, + Account { + program_owner: test_programs::pinata_cooldown().id(), + balance: 1000, + data: pinata_cooldown_data(prize, cooldown_ms, last_claim_timestamp) + .try_into() + .unwrap(), + ..Account::default() + }, + ); + + let tx = pinata_cooldown_transaction( + pinata_id, + winner_id, + system_accounts::clock_account_ids()[0], + ); + + let result = state.transition_from_public_transaction(&tx, 2, block_timestamp); + + assert!(result.is_err(), "Claim should fail during cooldown period"); + assert_eq!(state.get_account_by_id(pinata_id).balance, 1000); + assert_eq!(state.get_account_by_id(winner_id).balance, 0); +} + +#[test] +fn pda_mechanism_with_pinata_token_program() { + let pinata_token = programs::pinata_token(); + let token = programs::token(); + + let pinata_definition_id = AccountId::new([1; 32]); + let pinata_token_definition_id = AccountId::new([2; 32]); + // Total supply of pinata token will be in an account under a PDA. + let pinata_token_holding_id = + AccountId::for_public_pda(&pinata_token.id(), &PdaSeed::new([0; 32])); + let winner_token_holding_id = AccountId::new([3; 32]); + + let expected_winner_account_holding = token_core::TokenHolding::Fungible { + definition_id: pinata_token_definition_id, + balance: 150, + }; + let expected_winner_token_holding_post = Account { + program_owner: token.id(), + data: Data::from(&expected_winner_account_holding), + ..Account::default() + }; + + // Register the pinata-token and token programs and create the pinata definition account. + // This replaces the removed `add_pinata_token_program` helper. + let mut state = V03State::new().with_programs([pinata_token.clone(), token.clone()]); + state.force_insert_account( + pinata_definition_id, + Account { + program_owner: pinata_token.id(), + // Difficulty: 3 + data: vec![3; 33].try_into().unwrap(), + ..Account::default() + }, + ); + + // Set up the token accounts directly (bypassing public transactions which + // would require signers for Claim::Authorized). The focus of this test is + // the PDA mechanism in the pinata program's chained call, not token creation. + let total_supply: u128 = 10_000_000; + let token_definition = token_core::TokenDefinition::Fungible { + name: String::from("PINATA"), + total_supply, + metadata_id: None, + }; + let token_holding = token_core::TokenHolding::Fungible { + definition_id: pinata_token_definition_id, + balance: total_supply, + }; + let winner_holding = token_core::TokenHolding::Fungible { + definition_id: pinata_token_definition_id, + balance: 0, + }; + state.force_insert_account( + pinata_token_definition_id, + Account { + program_owner: token.id(), + data: Data::from(&token_definition), + ..Account::default() + }, + ); + state.force_insert_account( + pinata_token_holding_id, + Account { + program_owner: token.id(), + data: Data::from(&token_holding), + ..Account::default() + }, + ); + state.force_insert_account( + winner_token_holding_id, + Account { + program_owner: token.id(), + data: Data::from(&winner_holding), + ..Account::default() + }, + ); + + // Submit a solution to the pinata program to claim the prize + let solution: u128 = 989_106; + let message = lee::public_transaction::Message::try_new( + pinata_token.id(), + vec![ + pinata_definition_id, + pinata_token_holding_id, + winner_token_holding_id, + ], + vec![], + solution, + ) + .unwrap(); + let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[]); + let tx = PublicTransaction::new(message, witness_set); + state.transition_from_public_transaction(&tx, 1, 0).unwrap(); + + let winner_token_holding_post = state.get_account_by_id(winner_token_holding_id); + assert_eq!( + winner_token_holding_post, + expected_winner_token_holding_post + ); +} + +#[test] +fn resubmittable_txs_drops_clock_and_bridge_deposits() { + let user_tx = common::test_utils::produce_dummy_empty_transaction(); + let deposit_tx = build_bridge_deposit_tx_from_event(&PendingDepositEventRecord { + deposit_op_id: HashType([13; 32]), + source_tx_hash: HashType([7; 32]), + amount: 1, + metadata: borsh::to_vec(&DepositMetadataForEncoding { + recipient_id: initial_public_user_accounts()[0].account_id, + }) + .unwrap(), + }) + .unwrap(); + let withdraw_tx = { + let message = lee::public_transaction::Message::try_new( + programs::bridge().id(), + vec![system_accounts::bridge_account_id()], + vec![], + bridge_core::Instruction::Withdraw { + amount: 1, + bedrock_account_pk: [0; 32], + }, + ) + .unwrap(); + LeeTransaction::Public(PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + )) + }; + + let block = common::test_utils::produce_dummy_block( + 2, + Some(HashType([1; 32])), + vec![user_tx.clone(), deposit_tx, withdraw_tx.clone()], + ); + + // The trailing clock tx and the sequencer-generated deposit are dropped; + // user txs (withdrawals included) are returned. + assert_eq!(resubmittable_txs(&block), vec![user_tx, withdraw_tx]); +} + +#[test] +fn resubmittable_txs_of_blocks_without_user_txs_is_empty() { + // No transactions at all (not even the mandatory clock tx). + let empty = HashableBlockData { + block_id: 1, + prev_block_hash: HashType([0; 32]), + timestamp: 0, + transactions: vec![], + } + .into_pending_block(&sequencer_sign_key_for_testing()); + assert!(resubmittable_txs(&empty).is_empty()); + + let clock_only = common::test_utils::produce_dummy_block(1, None, vec![]); + assert!(resubmittable_txs(&clock_only).is_empty()); +} + +#[tokio::test] +async fn follow_update_persists_the_checkpoint_with_its_effects() { + let config = setup_sequencer_config(); + let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; + let genesis_meta = sequencer + .store + .latest_block_meta() + .unwrap() + .expect("genesis meta is set"); + + let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![]); + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![(MsgId::from([1; 32]), peer_block)], + ..empty_follow_update() + }, + ); + + // The checkpoint is the sdk resume cursor; landing it without the block + // would let a restart stream past a block the store never got. + assert!( + sequencer.store.get_zone_checkpoint().unwrap().is_some(), + "the event's checkpoint must be persisted alongside the block it covers" + ); + assert!(sequencer.store.get_block_at_id(2).unwrap().is_some()); +} + +#[tokio::test] +async fn follow_update_records_deposits_for_the_production_drain() { + let config = setup_sequencer_config(); + let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; + + let recipient_id = initial_public_user_accounts()[0].account_id; + let metadata = borsh::to_vec(&DepositMetadataForEncoding { recipient_id }).unwrap(); + let deposit = DepositInfo { + op_id: [21; 32], + tx_hash: TxHash::from([9; 32]), + channel_id: ChannelId::from([0; 32]), + inputs: Inputs::empty(), + amount: 5, + metadata: Metadata::try_from(metadata).expect("deposit metadata fits"), + notes: DepositRecreatedNotes::default(), + }; + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + deposits: vec![deposit], + ..empty_follow_update() + }, + ); + + let pending = sequencer.store.get_pending_deposit_events().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].deposit_op_id, HashType([21; 32])); +} + +#[tokio::test] +async fn follow_adopted_peer_block_applies_and_persists() { + let config = setup_sequencer_config(); + let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; + let genesis_meta = sequencer + .store + .latest_block_meta() + .unwrap() + .expect("genesis meta is set"); + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]); + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![(MsgId::from([1; 32]), peer_block.clone())], + ..empty_follow_update() + }, + ); + + assert_eq!(sequencer.chain_height(), 2); + let stored = sequencer + .store + .get_block_at_id(2) + .unwrap() + .expect("adopted peer block should be persisted"); + assert_eq!(stored.header.hash, peer_block.header.hash); + assert_eq!( + sequencer.with_state(|s| s.get_account_by_id(acc2).balance), + 20010 + ); +} + +#[tokio::test] +async fn follow_redelivery_of_own_block_is_deduped() { + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + mempool_handle + .push((TransactionOrigin::User, tx)) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + + // The channel redelivers our own block under the MsgId the mock publisher + // assigned at publish time. + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![(MsgId::from(block2.header.hash.0), block2)], + ..empty_follow_update() + }, + ); + + assert_eq!(sequencer.chain_height(), 2); + assert_eq!( + sequencer.with_state(|s| s.get_account_by_id(acc2).balance), + 20010, + "the transfer must not be double-applied" + ); +} + +#[tokio::test] +async fn follow_orphan_reverts_head_and_requeues_user_txs() { + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + mempool_handle + .push((TransactionOrigin::User, tx.clone())) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![], + orphaned: vec![(MsgId::from(block2.header.hash.0), block2)], + ..empty_follow_update() + }, + ); + + assert_eq!(sequencer.chain_height(), 1); + assert_eq!( + sequencer.with_state(|s| s.get_account_by_id(acc1).balance), + 10000, + "the orphaned transfer must be reverted from the head" + ); + let (origin, requeued) = sequencer + .mempool + .pop() + .expect("orphaned user tx should be requeued"); + assert!(matches!(origin, TransactionOrigin::User)); + assert_eq!(requeued, tx); + assert!( + sequencer.mempool.pop().is_none(), + "the clock tx must not be requeued" + ); +} + +#[tokio::test] +async fn follow_orphan_of_a_finalized_block_requeues_nothing() { + // The zone-sdk reports a block as orphaned once LIB pruning drops its + // inscription from the channel lineage, which happens a poll or two after + // every block of ours finalizes. Its transactions are irreversibly + // included, so requeueing them would put them back in every block we + // produce from then on. + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + mempool_handle + .push((TransactionOrigin::User, tx)) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + finalized: vec![(MsgId::from(block2.header.hash.0), block2.clone())], + ..empty_follow_update() + }, + ); + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + orphaned: vec![(MsgId::from(block2.header.hash.0), block2)], + ..empty_follow_update() + }, + ); + + assert_eq!( + sequencer.chain_height(), + 2, + "an irreversible block cannot be reverted" + ); + assert_eq!( + sequencer.with_state(|s| s.get_account_by_id(acc2).balance), + 20010, + "the finalized transfer stands" + ); + assert!( + sequencer.mempool.pop().is_none(), + "a transaction that is already irreversible must not be requeued" + ); +} + +#[tokio::test] +async fn follow_finalized_own_block_moves_final_tier_and_marks_store() { + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + + let tx = common::test_utils::produce_dummy_empty_transaction(); + mempool_handle + .push((TransactionOrigin::User, tx)) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![], + orphaned: vec![], + finalized: vec![(MsgId::from(block2.header.hash.0), block2)], + ..empty_follow_update() + }, + ); + + let final_tip = sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .final_tip() + .expect("final tip set"); + assert_eq!(final_tip.block_id, 2); + assert_eq!(sequencer.chain_height(), 2, "head is unchanged"); + let stored = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized)); +} + +#[tokio::test] +async fn follow_finalized_delivery_drops_its_pending_record() { + // The record exists to bridge the gap between the watcher's durable read + // cursor and a block that carries the delivery. Once that block is + // irreversible the delivery cannot be lost any more, so the record is owed + // nothing and goes with the same update that made the block irreversible. + let record = dispatch_record(17, ping_payload(b"settled")); + let key = record.message_key; + + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await; + sequencer + .store + .dbio() + .add_pending_cross_zone_dispatches(vec![record]) + .unwrap(); + + let block_id = sequencer.produce_new_block().await.unwrap(); + let delivery_block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap(); + assert_eq!(dispatches_in(&delivery_block), vec![key]); + assert_eq!( + pending_dispatches(&sequencer).len(), + 1, + "including the delivery is not enough to settle its record" + ); + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + finalized: vec![(MsgId::from(delivery_block.header.hash.0), delivery_block)], + ..empty_follow_update() + }, + ); + + assert!( + pending_dispatches(&sequencer).is_empty(), + "a delivery in an irreversible block settles its record" + ); +} + +#[tokio::test] +async fn a_parked_finalized_block_does_not_drop_a_dispatch_record() { + // Keyed by message key, not by height: a finalized block the final tier + // parks never became irreversible, so nothing it happens to sit above may + // settle a record. Dropping one here would lose the delivery for good, since + // the watcher's floor has already moved past the peer block it came from. + let record = dispatch_record(19, ping_payload(b"parked")); + let key = record.message_key; + let delivery = dispatch_tx(19, ping_payload(b"parked")); + + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await; + sequencer + .store + .dbio() + .add_pending_cross_zone_dispatches(vec![record]) + .unwrap(); + + let tx = common::test_utils::produce_dummy_empty_transaction(); + mempool_handle + .push((TransactionOrigin::User, tx)) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + + // A skip-ahead block carrying the same delivery: not in head and linking to + // nothing we hold, so the final tier parks it instead of applying it. + let parked = + common::test_utils::produce_dummy_block(9, Some(HashType([44; 32])), vec![delivery]); + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + finalized: vec![(MsgId::from([9; 32]), parked)], + ..empty_follow_update() + }, + ); + + assert_eq!( + pending_dispatches(&sequencer) + .iter() + .map(|record| record.message_key) + .collect::>(), + vec![key], + "a parked finalized block must not drop its delivery's record" + ); +} + +#[tokio::test] +async fn follow_finalized_backfill_block_is_applied_and_marked_finalized() { + let config = setup_sequencer_config(); + let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; + let genesis_meta = sequencer + .store + .latest_block_meta() + .unwrap() + .expect("genesis meta is set"); + + // A peer block we never saw as adopted arrives straight from the + // finalized (backfill) stream. + let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![]); + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![], + orphaned: vec![], + finalized: vec![(MsgId::from([2; 32]), peer_block.clone())], + ..empty_follow_update() + }, + ); + + assert_eq!( + sequencer.chain_height(), + 2, + "head mirrors final on backfill" + ); + let stored = sequencer + .store + .get_block_at_id(2) + .unwrap() + .expect("backfilled block should be persisted"); + assert_eq!(stored.header.hash, peer_block.header.hash); + assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized)); +} + +#[tokio::test] +async fn parked_finalized_block_neither_sweeps_the_store_nor_drops_its_deposit_record() { + let config = setup_sequencer_config(); + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + + // A produced block at head, still pending on the channel. + let tx = common::test_utils::produce_dummy_empty_transaction(); + mempool_handle + .push((TransactionOrigin::User, tx)) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + + let deposit_op_id = HashType([21; 32]); + let record = PendingDepositEventRecord { + deposit_op_id, + source_tx_hash: HashType([22; 32]), + amount: 5, + metadata: borsh::to_vec(&DepositMetadataForEncoding { + recipient_id: initial_public_user_accounts()[0].account_id, + }) + .unwrap(), + }; + let deposit_tx = build_bridge_deposit_tx_from_event(&record).unwrap(); + assert!( + sequencer + .store + .dbio() + .add_pending_deposit_event(record) + .unwrap() + ); + + // Skip-ahead block carrying that deposit: not in head and linking to + // nothing we hold, so the final tier parks it instead of applying it. + let parked = + common::test_utils::produce_dummy_block(9, Some(HashType([44; 32])), vec![deposit_tx]); + + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![], + orphaned: vec![], + finalized: vec![(MsgId::from([9; 32]), parked)], + ..empty_follow_update() + }, + ); + + // Nothing became irreversible, so the store must not be swept through the + // parked block's height. + let stored = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + assert!( + matches!(stored.bedrock_status, BedrockStatus::Pending), + "a parked finalized block must not mark earlier blocks finalized" + ); + // And its deposit is not minted anywhere, so dropping the record would lose + // the deposit for good once the stall clears. + assert!( + sequencer + .store + .get_pending_deposit_events() + .unwrap() + .iter() + .any(|event| event.deposit_op_id == deposit_op_id), + "a parked finalized block must not drop its deposit record" + ); +} + +#[tokio::test] +async fn restart_restores_head_tier_and_recovers_from_orphan() { + let config = setup_sequencer_config(); + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + + // Produce block 2 (a user transfer), then "crash" before it finalizes. + let (tx, block2) = { + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + mempool_handle + .push((TransactionOrigin::User, tx.clone())) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + (tx, sequencer.store.get_block_at_id(2).unwrap().unwrap()) + }; + + // Restart: nothing is finalized, so block 2 must come back as *head*, not + // final — the L1 can still orphan it. + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + assert_eq!(sequencer.chain_height(), 2); + + // The L1 orphans block 2 under its real MsgId (which we never persisted) + // and adopts a competing empty block 2'. + let genesis = sequencer.store.get_block_at_id(1).unwrap().unwrap(); + let block2_prime = + common::test_utils::produce_dummy_block(2, Some(genesis.header.hash), vec![]); + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![(MsgId::from([21; 32]), block2_prime.clone())], + orphaned: vec![(MsgId::from([20; 32]), block2)], + ..empty_follow_update() + }, + ); + + // The head reorged onto 2': transfer reverted, store overwritten, and the + // orphaned user tx returned to the mempool. + assert_eq!(sequencer.chain_height(), 2); + let head_tip = sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .head_tip() + .expect("head tip set"); + assert_eq!(head_tip.hash, block2_prime.header.hash); + assert_eq!( + sequencer.with_state(|s| s.get_account_by_id(acc1).balance), + 10000, + "the orphaned transfer must be reverted" + ); + let stored = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + assert_eq!(stored.header.hash, block2_prime.header.hash); + let (origin, requeued) = sequencer + .mempool + .pop() + .expect("orphaned user tx should be requeued"); + assert!(matches!(origin, TransactionOrigin::User)); + assert_eq!(requeued, tx); +} + +#[tokio::test] +async fn restart_reanchors_on_the_persisted_final_snapshot() { + let config = setup_sequencer_config(); + + // Produce block 2 and follow its finalization, which persists the final + // snapshot; then "crash". + { + let (mut sequencer, mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + let tx = common::test_utils::produce_dummy_empty_transaction(); + mempool_handle + .push((TransactionOrigin::User, tx)) + .await + .unwrap(); + sequencer.produce_new_block().await.unwrap(); + let block2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![], + orphaned: vec![], + finalized: vec![(MsgId::from(block2.header.hash.0), block2)], + ..empty_follow_update() + }, + ); + } + + // Restart: the final tier re-anchors on the snapshot instead of treating + // the whole stored chain as final. + let (sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + let chain = sequencer.chain(); + let chain = chain.lock().expect("chain mutex poisoned"); + assert_eq!(chain.final_tip().expect("final tip set").block_id, 2); + assert_eq!(chain.head_tip().expect("head tip set").block_id, 2); +} + +#[tokio::test] +async fn record_produced_block_skips_persistence_on_lost_race() { + let config = setup_sequencer_config(); + let (mut sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + let genesis_meta = sequencer + .store + .latest_block_meta() + .unwrap() + .expect("genesis meta is set"); + + // A peer block wins height 2 while "our" block is in flight. + let peer_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![]); + sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .apply_adopted(MsgId::from([9; 32]), &peer_block); + + // Our competing block at the same height: same parent, different content. + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + let our_block = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]); + sequencer + .record_produced_block( + MsgId::from(our_block.header.hash.0), + &our_block, + &[], + &mock_checkpoint(), + ) + .unwrap(); + + // The lost-race block must not reach the store; the head keeps the peer block. + assert!(sequencer.store.get_block_at_id(2).unwrap().is_none()); + let head_tip = sequencer + .chain() + .lock() + .expect("chain mutex poisoned") + .head_tip() + .expect("head tip"); + assert_eq!(head_tip.hash, peer_block.header.hash); +} + +#[tokio::test] +async fn record_produced_block_skips_persistence_when_block_no_longer_chains() { + let config = setup_sequencer_config(); + let (mut sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + + // The head reorged under us: our block's parent is no longer the tip. + let stale = common::test_utils::produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + sequencer + .record_produced_block( + MsgId::from(stale.header.hash.0), + &stale, + &[], + &mock_checkpoint(), + ) + .unwrap(); + + assert!(sequencer.store.get_block_at_id(2).unwrap().is_none()); + assert_eq!(sequencer.chain_height(), 1, "head is unchanged"); +} + +#[tokio::test] +async fn follow_update_persists_blocks_meta_and_state_atomically() { + let config = setup_sequencer_config(); + let (sequencer, mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; + let genesis_meta = sequencer + .store + .latest_block_meta() + .unwrap() + .expect("genesis meta is set"); + + let acc1 = initial_public_user_accounts()[0].account_id; + let acc2 = initial_public_user_accounts()[1].account_id; + let tx = common::test_utils::create_transaction_native_token_transfer( + acc1, + 0, + acc2, + 10, + &create_signing_key_for_account1(), + ); + let block2 = common::test_utils::produce_dummy_block(2, Some(genesis_meta.hash), vec![tx]); + let block3 = common::test_utils::produce_dummy_block(3, Some(block2.header.hash), vec![]); + + // One update carrying several blocks: both adopted, block 2 also finalized. + apply_follow_update( + &sequencer.store.dbio(), + &sequencer.chain(), + &mempool_handle, + FollowUpdate { + adopted: vec![ + (MsgId::from([2; 32]), block2.clone()), + (MsgId::from([3; 32]), block3.clone()), + ], + orphaned: vec![], + finalized: vec![(MsgId::from([2; 32]), block2)], + ..empty_follow_update() + }, + ); + + // Blocks, tip meta and state all reflect the end of the batch: a late + // finalized entry for an earlier block must not drag the tip meta back. + let meta = sequencer + .store + .latest_block_meta() + .unwrap() + .expect("meta is set"); + assert_eq!(meta.id, 3); + assert_eq!(meta.hash, block3.header.hash); + let stored2 = sequencer.store.get_block_at_id(2).unwrap().unwrap(); + assert!(matches!(stored2.bedrock_status, BedrockStatus::Finalized)); + let stored_balance = sequencer + .store + .get_lee_state() + .unwrap() + .get_account_by_id(acc2) + .balance; + assert_eq!(stored_balance, 20010); +} diff --git a/lez/sequencer/core/src/tests/reconstruction.rs b/lez/sequencer/core/src/tests/reconstruction.rs new file mode 100644 index 00000000..846abe20 --- /dev/null +++ b/lez/sequencer/core/src/tests/reconstruction.rs @@ -0,0 +1,771 @@ +#![expect( + clippy::arithmetic_side_effects, + clippy::as_conversions, + reason = "We don't care about it in tests" +)] + +use std::sync::Mutex; + +use chain_state::ChainState; +use common::block::Block; +use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription}; +use logos_blockchain_zone_sdk::{Slot, ZoneBlock, ZoneMessage}; +use storage::sequencer::sequencer_cells::ZoneAnchorRecord; + +use super::*; +use crate::{ + SequencerCore, block_store::SequencerStore, config::GenesisAction, mock::MockBlockPublisher, +}; + +/// Fresh `(store, chain)` pair for a reconstruction target, as +/// `start_from_config` would build them before the publisher starts. +fn fresh_store_and_chain(config: &SequencerConfig) -> (SequencerStore, Mutex) { + let (store, state) = SequencerCore::::open_or_create_store(config); + let chain = Mutex::new(SequencerCore::::restore_chain_state( + config, &store, &state, + )); + (store, chain) +} + +fn block_to_channel_message(block: &Block, slot: u64) -> (ZoneMessage, Slot) { + let bytes = borsh::to_vec(block).expect("serialize block"); + let message = ZoneMessage::Block(ZoneBlock { + id: MsgId::from([0_u8; 32]), + data: Inscription::try_from(bytes.as_slice()).expect("inscription"), + }); + (message, Slot::from(slot)) +} + +/// Collects a sequencer's whole chain (genesis..=tip) into a canned channel, +/// one block per slot at `slot_step` spacing. +fn channel_from_store(store: &SequencerStore, slot_step: u64) -> Vec<(ZoneMessage, Slot)> { + let genesis_id = store.genesis_id(); + let tip_id = store.latest_block_meta().expect("tip").expect("present").id; + (genesis_id..=tip_id) + .enumerate() + .map(|(index, id)| { + let block = store.get_block_at_id(id).expect("read").expect("present"); + block_to_channel_message(&block, (index as u64 + 1) * slot_step) + }) + .collect() +} + +#[tokio::test] +async fn reconstructs_missing_channel_blocks_into_fresh_store() { + // Sequencer A produces a few blocks; treat its chain as the channel. + let config_a = setup_sequencer_config(); + let (mut seq_a, _handle_a) = + SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; + seq_a.produce_new_block().await.unwrap(); + seq_a.produce_new_block().await.unwrap(); + let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap(); + + let messages = channel_from_store(seq_a.block_store(), 10); + let tip_slot = messages.last().unwrap().1; + let channel_id = config_a.bedrock_config.channel_id; + + // Sequencer B starts from a fresh store and reconstructs A's chain. + let config_b = setup_sequencer_config(); + let (store_b, chain_b) = fresh_store_and_chain(&config_b); + let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages); + + let channel_was_empty = SequencerCore::::verify_and_reconstruct( + &mock_b, &store_b, &chain_b, true, + ) + .await + .expect("reconstruct"); + assert!(!channel_was_empty); + + let tip_b = store_b.latest_block_meta().unwrap().unwrap(); + assert_eq!(tip_b.id, tip_a.id); + assert_eq!(tip_b.hash, tip_a.hash); + + // State matches: initial account balances agree with sequencer A. + let state_b = chain_b.lock().unwrap().head_state().clone(); + let state_a = seq_a.chain().lock().unwrap().head_state().clone(); + for account in initial_public_user_accounts() { + assert_eq!( + state_b.get_account_by_id(account.account_id).balance, + state_a.get_account_by_id(account.account_id).balance, + ); + } + + let anchor = store_b.get_zone_anchor().unwrap().expect("anchor recorded"); + assert_eq!(anchor.block_id, tip_a.id); + assert_eq!(anchor.slot, tip_slot.into_inner()); + + // Re-running is idempotent: everything is already applied, no error. + let channel_was_empty = SequencerCore::::verify_and_reconstruct( + &mock_b, &store_b, &chain_b, true, + ) + .await + .expect("reconstruct idempotent"); + assert!(!channel_was_empty); + assert_eq!(store_b.latest_block_meta().unwrap().unwrap().id, tip_a.id); +} + +#[tokio::test] +async fn fails_when_channel_serves_a_divergent_block() { + let config = setup_sequencer_config(); + let (store, chain) = fresh_store_and_chain(&config); + + // Anchor on the local genesis at some slot. + let genesis_id = store.genesis_id(); + let genesis = store.get_block_at_id(genesis_id).unwrap().unwrap(); + let anchor_slot = 100_u64; + store + .set_zone_anchor(&ZoneAnchorRecord { + slot: anchor_slot, + block_id: genesis_id, + hash: genesis.header.hash, + }) + .unwrap(); + + // The channel serves a different block at the anchor id/slot. + let mut tampered = genesis.clone(); + tampered.header.hash = HashType([9_u8; 32]); + let messages = vec![block_to_channel_message(&tampered, anchor_slot)]; + let mock = MockBlockPublisher::with_canned_channel( + config.bedrock_config.channel_id, + Some(Slot::from(anchor_slot)), + messages, + ); + + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, true) + .await; + assert!(result.is_err(), "divergent channel must abort startup"); +} + +#[tokio::test] +async fn fails_when_channel_is_missing() { + let config = setup_sequencer_config(); + let (store, chain) = fresh_store_and_chain(&config); + let genesis_id = store.genesis_id(); + let genesis = store.get_block_at_id(genesis_id).unwrap().unwrap(); + store + .set_zone_anchor(&ZoneAnchorRecord { + slot: 100, + block_id: genesis_id, + hash: genesis.header.hash, + }) + .unwrap(); + + // Anchor present, but the channel does not exist on the connected chain. + let mock = + MockBlockPublisher::with_canned_channel(config.bedrock_config.channel_id, None, vec![]); + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, true) + .await; + assert!(result.is_err(), "missing channel must abort startup"); +} + +// The following cases exercise the divergence branches of +// `apply_reconstructed_block` reached with no recorded anchor, so the block's own +// validation fires rather than the up-front `AnchorConsistencyCheck`. + +#[tokio::test] +async fn fails_when_channel_reinscribes_genesis_with_a_different_hash() { + let config = setup_sequencer_config(); + let (store, chain) = fresh_store_and_chain(&config); + + // Fresh store, no anchor. The channel serves a genesis at the same id but a + // different hash — a foreign chain reinscribing genesis. + let mut reinscribed = store.get_block_at_id(store.genesis_id()).unwrap().unwrap(); + reinscribed.header.hash = HashType([0xAB_u8; 32]); + + let messages = vec![block_to_channel_message(&reinscribed, 10)]; + let mock = MockBlockPublisher::with_canned_channel( + config.bedrock_config.channel_id, + Some(Slot::from(10)), + messages, + ); + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, true) + .await; + assert!( + result.is_err(), + "a reinscribed genesis with a different hash must abort startup" + ); +} + +#[tokio::test] +async fn fails_when_a_stored_block_hash_diverges_from_the_channel() { + // A sequencer that committed blocks past genesis but never recorded an anchor. + let config = setup_sequencer_config(); + let (mut seq, _handle) = SequencerCoreWithMockClients::start_from_config(config.clone()).await; + seq.produce_new_block().await.unwrap(); + seq.produce_new_block().await.unwrap(); + + // A below-tip block re-served with a corrupted hash: we already hold this id + // with a different hash, so the channel is a different chain. + let below_tip_id = seq.block_store().genesis_id() + 1; + let mut block = seq + .block_store() + .get_block_at_id(below_tip_id) + .unwrap() + .unwrap(); + block.header.hash = HashType([0xCD_u8; 32]); + + let messages = vec![block_to_channel_message(&block, 10)]; + let mock = MockBlockPublisher::with_canned_channel( + config.bedrock_config.channel_id, + Some(Slot::from(10)), + messages, + ); + let result = SequencerCore::::verify_and_reconstruct( + &mock, &seq.store, &seq.chain, true, + ) + .await; + assert!( + result.is_err(), + "a diverging below-tip block hash must abort startup" + ); +} + +#[tokio::test] +async fn fails_when_a_channel_block_is_missing_locally() { + let config = setup_sequencer_config(); + let (store, chain) = fresh_store_and_chain(&config); + + // A block numbered below our genesis is at/below the local tip yet absent from + // the store — a foreign chain with a lower numbering. + let mut foreign = store.get_block_at_id(store.genesis_id()).unwrap().unwrap(); + foreign.header.block_id = store.genesis_id() - 1; + + let messages = vec![block_to_channel_message(&foreign, 10)]; + let mock = MockBlockPublisher::with_canned_channel( + config.bedrock_config.channel_id, + Some(Slot::from(10)), + messages, + ); + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, true) + .await; + assert!( + result.is_err(), + "a channel block below the local range must abort startup" + ); +} + +#[tokio::test] +async fn fails_when_a_channel_block_does_not_extend_the_tip() { + let config = setup_sequencer_config(); + let (store, chain) = fresh_store_and_chain(&config); + + // A block claiming an id far past genesis does not chain onto the local tip. + let mut orphan = store.get_block_at_id(store.genesis_id()).unwrap().unwrap(); + orphan.header.block_id = store.genesis_id() + 5; + + let messages = vec![block_to_channel_message(&orphan, 10)]; + let mock = MockBlockPublisher::with_canned_channel( + config.bedrock_config.channel_id, + Some(Slot::from(10)), + messages, + ); + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, true) + .await; + assert!( + result.is_err(), + "a non-contiguous channel block must abort startup" + ); +} + +/// A sequencer config whose genesis funds the bridge account, so replayed bridge +/// deposit transactions have a source balance to mint from. +fn bridge_funded_config() -> SequencerConfig { + let mut config = setup_sequencer_config(); + config.genesis = vec![GenesisAction::SupplyBridgeAccount { balance: 1_000_000 }]; + config +} + +/// Builds an unfulfilled pending deposit event for `recipient`, matching the +/// encoding `build_bridge_deposit_tx_from_event` expects. +fn deposit_event_record( + op_id: [u8; 32], + amount: u64, + recipient: lee::AccountId, +) -> PendingDepositEventRecord { + PendingDepositEventRecord { + deposit_op_id: HashType(op_id), + source_tx_hash: HashType([0_u8; 32]), + amount, + metadata: borsh::to_vec(&DepositMetadataForEncoding { + recipient_id: recipient, + }) + .unwrap(), + } +} + +// /// Builds a signed public bridge `Withdraw` transaction (the normal user path). +// fn build_public_withdraw_tx( +// sender: lee::AccountId, +// nonce: u128, +// amount: u64, +// bedrock_account_pk: [u8; 32], +// signing_key: &lee::PrivateKey, +// ) -> LeeTransaction { +// let message = lee::public_transaction::Message::try_new( +// programs::bridge().id(), +// vec![sender, system_accounts::bridge_account_id()], +// vec![nonce.into()], +// bridge_core::Instruction::Withdraw { +// amount, +// bedrock_account_pk, +// }, +// ) +// .unwrap(); +// let witness_set = lee::public_transaction::WitnessSet::for_message(&message, &[signing_key]); +// LeeTransaction::Public(lee::PublicTransaction::new(message, witness_set)) +// } + +// /// The reconciliation key a produced block carries for `withdraw_tx`, keyed on +// /// the note [`MockBlockPublisher`] reports as released for it. +// fn produced_withdraw_key(withdraw_tx: &LeeTransaction) -> WithdrawalReconciliationKey { +// let withdraw_arg = crate::extract_bridge_withdraw_data(withdraw_tx).expect("withdraw data"); +// let [note_id] = crate::mock::mock_released_notes(std::slice::from_ref(&withdraw_arg))[..] +// else { +// panic!("A bridge withdraw releases exactly one note"); +// }; + +// crate::withdrawal_reconciliation_key(¬e_id) +// } + +// /// Cold-start backfill re-records an already-finalized deposit event as a +// /// pending record before reconstruction replays the same deposit block. +// /// Reconstruction must drop that record — its mint is permanently reflected in +// /// the reconstructed state (the receipt PDA) — so the next production neither +// /// re-mints the vault nor emits a stray deposit tx. +// #[tokio::test] +// async fn reconstructed_deposit_is_not_reminted_after_backfill_redelivery() { +// let recipient = initial_public_user_accounts()[0].account_id; +// let deposit_amount = 500_u64; +// let withdraw_amount = 100_u64; +// let bedrock_account_pk = [0x22_u8; 32]; +// let deposit_op_id = [0x0d_u8; 32]; + +// // Sequencer A produces a deposit block then a withdraw block. +// let config_a = bridge_funded_config(); +// let (mut seq_a, mempool_a) = +// SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; + +// let deposit_record = deposit_event_record(deposit_op_id, deposit_amount, recipient); +// let deposit_tx = +// crate::build_bridge_deposit_tx_from_event(&deposit_record).expect("build deposit tx"); +// mempool_a +// .push((TransactionOrigin::Sequencer, deposit_tx)) +// .await +// .unwrap(); +// seq_a.produce_new_block().await.unwrap(); + +// let withdraw_tx = build_public_withdraw_tx( +// recipient, +// 0, +// withdraw_amount, +// bedrock_account_pk, +// &create_signing_key_for_account1(), +// ); +// mempool_a +// .push((TransactionOrigin::User, withdraw_tx.clone())) +// .await +// .unwrap(); +// seq_a.produce_new_block().await.unwrap(); + +// let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap(); +// let messages = channel_from_store(seq_a.block_store(), 10); +// let tip_slot = messages.last().unwrap().1; +// let channel_id = config_a.bedrock_config.channel_id; + +// let config_b = bridge_funded_config(); +// let (mut seq_b, _mempool_b) = +// SequencerCoreWithMockClients::start_from_config(config_b).await; + +// // Backfill re-delivery: the deposit event is re-recorded as a pending record +// // before reconstruction runs. The mint no longer flows through the mempool +// // (that sink was removed); the store drain is the only source. +// assert!( +// seq_b +// .block_store() +// .dbio() +// .add_pending_deposit_event(deposit_record.clone()) +// .unwrap() +// ); + +// let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages); +// SequencerCore::::verify_and_reconstruct( +// &mock_b, +// &seq_b.store, +// &seq_b.chain, +// true, +// ) +// .await +// .expect("reconstruct"); + +// let tip_b = seq_b.block_store().latest_block_meta().unwrap().unwrap(); +// assert_eq!(tip_b.id, tip_a.id); +// assert_eq!(tip_b.hash, tip_a.hash); + +// // Reconstruction replays the finalized deposit block, minting the receipt +// // into state and dropping the re-recorded pending event — so the drain has +// // nothing left to re-mint. This is the mechanism that protects against the +// // re-delivery, in place of the removed mempool sink. +// assert!( +// seq_b +// .block_store() +// .dbio() +// .get_pending_deposit_events() +// .unwrap() +// .is_empty(), +// "reconstruction must drop the re-delivered pending deposit record" +// ); + +// seq_b.produce_new_block().await.unwrap(); + +// let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient); +// let bridge_id = system_accounts::bridge_account_id(); +// let state_b = seq_b.chain().lock().unwrap().head_state().clone(); +// let state_a = seq_a.chain().lock().unwrap().head_state().clone(); +// for account in [vault_id, bridge_id, recipient] { +// assert_eq!( +// state_b.get_account_by_id(account).balance, +// state_a.get_account_by_id(account).balance, +// "reconstructed balance mismatch for {account:?}", +// ); +// } +// assert_eq!( +// state_b.get_account_by_id(vault_id).balance, +// u128::from(deposit_amount), +// "deposit must mint into the recipient vault exactly once, not twice" +// ); + +// let produced = seq_b +// .block_store() +// .get_block_at_id(tip_b.id + 1) +// .unwrap() +// .expect("produced block present"); +// assert!( +// !produced +// .body +// .transactions +// .iter() +// .any(|tx| crate::extract_bridge_deposit_id(tx) == Some(HashType(deposit_op_id))), +// "the re-delivered mint must be skipped, not re-included in a block" +// ); + +// // A reconstructed withdraw's finalized L1 event was already re-delivered (and +// // dropped) by cold-start backfill, so it will never be consumed again. +// // Reconstruction must not count it, or the count stays phantom-inflated forever. +// let key = produced_withdraw_key(&withdraw_tx); +// assert!( +// !seq_b +// .block_store() +// .dbio() +// .consume_unseen_withdraw_count(key) +// .unwrap(), +// "reconstruction must not leave a phantom unseen-withdraw count" +// ); +// } + +// /// A reconstructed withdraw block must not touch the unseen-withdraw counter. +// /// Its finalized L1 Withdraw event was already re-delivered (and dropped as a +// /// no-op) by cold-start backfill, so counting it during reconstruction would +// /// leave a permanent phantom that nothing ever consumes. +// #[tokio::test] +// async fn reconstructed_withdraw_leaves_no_phantom_unseen_count() { +// let recipient = initial_public_user_accounts()[0].account_id; +// let withdraw_amount = 100_u64; +// let bedrock_account_pk = [0x33_u8; 32]; + +// // Sequencer A produces a single withdraw block; treat its chain as the channel. +// let config_a = bridge_funded_config(); +// let (mut seq_a, mempool_a) = +// SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; +// let withdraw_tx = build_public_withdraw_tx( +// recipient, +// 0, +// withdraw_amount, +// bedrock_account_pk, +// &create_signing_key_for_account1(), +// ); +// mempool_a +// .push((TransactionOrigin::User, withdraw_tx.clone())) +// .await +// .unwrap(); +// seq_a.produce_new_block().await.unwrap(); + +// let key = produced_withdraw_key(&withdraw_tx); +// // Producing the withdraw counts it as unseen, awaiting its L1 event. +// assert!( +// seq_a +// .block_store() +// .dbio() +// .consume_unseen_withdraw_count(key) +// .unwrap(), +// "producing a withdraw must count it as unseen" +// ); + +// let messages = channel_from_store(seq_a.block_store(), 10); +// let tip_slot = messages.last().unwrap().1; +// let channel_id = config_a.bedrock_config.channel_id; + +// // Sequencer B reconstructs A's chain from a fresh store. +// let config_b = bridge_funded_config(); +// let (store_b, chain_b) = fresh_store_and_chain(&config_b); +// let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages); +// SequencerCore::::verify_and_reconstruct(&mock_b, &store_b, &chain_b, +// true) .await +// .expect("reconstruct"); + +// assert!( +// !store_b.dbio().consume_unseen_withdraw_count(key).unwrap(), +// "reconstruction must not leave a phantom unseen-withdraw count" +// ); +// } + +/// A deposit whose L1 event was observed (an unfulfilled pending record +/// exists) and whose L2 mint is already contained in a finalized channel block. +/// Reconstruction must reconcile the pending record against that block — marking +/// it submitted so the startup replay does not re-inject it — and apply the mint +/// exactly once. +#[tokio::test] +async fn reconstruction_reconciles_already_finished_deposit() { + let recipient = initial_public_user_accounts()[0].account_id; + let deposit_amount = 400_u64; + let deposit_op_id = [0x1a_u8; 32]; + + // Sequencer A: a single block that fully processes the bridge deposit. + let config_a = bridge_funded_config(); + let (mut seq_a, mempool_a) = + SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; + let deposit_record = deposit_event_record(deposit_op_id, deposit_amount, recipient); + let deposit_tx = + crate::build_bridge_deposit_tx_from_event(&deposit_record).expect("build deposit tx"); + mempool_a + .push((TransactionOrigin::Sequencer, deposit_tx)) + .await + .unwrap(); + seq_a.produce_new_block().await.unwrap(); + + let messages = channel_from_store(seq_a.block_store(), 10); + let tip_slot = messages.last().unwrap().1; + let channel_id = config_a.bedrock_config.channel_id; + + // Sequencer B: fresh store, but with the *unfulfilled* pending deposit event + // pre-seeded, as the cold-start backfill would when it re-observes this + // already-finalized deposit. + let config_b = bridge_funded_config(); + let (store_b, chain_b) = fresh_store_and_chain(&config_b); + assert!( + store_b + .dbio() + .add_pending_deposit_event(deposit_record.clone()) + .unwrap() + ); + + let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages); + SequencerCore::::verify_and_reconstruct(&mock_b, &store_b, &chain_b, true) + .await + .expect("reconstruct"); + + // The mint was applied exactly once. + let vault_id = vault_core::compute_vault_account_id(programs::vault().id(), recipient); + assert_eq!( + chain_b + .lock() + .unwrap() + .head_state() + .get_account_by_id(vault_id) + .balance, + u128::from(deposit_amount), + "already-finished deposit must be applied exactly once" + ); + + // The mint's receipt PDA is in the reconstructed state, and reconstruction + // dropped the pending record backfill had re-delivered — so the production + // drain sees the deposit as minted and never re-emits it. + assert!( + crate::deposit_already_minted( + chain_b.lock().unwrap().head_state(), + HashType(deposit_op_id) + ), + "the reconstructed deposit's receipt marks it minted" + ); + assert!( + store_b.get_pending_deposit_events().unwrap().is_empty(), + "reconstruction drops the finalized deposit's pending record" + ); +} + +/// A cross-zone delivery whose record is still pending locally, but whose block +/// arrives already finalized on the channel. Reconstruction must settle the +/// record on the way through: the delivery is permanently reflected in the +/// reconstructed state (the inbox seen shard), so the next production neither +/// re-delivers it nor leaves a record nothing will ever drop. +#[tokio::test] +async fn reconstructed_delivery_settles_its_pending_record() { + let payload = b"reconstructed".to_vec(); + let record = dispatch_record(23, ping_payload(&payload)); + let key = record.message_key; + + // Sequencer A produces the block that carries the delivery. + let config_a = cross_zone_test_config(); + let (mut seq_a, _mempool_a) = + SequencerCoreWithMockClients::start_from_config(config_a.clone()).await; + seq_a + .block_store() + .dbio() + .add_pending_cross_zone_dispatches(vec![record.clone()]) + .unwrap(); + seq_a.produce_new_block().await.unwrap(); + + let tip_a = seq_a.block_store().latest_block_meta().unwrap().unwrap(); + let messages = channel_from_store(seq_a.block_store(), 10); + let tip_slot = messages.last().unwrap().1; + let channel_id = config_a.bedrock_config.channel_id; + + // Sequencer B holds the same record, as its own watcher would after reading + // the peer block, and reconstructs A's chain from a fresh store. + let (mut seq_b, _mempool_b) = + SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await; + assert_eq!( + seq_b + .block_store() + .dbio() + .add_pending_cross_zone_dispatches(vec![record]) + .unwrap(), + 1 + ); + + let mock_b = MockBlockPublisher::with_canned_channel(channel_id, Some(tip_slot), messages); + SequencerCore::::verify_and_reconstruct( + &mock_b, + &seq_b.store, + &seq_b.chain, + true, + ) + .await + .expect("reconstruct"); + + let tip_b = seq_b.block_store().latest_block_meta().unwrap().unwrap(); + assert_eq!(tip_b.id, tip_a.id); + assert_eq!(tip_b.hash, tip_a.hash); + assert!( + seq_b + .block_store() + .dbio() + .get_pending_cross_zone_dispatches() + .unwrap() + .is_empty(), + "reconstruction must settle the record of a delivery it replayed" + ); + + // The delivery landed exactly once, and the next turn does not re-emit it. + let record_id = ping_record_pda(programs::ping_receiver().id()); + assert_eq!( + seq_b.with_state(|state| state.get_account_by_id(record_id).data.into_inner()), + payload, + "the reconstructed delivery must reach its target program" + ); + seq_b.produce_new_block().await.unwrap(); + let produced = seq_b + .block_store() + .get_block_at_id(tip_b.id + 1) + .unwrap() + .expect("produced block present"); + assert!( + !dispatches_in(&produced).contains(&key), + "the reconstructed delivery must not be re-emitted" + ); +} + +/// A delivery this node published itself, served back by the channel at or below +/// its own tip. That path verifies the block matches and returns early, so it is +/// reached on every restart. It must still settle the delivery's record: the +/// channel serving the block is what makes it irreversible, and nothing later +/// will ever put that key in a block again. +#[tokio::test] +async fn a_verified_own_block_settles_its_delivery_records() { + let record = dispatch_record(37, ping_payload(b"verified")); + let key = record.message_key; + + let (mut seq, _mempool) = + SequencerCoreWithMockClients::start_from_config(cross_zone_test_config()).await; + seq.block_store() + .dbio() + .add_pending_cross_zone_dispatches(vec![record]) + .unwrap(); + + let block_id = seq.produce_new_block().await.unwrap(); + let block = seq + .block_store() + .get_block_at_id(block_id) + .unwrap() + .unwrap(); + assert_eq!(dispatches_in(&block), vec![key]); + assert_eq!( + seq.block_store() + .dbio() + .get_pending_cross_zone_dispatches() + .unwrap() + .len(), + 1, + "producing the block is not what settles the record" + ); + + // The channel serves our own chain back, tip included. + let messages = channel_from_store(seq.block_store(), 10); + let tip_slot = messages.last().unwrap().1; + let mock = MockBlockPublisher::with_canned_channel( + seq.sequencer_config.bedrock_config.channel_id, + Some(tip_slot), + messages, + ); + SequencerCore::::verify_and_reconstruct( + &mock, &seq.store, &seq.chain, true, + ) + .await + .expect("reconstruct"); + + assert!( + seq.block_store() + .dbio() + .get_pending_cross_zone_dispatches() + .unwrap() + .is_empty(), + "a delivery the channel confirms must not leave a record nothing can remove" + ); +} + +#[tokio::test] +async fn committed_local_against_missing_channel_fails_without_anchor() { + // A sequencer that has committed blocks — a non-genesis tip plus a persisted + // checkpoint — but only ever produced (so it never recorded a per-block + // anchor). Restarting it against a wiped/missing channel must still fail, + // driven by the committed-blocks invariant rather than an anchor probe. + let config = setup_sequencer_config(); + { + let (mut seq, _handle) = + SequencerCoreWithMockClients::start_from_config(config.clone()).await; + seq.produce_new_block().await.unwrap(); + seq.produce_new_block().await.unwrap(); + assert!(seq.block_store().latest_block_meta().unwrap().unwrap().id > 1); + } // drop releases the store so we can reopen it + + // Reopen: blocks beyond genesis, no anchor. `is_fresh_start = false` stands in + // for a checkpoint persisted by a prior sync (the mock never emits one). + let (store, chain) = fresh_store_and_chain(&config); + assert!(store.get_zone_anchor().unwrap().is_none()); + assert!(store.latest_block_meta().unwrap().unwrap().id > 1); + + // The channel is gone: no tip, no messages. + let mock = + MockBlockPublisher::with_canned_channel(config.bedrock_config.channel_id, None, vec![]); + let result = + SequencerCore::::verify_and_reconstruct(&mock, &store, &chain, false) + .await; + assert!( + result.is_err(), + "committed blocks against a missing channel must abort startup" + ); +} diff --git a/lez/sequencer/service/Cargo.toml b/lez/sequencer/service/Cargo.toml index 3427dc22..338aac0d 100644 --- a/lez/sequencer/service/Cargo.toml +++ b/lez/sequencer/service/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "sequencer_service" version = "0.1.0" +default-run = "sequencer_service" edition = "2024" license = { workspace = true } @@ -19,6 +20,7 @@ programs.workspace = true clap = { workspace = true, features = ["derive", "env"] } anyhow.workspace = true env_logger.workspace = true +hex.workspace = true log.workspace = true tokio.workspace = true tokio-util.workspace = true diff --git a/lez/sequencer/service/configs/debug/sequencer_config.json b/lez/sequencer/service/configs/debug/sequencer_config.json index 7ea85b48..92072e84 100644 --- a/lez/sequencer/service/configs/debug/sequencer_config.json +++ b/lez/sequencer/service/configs/debug/sequencer_config.json @@ -11,7 +11,8 @@ "max_retries": 5 }, "channel_id": "0101010101010101010101010101010101010101010101010101010101010101", - "node_url": "http://localhost:18080" + "node_url": "http://localhost:18080", + "funding_key": "2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26" }, "genesis": [ { diff --git a/lez/sequencer/service/configs/docker/sequencer_config.json b/lez/sequencer/service/configs/docker/sequencer_config.json index 24184ea8..44bebf54 100644 --- a/lez/sequencer/service/configs/docker/sequencer_config.json +++ b/lez/sequencer/service/configs/docker/sequencer_config.json @@ -11,7 +11,8 @@ "max_retries": 5 }, "channel_id": "0101010101010101010101010101010101010101010101010101010101010101", - "node_url": "http://host.docker.internal:18080" + "node_url": "http://host.docker.internal:18080", + "funding_key": "2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26" }, "genesis": [ { diff --git a/lez/sequencer/service/protocol/src/lib.rs b/lez/sequencer/service/protocol/src/lib.rs index 58e300f6..ce669d31 100644 --- a/lez/sequencer/service/protocol/src/lib.rs +++ b/lez/sequencer/service/protocol/src/lib.rs @@ -4,7 +4,7 @@ use std::{fmt::Display, str::FromStr}; pub use common::{HashType, block::Block, transaction::LeeTransaction}; pub use lee::{Account, AccountId, ProgramId}; -pub use lee_core::{BlockId, Commitment, MembershipProof, account::Nonce}; +pub use lee_core::{BlockId, Commitment, CommitmentSetDigest, MembershipProof, account::Nonce}; use serde_with::{DeserializeFromStr, SerializeDisplay}; #[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)] diff --git a/lez/sequencer/service/rpc/src/lib.rs b/lez/sequencer/service/rpc/src/lib.rs index 914232c0..a1d2acfb 100644 --- a/lez/sequencer/service/rpc/src/lib.rs +++ b/lez/sequencer/service/rpc/src/lib.rs @@ -6,8 +6,8 @@ use jsonrpsee::types::ErrorObjectOwned; #[cfg(feature = "client")] pub use jsonrpsee::{core::ClientError, http_client::HttpClientBuilder as SequencerClientBuilder}; use sequencer_service_protocol::{ - Account, AccountId, Block, BlockId, ChannelId, Commitment, HashType, LeeTransaction, - MembershipProof, Nonce, ProgramId, + Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, HashType, + LeeTransaction, MembershipProof, Nonce, ProgramId, }; #[cfg(all(not(feature = "server"), not(feature = "client")))] @@ -68,7 +68,7 @@ pub trait Rpc { async fn get_transaction( &self, tx_hash: HashType, - ) -> Result, ErrorObjectOwned>; + ) -> Result, ErrorObjectOwned>; #[method(name = "getAccountsNonces")] async fn get_accounts_nonces( @@ -76,11 +76,11 @@ pub trait Rpc { account_ids: Vec, ) -> Result, ErrorObjectOwned>; - #[method(name = "getProofForCommitment")] - async fn get_proof_for_commitment( + #[method(name = "getProofsAndRoot")] + async fn get_proofs_and_root( &self, - commitment: Commitment, - ) -> Result, ErrorObjectOwned>; + commitments: Vec, + ) -> Result<(Vec>, CommitmentSetDigest), ErrorObjectOwned>; #[method(name = "getAccount")] async fn get_account(&self, account_id: AccountId) -> Result; @@ -90,6 +90,4 @@ pub trait Rpc { #[method(name = "getChannelId")] async fn get_channel_id(&self) -> Result; - - // ============================================================================================= } diff --git a/lez/sequencer/service/src/bin/bedrock_pubkey.rs b/lez/sequencer/service/src/bin/bedrock_pubkey.rs new file mode 100644 index 00000000..ad602be6 --- /dev/null +++ b/lez/sequencer/service/src/bin/bedrock_pubkey.rs @@ -0,0 +1,35 @@ +//! Prints the sequencer's bedrock signing public key (hex) without booting it. +//! +//! Loads `/bedrock_signing_key` from the given sequencer config, creating +//! the key if it doesn't exist yet, so that a node can be accredited into the +//! channel committee before its first boot. + +use std::path::PathBuf; + +use anyhow::Result; +use clap::Parser; + +#[derive(Debug, Parser)] +#[clap(version)] +struct Args { + #[clap(name = "config")] + config_path: PathBuf, + /// Override the config's home directory, matching the sequencer's --home. + #[clap(long)] + home: Option, +} + +#[expect( + clippy::print_stdout, + reason = "the hex pubkey on stdout is this binary's output" +)] +fn main() -> Result<()> { + let Args { config_path, home } = Args::parse(); + + let config = sequencer_service::SequencerConfig::from_path(&config_path)?; + let home = home.unwrap_or(config.home); + let key = sequencer_core::load_or_create_signing_key(&home.join("bedrock_signing_key"))?; + println!("{}", hex::encode(key.public_key().to_bytes())); + + Ok(()) +} diff --git a/lez/sequencer/service/src/bin/configure_channel.rs b/lez/sequencer/service/src/bin/configure_channel.rs new file mode 100644 index 00000000..01109d37 --- /dev/null +++ b/lez/sequencer/service/src/bin/configure_channel.rs @@ -0,0 +1,71 @@ +//! Posts a `ChannelConfig` op (accredited keys + rotation params) to bedrock, +//! signed with `/bedrock_signing_key`, without booting the sequencer. +//! +//! Authorization is holding the admin key file — the L1 rejects non-admin +//! signers. Acceptance is asynchronous: a rejection only shows up in node +//! logs and on-chain behavior. + +use std::path::PathBuf; + +use anyhow::{Context as _, Result, anyhow}; +use clap::Parser; +use sequencer_core::block_publisher::{Ed25519PublicKey, post_channel_config}; + +#[derive(Debug, Parser)] +#[clap(version)] +struct Args { + #[clap(name = "config")] + config_path: PathBuf, + /// Override the config's home directory, matching the sequencer's --home. + #[clap(long)] + home: Option, + /// Accredited ed25519 public keys (hex), admin (this node's key) first. + #[clap(long, required = true, value_delimiter = ',')] + keys: Vec, + /// Slots a sequencer's posting turn lasts. + #[clap(long)] + posting_timeframe: u32, + /// Slots after which a stalled turn can be taken over. + #[clap(long)] + posting_timeout: u32, + /// Signatures required for future config changes. + #[clap(long, default_value_t = 1)] + configuration_threshold: u16, + /// Signatures required for channel transfers. + #[clap(long, default_value_t = 1)] + transfer_threshold: u16, +} + +#[tokio::main] +async fn main() -> Result<()> { + env_logger::init(); + let args = Args::parse(); + + let config = sequencer_service::SequencerConfig::from_path(&args.config_path)?; + let home = args.home.unwrap_or(config.home); + let signing_key = + sequencer_core::load_or_create_signing_key(&home.join("bedrock_signing_key"))?; + let keys = args + .keys + .iter() + .map(|key| parse_key(key)) + .collect::>>()?; + + post_channel_config( + &config.bedrock_config, + &signing_key, + keys, + args.posting_timeframe, + args.posting_timeout, + args.configuration_threshold, + args.transfer_threshold, + ) + .await +} + +fn parse_key(hex_key: &str) -> Result { + let mut bytes = [0_u8; 32]; + hex::decode_to_slice(hex_key, &mut bytes) + .with_context(|| format!("Invalid hex-encoded key {hex_key}"))?; + Ed25519PublicKey::from_bytes(&bytes).map_err(|err| anyhow!("Invalid Ed25519 public key: {err}")) +} diff --git a/lez/sequencer/service/src/lib.rs b/lez/sequencer/service/src/lib.rs index 687ee424..ba5b68ec 100644 --- a/lez/sequencer/service/src/lib.rs +++ b/lez/sequencer/service/src/lib.rs @@ -11,10 +11,15 @@ use mempool::MemPoolHandle; use sequencer_core::SequencerCore; #[cfg(feature = "standalone")] use sequencer_core::SequencerCoreWithMockClients as SequencerCore; -use sequencer_core::TransactionOrigin; pub use sequencer_core::config::*; +use sequencer_core::{ + TransactionOrigin, + block_publisher::BlockPublisherTrait as _, + task_group::{StoreRelease, TaskGroup}, +}; use sequencer_service_rpc::RpcServer as _; use tokio::{sync::Mutex, task::JoinHandle}; +use tokio_util::sync::CancellationToken; pub mod service; @@ -25,9 +30,19 @@ const REQUEST_BODY_MAX_SIZE: ByteSize = ByteSize::mib(10); /// Implements `Drop` to ensure all tasks are aborted and the RPC server is stopped when dropped. pub struct SequencerHandle { addr: SocketAddr, - /// Option because of `Drop` which forbids to simply move out of `self` in `stopped()`. - server_handle: Option, + server_handle: ServerHandle, main_loop_handle: JoinHandle>, + /// Cancelled when the publisher's drive task terminates (e.g. a panicked + /// persist sink); no channel events are processed past that point. + driver_cancellation: CancellationToken, + /// The core's background tasks, taken before the core was shared. This + /// handle owns no reference to the core itself, so without these there is + /// nothing to wait on: aborting the main loop only starts the teardown. + background_tasks: Vec, + /// The store, weakly. Every strong reference lives inside something this + /// handle stops, so watching the count go to zero is how shutdown knows the + /// database file is actually closed rather than assuming it from drop order. + store: StoreRelease, } impl SequencerHandle { @@ -35,27 +50,74 @@ impl SequencerHandle { addr: SocketAddr, server_handle: ServerHandle, main_loop_handle: JoinHandle>, + driver_cancellation: CancellationToken, + background_tasks: Vec, + store: StoreRelease, ) -> Self { Self { addr, - server_handle: Some(server_handle), + server_handle, main_loop_handle, + driver_cancellation, + background_tasks, + store, } } + /// Stops the sequencer and waits for every part of it to be gone. + /// + /// `Drop` alone cannot do this: it aborts the main loop without awaiting it, + /// and the core lives behind `Arc`s held by that task and the RPC server, so + /// after a plain drop the store is still open for an unbounded stretch. That + /// is why restarting a sequencer on the same home directory used to need a + /// sleep, and why an in-process restart could fail outright with a `RocksDB` + /// lock error. + /// + /// Order matters: the main loop stops first so nothing new is produced while + /// the publisher is torn down, then the background tasks that hold the store, + /// then the server. Consuming `self` drops the last references, so the store + /// is closed by the time this returns. + pub async fn shutdown(mut self) { + self.main_loop_handle.abort(); + if let Err(err) = (&mut self.main_loop_handle).await + && err.is_panic() + { + error!("Sequencer main loop panicked before shutdown: {err}"); + } + + for tasks in &self.background_tasks { + tasks.shutdown().await; + } + + if let Err(err) = self.server_handle.stop() { + error!("An error occurred while stopping Sequencer RPC server: {err}"); + } + self.server_handle.clone().stopped().await; + + // Nothing this handle owns holds the store, so waiting here rather than + // after the drop is the same thing, and it keeps the guarantee inside + // the call the caller awaits. + wait_for_store_release(&self.store).await; + } + /// Wait for any of the sequencer tasks to fail and return the error. #[expect( clippy::integer_division_remainder_used, reason = "Generated by select! macro, can't be easily rewritten to avoid this lint" )] - pub async fn failed(mut self) -> Result { + pub async fn failed(&mut self) -> Result { let Self { addr: _, server_handle, main_loop_handle, - } = &mut self; + driver_cancellation, + background_tasks: _, + store: _, + } = self; - let server_handle = server_handle.take().expect("Server handle is set"); + // Cloned rather than taken: `stopped()` consumes a handle, and taking + // this one would leave `shutdown` with no way to stop the server. + let server_handle = server_handle.clone(); tokio::select! { () = server_handle.stopped() => { Err(anyhow!("RPC Server stopped")) @@ -65,6 +127,9 @@ impl SequencerHandle { .context("Main loop task panicked")? .context("Main loop exited unexpectedly") } + () = driver_cancellation.cancelled() => { + Err(anyhow!("Publisher drive task terminated")) + } } } @@ -78,10 +143,17 @@ impl SequencerHandle { addr: _, server_handle, main_loop_handle, + driver_cancellation, + background_tasks, + store: _, } = self; - let stopped = server_handle.as_ref().is_none_or(ServerHandle::is_stopped) - || main_loop_handle.is_finished(); + let stopped = server_handle.is_stopped() + || main_loop_handle.is_finished() + || driver_cancellation.is_cancelled() + // A watcher only ends by panicking, and a peer whose deliveries have + // silently stopped is exactly what this predicate exists to catch. + || background_tasks.iter().any(TaskGroup::any_finished); !stopped } @@ -97,35 +169,69 @@ impl Drop for SequencerHandle { addr: _, server_handle, main_loop_handle, + driver_cancellation: _, + background_tasks: _, + store: _, } = self; main_loop_handle.abort(); - let Some(handle) = server_handle else { - return; - }; - - if let Err(err) = handle.stop() { + if let Err(err) = server_handle.stop() { error!("An error occurred while stopping Sequencer RPC server: {err}"); } } } -pub async fn run(config: SequencerConfig, port: u16) -> Result { +/// Waits until nothing holds the store any more. +/// +/// Everything that holds one lives inside a task or a server this handle has +/// already stopped, but the last drop happens on whichever thread ran them, not +/// on this one. Without this the caller can reopen the database a moment too +/// early and hit a `RocksDB` lock error, which is the kind of failure that shows +/// up as an occasional flake rather than a bug. +async fn wait_for_store_release(store: &StoreRelease) { + /// Long enough for a drop that is already in flight, short enough that a + /// leak is reported rather than hung on. + const RELEASE_TIMEOUT: Duration = Duration::from_secs(10); + const POLL: Duration = Duration::from_millis(10); + + let released = tokio::time::timeout(RELEASE_TIMEOUT, async { + while store.holders() > 0 { + tokio::time::sleep(POLL).await; + } + }) + .await; + + if released.is_err() { + error!( + "Sequencer store still held by {} reference(s) after shutdown; something outlived the tasks it should have died with", + store.holders() + ); + } +} + +pub async fn run(config: SequencerConfig, listen_addr: SocketAddr) -> Result { let block_timeout = config.block_create_timeout; let max_block_size = config.max_block_size; - let (sequencer_core, mempool_handle) = SequencerCore::start_from_config(config).await; + let (sequencer_core, mempool_handle): (SequencerCore, _) = + SequencerCore::start_from_config(config).await; info!("Sequencer core set up"); + let driver_cancellation = sequencer_core.block_publisher().driver_cancellation(); + // Taken while the core is still owned here: once it is behind the `Arc` + // below, the only owners are the RPC server and the main loop task, and + // neither hands it back. + let background_tasks = sequencer_core.background_tasks(); + let store = sequencer_core.store_release(); let seq_core_wrapped = Arc::new(Mutex::new(sequencer_core)); let mempool_handle_for_server = mempool_handle.clone(); let (server_handle, addr) = run_server( Arc::clone(&seq_core_wrapped), mempool_handle_for_server, - port, + listen_addr, max_block_size.as_u64(), ) .await?; @@ -136,13 +242,20 @@ pub async fn run(config: SequencerConfig, port: u16) -> Result let _ = mempool_handle; - Ok(SequencerHandle::new(addr, server_handle, main_loop_handle)) + Ok(SequencerHandle::new( + addr, + server_handle, + main_loop_handle, + driver_cancellation, + background_tasks, + store, + )) } async fn run_server( sequencer: Arc>, mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, - port: u16, + listen_addr: SocketAddr, max_block_size: u64, ) -> Result<(ServerHandle, SocketAddr)> { let server = jsonrpsee::server::ServerBuilder::with_config( @@ -153,7 +266,7 @@ async fn run_server( ) .build(), ) - .build(SocketAddr::from(([0, 0, 0, 0], port))) + .build(listen_addr) .await .context("Failed to build RPC server")?; @@ -172,16 +285,15 @@ async fn main_loop(seq_core: Arc>, block_timeout: Duration) loop { tokio::time::sleep(block_timeout).await; - info!("Collecting transactions from mempool, block creation"); + let mut state = seq_core.lock().await; - let id = { - let mut state = seq_core.lock().await; - - state.produce_new_block().await? - }; + // Only produce on our turn. + if !state.is_our_turn() { + continue; + } + info!("Our turn: collecting transactions from mempool, creating block"); + let id = state.produce_new_block().await?; info!("Block with id {id} created"); - - info!("Waiting for new transactions"); } } diff --git a/lez/sequencer/service/src/main.rs b/lez/sequencer/service/src/main.rs index e78ad502..95d02e16 100644 --- a/lez/sequencer/service/src/main.rs +++ b/lez/sequencer/service/src/main.rs @@ -1,8 +1,12 @@ -use std::path::PathBuf; +use std::{ + net::{IpAddr, SocketAddr}, + path::PathBuf, +}; use anyhow::Result; use clap::Parser; use log::{error, info}; +use tokio::signal::unix::{SignalKind, signal}; use tokio_util::sync::CancellationToken; #[derive(Debug, Parser)] @@ -12,6 +16,14 @@ struct Args { config_path: PathBuf, #[clap(short, long, default_value = "3040")] port: u16, + /// Interface the RPC server binds to. The RPC has no caller auth — + /// bind loopback unless the port is firewalled. + #[clap(long, default_value = "0.0.0.0")] + listen_address: IpAddr, + /// Override the config's home directory (`RocksDB` + bedrock signing key), + /// so multiple instances can share one config file. + #[clap(long)] + home: Option, } #[tokio::main] @@ -22,12 +34,21 @@ struct Args { async fn main() -> Result<()> { env_logger::init(); - let Args { config_path, port } = Args::parse(); + let Args { + config_path, + port, + listen_address, + home, + } = Args::parse(); let cancellation_token = listen_for_shutdown_signal(); - let config = sequencer_service::SequencerConfig::from_path(&config_path)?; - let sequencer_handle = sequencer_service::run(config, port).await?; + let mut config = sequencer_service::SequencerConfig::from_path(&config_path)?; + if let Some(home) = home { + config.home = home; + } + let mut sequencer_handle = + sequencer_service::run(config, SocketAddr::new(listen_address, port)).await?; tokio::select! { () = cancellation_token.cancelled() => { @@ -38,21 +59,50 @@ async fn main() -> Result<()> { } } + // Stop the watchers, the publisher's drive task, the block loop and the RPC + // server, and wait for each. Dropping the handle only asks; the store stays + // open for an unbounded stretch after that, so a restart can find its own + // home directory locked, and a watcher can be killed between recording a + // delivery and handing it over. + sequencer_handle.shutdown().await; + info!("Sequencer shutdown complete"); Ok(()) } +/// Cancelled on Ctrl-C or `SIGTERM`. +/// +/// `SIGTERM` is what a container runtime sends first, so without it every +/// orchestrated stop is the ungraceful path. +#[expect( + clippy::integer_division_remainder_used, + reason = "Generated by select! macro, can't be easily rewritten to avoid this lint" +)] fn listen_for_shutdown_signal() -> CancellationToken { let cancellation_token = CancellationToken::new(); let cancellation_token_clone = cancellation_token.clone(); tokio::spawn(async move { - if let Err(err) = tokio::signal::ctrl_c().await { - error!("Failed to listen for Ctrl-C signal: {err}"); - return; + let mut terminate = match signal(SignalKind::terminate()) { + Ok(terminate) => terminate, + Err(err) => { + error!("Failed to listen for SIGTERM: {err}"); + return; + } + }; + + tokio::select! { + result = tokio::signal::ctrl_c() => match result { + Ok(()) => info!("Received Ctrl-C signal"), + Err(err) => { + error!("Failed to listen for Ctrl-C signal: {err}"); + return; + } + }, + _ = terminate.recv() => info!("Received SIGTERM"), } - info!("Received Ctrl-C signal"); + cancellation_token_clone.cancel(); }); diff --git a/lez/sequencer/service/src/service.rs b/lez/sequencer/service/src/service.rs index 3a48e7cc..7ab9ed3c 100644 --- a/lez/sequencer/service/src/service.rs +++ b/lez/sequencer/service/src/service.rs @@ -12,8 +12,8 @@ use sequencer_core::{ DbError, SequencerCore, TransactionOrigin, block_publisher::BlockPublisherTrait, }; use sequencer_service_protocol::{ - Account, AccountId, Block, BlockId, ChannelId, Commitment, HashType, MembershipProof, Nonce, - ProgramId, + Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, HashType, + MembershipProof, Nonce, ProgramId, }; use tokio::sync::Mutex; @@ -40,7 +40,7 @@ impl SequencerService { } #[async_trait] -impl sequencer_service_rpc::RpcServer +impl sequencer_service_rpc::RpcServer for SequencerService { async fn send_transaction(&self, tx: LeeTransaction) -> Result { @@ -74,6 +74,20 @@ impl sequencer_service_rpc::RpcServer ) })?; + // Sequencer-only programs (the cross-zone inbox) are injected by the + // watcher; a user must not invoke them top-level, or anyone could forge + // an inbound cross-zone delivery. Chained user calls are already rejected + // by the inbox guest's caller-is-none assertion. + if let LeeTransaction::Public(public_tx) = &authenticated_tx + && sequencer_core::is_sequencer_only_program(public_tx.message().program_id) + { + return Err(ErrorObjectOwned::owned( + ErrorCode::InvalidParams.code(), + "Program is sequencer-only and cannot be invoked by a user transaction".to_owned(), + None::<()>, + )); + } + self.mempool_handle .push((TransactionOrigin::User, authenticated_tx)) .await @@ -124,14 +138,14 @@ impl sequencer_service_rpc::RpcServer async fn get_account_balance(&self, account_id: AccountId) -> Result { let sequencer = self.sequencer.lock().await; - let account = sequencer.state().get_account_by_id(account_id); - Ok(account.balance) + let balance = sequencer.with_state(|state| state.get_account_by_id(account_id).balance); + Ok(balance) } async fn get_transaction( &self, tx_hash: HashType, - ) -> Result, ErrorObjectOwned> { + ) -> Result, ErrorObjectOwned> { let sequencer = self.sequencer.lock().await; Ok(sequencer.block_store().get_transaction_by_hash(tx_hash)) } @@ -141,24 +155,32 @@ impl sequencer_service_rpc::RpcServer account_ids: Vec, ) -> Result, ErrorObjectOwned> { let sequencer = self.sequencer.lock().await; - let nonces = account_ids - .into_iter() - .map(|account_id| sequencer.state().get_account_by_id(account_id).nonce) - .collect(); + let nonces = sequencer.with_state(|state| { + account_ids + .into_iter() + .map(|account_id| state.get_account_by_id(account_id).nonce) + .collect() + }); Ok(nonces) } - async fn get_proof_for_commitment( + async fn get_proofs_and_root( &self, - commitment: Commitment, - ) -> Result, ErrorObjectOwned> { + commitments: Vec, + ) -> Result<(Vec>, CommitmentSetDigest), ErrorObjectOwned> { let sequencer = self.sequencer.lock().await; - Ok(sequencer.state().get_proof_for_commitment(&commitment)) + Ok(sequencer.with_state(|state| { + let proofs = commitments + .iter() + .map(|commitment| state.get_proof_for_commitment(commitment)) + .collect(); + (proofs, state.commitment_root()) + })) } async fn get_account(&self, account_id: AccountId) -> Result { let sequencer = self.sequencer.lock().await; - Ok(sequencer.state().get_account_by_id(account_id)) + Ok(sequencer.with_state(|state| state.get_account_by_id(account_id))) } async fn get_program_ids(&self) -> Result, ErrorObjectOwned> { diff --git a/lez/storage/Cargo.toml b/lez/storage/Cargo.toml index 8767b525..18c58e11 100644 --- a/lez/storage/Cargo.toml +++ b/lez/storage/Cargo.toml @@ -13,8 +13,10 @@ lee.workspace = true thiserror.workspace = true borsh.workspace = true +log.workspace = true rocksdb.workspace = true tempfile.workspace = true +zstd.workspace = true [dev-dependencies] programs.workspace = true diff --git a/lez/storage/src/error.rs b/lez/storage/src/error.rs index 3056e09b..069a95d8 100644 --- a/lez/storage/src/error.rs +++ b/lez/storage/src/error.rs @@ -12,6 +12,12 @@ pub enum DbError { error: borsh::io::Error, additional_info: Option, }, + #[error("Compression error: {}", additional_info.as_deref().unwrap_or("No additional info"))] + CompressionError { + #[source] + error: std::io::Error, + additional_info: Option, + }, #[error("Logic Error: {additional_info}")] DbInteractionError { additional_info: String }, } @@ -33,6 +39,14 @@ impl DbError { } } + #[must_use] + pub const fn compression_error(err: std::io::Error, message: Option) -> Self { + Self::CompressionError { + error: err, + additional_info: message, + } + } + #[must_use] pub const fn db_interaction_error(message: String) -> Self { Self::DbInteractionError { diff --git a/lez/storage/src/indexer/indexer_cells.rs b/lez/storage/src/indexer/indexer_cells.rs index b19a5510..ef104f1a 100644 --- a/lez/storage/src/indexer/indexer_cells.rs +++ b/lez/storage/src/indexer/indexer_cells.rs @@ -7,9 +7,9 @@ use crate::{ error::DbError, indexer::{ ACC_NUM_CELL_NAME, BLOCK_HASH_CELL_NAME, BREAKPOINT_CELL_NAME, CF_ACC_META, - CF_BREAKPOINT_NAME, CF_HASH_TO_ID, CF_TX_TO_ID, DB_META_LAST_BREAKPOINT_ID, - DB_META_LAST_OBSERVED_L1_LIB_HEADER_ID_IN_DB_KEY, DB_META_ZONE_SDK_INDEXER_CURSOR_KEY, - TX_HASH_CELL_NAME, + CF_BREAKPOINT_NAME, CF_HASH_TO_ID, CF_TX_TO_ID, + DB_META_LAST_OBSERVED_L1_LIB_HEADER_ID_IN_DB_KEY, DB_META_STALL_REASON_KEY, + DB_META_TIP_SLOT_KEY, DB_META_ZONE_SDK_INDEXER_CURSOR_KEY, TX_HASH_CELL_NAME, }, }; @@ -36,29 +36,6 @@ impl SimpleWritableCell for LastObservedL1LibHeaderCell { } } -#[derive(Debug, BorshSerialize, BorshDeserialize)] -pub struct LastBreakpointIdCell(pub u64); - -impl SimpleStorableCell for LastBreakpointIdCell { - type KeyParams = (); - - const CELL_NAME: &'static str = DB_META_LAST_BREAKPOINT_ID; - const CF_NAME: &'static str = CF_META_NAME; -} - -impl SimpleReadableCell for LastBreakpointIdCell {} - -impl SimpleWritableCell for LastBreakpointIdCell { - fn value_constructor(&self) -> DbResult> { - borsh::to_vec(&self).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize last breakpoint id".to_owned()), - ) - }) - } -} - #[derive(BorshDeserialize)] pub struct BreakpointCellOwned(pub V03State); @@ -212,6 +189,27 @@ impl SimpleWritableCell for AccNumTxCell { } } +/// The L1 inscription slot of the tip block, written atomically with the tip. +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct TipSlotCell(pub u64); + +impl SimpleStorableCell for TipSlotCell { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_TIP_SLOT_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for TipSlotCell {} + +impl SimpleWritableCell for TipSlotCell { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize tip slot".to_owned())) + }) + } +} + /// Opaque bytes for the zone-sdk indexer cursor `Option<(MsgId, Slot)>`. /// The caller serializes via `serde_json` (neither type derives borsh). #[derive(BorshDeserialize)] @@ -247,6 +245,40 @@ impl SimpleWritableCell for ZoneSdkIndexerCursorCellRef<'_> { } } +/// Opaque JSON bytes for the indexer's persisted `Option`. +#[derive(BorshDeserialize)] +pub struct StallReasonCellOwned(pub Vec); + +impl SimpleStorableCell for StallReasonCellOwned { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_STALL_REASON_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for StallReasonCellOwned {} + +#[derive(BorshSerialize)] +pub struct StallReasonCellRef<'bytes>(pub &'bytes [u8]); + +impl SimpleStorableCell for StallReasonCellRef<'_> { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_STALL_REASON_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleWritableCell for StallReasonCellRef<'_> { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize stall reason cell".to_owned()), + ) + }) + } +} + #[cfg(test)] mod uniform_tests { use crate::{ diff --git a/lez/storage/src/indexer/mod.rs b/lez/storage/src/indexer/mod.rs index a753a71a..0955ef26 100644 --- a/lez/storage/src/indexer/mod.rs +++ b/lez/storage/src/indexer/mod.rs @@ -5,6 +5,7 @@ use common::{ transaction::{LeeTransaction, clock_invocation}, }; use lee::{GENESIS_BLOCK_ID, V03State}; +use log::warn; use rocksdb::{ BoundColumnFamily, ColumnFamilyDescriptor, DBWithThreadMode, MultiThreaded, Options, }; @@ -20,10 +21,12 @@ pub mod write_non_atomic; /// Key base for storing metainformation about id of last observed L1 lib header in db. pub const DB_META_LAST_OBSERVED_L1_LIB_HEADER_ID_IN_DB_KEY: &str = "last_observed_l1_lib_header_in_db"; -/// Key base for storing metainformation about the last breakpoint. -pub const DB_META_LAST_BREAKPOINT_ID: &str = "last_breakpoint_id"; /// Key base for storing the zone-sdk indexer cursor (opaque bytes). pub const DB_META_ZONE_SDK_INDEXER_CURSOR_KEY: &str = "zone_sdk_indexer_cursor"; +/// Key base for storing the persisted `Option` diagnostic record (opaque JSON bytes). +pub const DB_META_STALL_REASON_KEY: &str = "stall_reason"; +/// Key base for storing the L1 inscription slot of the tip block. +pub const DB_META_TIP_SLOT_KEY: &str = "tip_slot"; /// Cell name for a breakpoint. pub const BREAKPOINT_CELL_NAME: &str = "breakpoint"; @@ -84,9 +87,10 @@ impl RocksDBIO { let dbio = Self { db }; - // First breakpoint setup - dbio.put_breakpoint(0, initial_state)?; - dbio.put_meta_last_breakpoint_id(0)?; + // Seed the genesis snapshot once; reopening must not clobber it. + if dbio.get_breakpoint_opt(0)?.is_none() { + dbio.put_breakpoint(0, initial_state)?; + } Ok(dbio) } @@ -152,98 +156,41 @@ impl RocksDBIO { )); } - let br_id = closest_breakpoint_id(block_id); - let mut breakpoint = self.get_breakpoint(br_id)?; + // walk down to the nearest snapshot that exists + let target = closest_breakpoint_id(block_id); + let mut br_id = target; + let mut state = loop { + match self.get_breakpoint_opt(br_id)? { + Some(state) => break state, + None if br_id == 0 => { + return Err(DbError::db_interaction_error( + "Breakpoint 0 is missing".to_owned(), + )); + } + None => { + br_id = br_id + .checked_sub(1) + .expect("breakpoint_id > 0 checked above"); + } + } + }; + if br_id < target { + warn!( + "Breakpoint {target} missing; replaying from breakpoint {br_id} for block {block_id}" + ); + } let start = u64::from(BREAKPOINT_INTERVAL) .checked_mul(br_id) .expect("Reached maximum breakpoint id"); - for mut block in self.get_block_batch_seq( + for block in self.get_block_batch_seq( start.checked_add(1).expect("Will be lesser that u64::MAX")..=block_id, )? { - let expected_clock = LeeTransaction::Public(clock_invocation(block.header.timestamp)); - - let clock_tx = block.body.transactions.pop().ok_or_else(|| { - DbError::db_interaction_error( - "Block must contain clock transaction at the end".to_owned(), - ) - })?; - let user_txs = block.body.transactions; - - if clock_tx != expected_clock { - return Err(DbError::db_interaction_error( - "Last transaction in block must be the clock invocation for the block timestamp" - .to_owned(), - )); - } - for transaction in user_txs { - let is_genesis = block.header.block_id == GENESIS_BLOCK_ID; - if is_genesis { - let genesis_tx = match transaction { - LeeTransaction::Public(public_tx) => public_tx, - LeeTransaction::PrivacyPreserving(_) - | LeeTransaction::ProgramDeployment(_) => { - return Err(DbError::db_interaction_error( - "Genesis block should contain only public transactions".to_owned(), - )); - } - }; - breakpoint - .transition_from_public_transaction( - &genesis_tx, - block.header.block_id, - block.header.timestamp, - ) - .map_err(|err| { - DbError::db_interaction_error(format!( - "genesis transaction execution failed with err {err:?}" - )) - })?; - } else { - transaction - .transaction_stateless_check() - .map_err(|err| { - DbError::db_interaction_error(format!( - "transaction pre check failed with err {err:?}" - )) - })? - // FIXME: HOT FIX (testnet v0.2): does not check for system account updates due to - // sequencer-generated deposit tx'es; - // CHANGE ME back to `execute_check_on_state` when the indexer can authenticate deposit transactions - .execute_without_system_accounts_check_on_state( - &mut breakpoint, - block.header.block_id, - block.header.timestamp, - ) - .map_err(|err| { - DbError::db_interaction_error(format!( - "transaction execution failed with err {err:?}" - )) - })?; - } - } - - let LeeTransaction::Public(clock_public_tx) = clock_tx else { - return Err(DbError::db_interaction_error( - "Clock invocation must be a public transaction".to_owned(), - )); - }; - - breakpoint - .transition_from_public_transaction( - &clock_public_tx, - block.header.block_id, - block.header.timestamp, - ) - .map_err(|err| { - DbError::db_interaction_error(format!( - "clock transaction execution failed with err {err:?}" - )) - })?; + apply_block_transactions(block, &mut state)?; } - Ok(breakpoint) + Ok(state) } pub fn final_state(&self) -> DbResult { @@ -252,6 +199,73 @@ impl RocksDBIO { } } +fn apply_block_transactions(mut block: Block, state: &mut V03State) -> DbResult<()> { + let expected_clock = LeeTransaction::Public(clock_invocation(block.header.timestamp)); + + let clock_tx = block.body.transactions.pop().ok_or_else(|| { + DbError::db_interaction_error("Block must contain clock transaction at the end".to_owned()) + })?; + + if clock_tx != expected_clock { + return Err(DbError::db_interaction_error( + "Last transaction in block must be the clock invocation for the block timestamp" + .to_owned(), + )); + } + + for transaction in block.body.transactions { + if block.header.block_id == GENESIS_BLOCK_ID { + let genesis_tx = match transaction { + LeeTransaction::Public(public_tx) => public_tx, + LeeTransaction::PrivacyPreserving(_) | LeeTransaction::ProgramDeployment(_) => { + return Err(DbError::db_interaction_error( + "Genesis block should contain only public transactions".to_owned(), + )); + } + }; + state + .transition_from_public_transaction( + &genesis_tx, + block.header.block_id, + block.header.timestamp, + ) + .map_err(|err| { + DbError::db_interaction_error(format!( + "genesis transaction execution failed with err {err:?}" + )) + })?; + } else { + transaction + .execute_on_state(state, block.header.block_id, block.header.timestamp) + .map_err(|err| { + DbError::db_interaction_error(format!( + "transaction execution failed with err {err:?}" + )) + })?; + } + } + + let LeeTransaction::Public(clock_public_tx) = clock_tx else { + return Err(DbError::db_interaction_error( + "Clock invocation must be a public transaction".to_owned(), + )); + }; + + state + .transition_from_public_transaction( + &clock_public_tx, + block.header.block_id, + block.header.timestamp, + ) + .map_err(|err| { + DbError::db_interaction_error(format!( + "clock transaction execution failed with err {err:?}" + )) + })?; + + Ok(()) +} + fn closest_breakpoint_id(block_id: u64) -> u64 { block_id .saturating_sub(1) @@ -261,439 +275,4 @@ fn closest_breakpoint_id(block_id: u64) -> u64 { #[expect(clippy::shadow_unrelated, reason = "Fine for tests")] #[cfg(test)] -mod tests { - use common::test_utils::produce_dummy_block; - use lee::{Account, AccountId, PublicKey}; - use tempfile::tempdir; - - use super::*; - - fn genesis_block() -> Block { - produce_dummy_block(1, None, vec![]) - } - - fn acc1_sign_key() -> lee::PrivateKey { - lee::PrivateKey::try_new([1; 32]).unwrap() - } - - fn acc2_sign_key() -> lee::PrivateKey { - lee::PrivateKey::try_new([2; 32]).unwrap() - } - - fn acc1() -> AccountId { - AccountId::from(&PublicKey::new_from_private_key(&acc1_sign_key())) - } - - fn acc2() -> AccountId { - AccountId::from(&PublicKey::new_from_private_key(&acc2_sign_key())) - } - - fn initial_state() -> lee::V03State { - let mut public_accounts = [(acc1(), 10000), (acc2(), 20000)] - .into_iter() - .map(|(id, balance)| { - ( - id, - Account { - program_owner: programs::authenticated_transfer().id(), - balance, - ..Account::default() - }, - ) - }) - .collect::>(); - for clock_id in system_accounts::clock_account_ids() { - public_accounts.push((clock_id, system_accounts::clock_account())); - } - - lee::V03State::new() - .with_public_accounts(public_accounts) - .with_programs([programs::authenticated_transfer(), programs::clock()]) - } - - #[test] - fn start_db() { - let temp_dir = tempdir().unwrap(); - let temdir_path = temp_dir.path(); - - let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state()).unwrap(); - - let last_id = dbio.get_meta_last_block_id_in_db().unwrap(); - let first_id = dbio.get_meta_first_block_id_in_db().unwrap(); - let is_first_set = dbio.get_meta_is_first_block_set().unwrap(); - let last_observed_l1_header = dbio.get_meta_last_observed_l1_lib_header_in_db().unwrap(); - let last_br_id = dbio.get_meta_last_breakpoint_id().unwrap(); - let last_block = dbio.get_block(1).unwrap(); - let breakpoint = dbio.get_breakpoint(0).unwrap(); - let final_state = dbio.final_state().unwrap(); - - assert_eq!(last_id, None); - assert_eq!(first_id, None); - assert_eq!(last_observed_l1_header, None); - assert!(!is_first_set); - assert_eq!(last_br_id, Some(0)); // TODO: Will be None after we remove hardcoded testnet state - assert!(last_block.is_none()); - assert_eq!( - breakpoint.get_account_by_id(acc1()), - final_state.get_account_by_id(acc1()) - ); - assert_eq!( - breakpoint.get_account_by_id(acc2()), - final_state.get_account_by_id(acc2()) - ); - } - - #[test] - fn one_block_insertion() { - let temp_dir = tempdir().unwrap(); - let temdir_path = temp_dir.path(); - - let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state()).unwrap(); - - let genesis_block = genesis_block(); - dbio.put_block(&genesis_block, [0; 32]).unwrap(); - - let prev_hash = genesis_block.header.hash; - let from = acc1(); - let to = acc2(); - let sign_key = acc1_sign_key(); - - let transfer_tx = - common::test_utils::create_transaction_native_token_transfer(from, 0, to, 1, &sign_key); - let block = produce_dummy_block(2, Some(prev_hash), vec![transfer_tx]); - - dbio.put_block(&block, [1; 32]).unwrap(); - - let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); - let first_id = dbio.get_meta_first_block_id_in_db().unwrap(); - let last_observed_l1_header = dbio - .get_meta_last_observed_l1_lib_header_in_db() - .unwrap() - .unwrap(); - let is_first_set = dbio.get_meta_is_first_block_set().unwrap(); - let last_br_id = dbio.get_meta_last_breakpoint_id().unwrap(); - let last_block = dbio.get_block(last_id).unwrap().unwrap(); - let breakpoint = dbio.get_breakpoint(0).unwrap(); - let final_state = dbio.final_state().unwrap(); - - assert_eq!(last_id, 2); - assert_eq!(first_id, Some(1)); - assert_eq!(last_observed_l1_header, [1; 32]); - assert!(is_first_set); - assert_eq!(last_br_id, Some(0)); - assert_eq!(last_block.header.hash, block.header.hash); - assert_eq!( - breakpoint.get_account_by_id(acc1()).balance - - final_state.get_account_by_id(acc1()).balance, - 1 - ); - assert_eq!( - final_state.get_account_by_id(acc2()).balance - - breakpoint.get_account_by_id(acc2()).balance, - 1 - ); - } - - #[test] - fn new_breakpoint() { - let temp_dir = tempdir().unwrap(); - let temdir_path = temp_dir.path(); - - let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state()).unwrap(); - - let from = acc1(); - let to = acc2(); - let sign_key = acc1_sign_key(); - - for i in 1..=BREAKPOINT_INTERVAL + 1 { - let prev_hash = dbio.get_meta_last_block_id_in_db().unwrap().map(|last_id| { - let last_block = dbio.get_block(last_id).unwrap().unwrap(); - last_block.header.hash - }); - - let transfer_tx = common::test_utils::create_transaction_native_token_transfer( - from, - (i - 1).into(), - to, - 1, - &sign_key, - ); - let block = produce_dummy_block(i.into(), prev_hash, vec![transfer_tx]); - dbio.put_block(&block, [i; 32]).unwrap(); - } - - let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); - let first_id = dbio.get_meta_first_block_id_in_db().unwrap(); - let is_first_set = dbio.get_meta_is_first_block_set().unwrap(); - let last_br_id = dbio.get_meta_last_breakpoint_id().unwrap(); - let last_block = dbio.get_block(last_id).unwrap().unwrap(); - let prev_breakpoint = dbio.get_breakpoint(0).unwrap(); - let breakpoint = dbio.get_breakpoint(1).unwrap(); - let final_state = dbio.final_state().unwrap(); - - assert_eq!(last_id, 101); - assert_eq!(first_id, Some(1)); - assert!(is_first_set); - assert_eq!(last_br_id, Some(1)); - assert_ne!(last_block.header.hash, genesis_block().header.hash); - assert_eq!( - prev_breakpoint.get_account_by_id(acc1()).balance - - final_state.get_account_by_id(acc1()).balance, - 101 - ); - assert_eq!( - final_state.get_account_by_id(acc2()).balance - - prev_breakpoint.get_account_by_id(acc2()).balance, - 101 - ); - assert_eq!( - breakpoint.get_account_by_id(acc1()).balance - - final_state.get_account_by_id(acc1()).balance, - 1 - ); - assert_eq!( - final_state.get_account_by_id(acc2()).balance - - breakpoint.get_account_by_id(acc2()).balance, - 1 - ); - } - - #[test] - fn simple_maps() { - let temp_dir = tempdir().unwrap(); - let temdir_path = temp_dir.path(); - - let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state()).unwrap(); - - let from = acc1(); - let to = acc2(); - let sign_key = acc1_sign_key(); - - let transfer_tx = - common::test_utils::create_transaction_native_token_transfer(from, 0, to, 1, &sign_key); - let block = produce_dummy_block(1, None, vec![transfer_tx]); - - let control_hash1 = block.header.hash; - - dbio.put_block(&block, [1; 32]).unwrap(); - - let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); - let last_block = dbio.get_block(last_id).unwrap().unwrap(); - - let prev_hash = last_block.header.hash; - let transfer_tx = - common::test_utils::create_transaction_native_token_transfer(from, 1, to, 1, &sign_key); - let block = produce_dummy_block(2, Some(prev_hash), vec![transfer_tx]); - - let control_hash2 = block.header.hash; - - dbio.put_block(&block, [2; 32]).unwrap(); - - let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); - let last_block = dbio.get_block(last_id).unwrap().unwrap(); - - let prev_hash = last_block.header.hash; - let transfer_tx = - common::test_utils::create_transaction_native_token_transfer(from, 2, to, 1, &sign_key); - - let control_tx_hash1 = transfer_tx.hash(); - - let block = produce_dummy_block(3, Some(prev_hash), vec![transfer_tx]); - dbio.put_block(&block, [3; 32]).unwrap(); - - let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); - let last_block = dbio.get_block(last_id).unwrap().unwrap(); - - let prev_hash = last_block.header.hash; - let transfer_tx = - common::test_utils::create_transaction_native_token_transfer(from, 3, to, 1, &sign_key); - - let control_tx_hash2 = transfer_tx.hash(); - - let block = produce_dummy_block(4, Some(prev_hash), vec![transfer_tx]); - dbio.put_block(&block, [4; 32]).unwrap(); - - let control_block_id1 = dbio.get_block_id_by_hash(control_hash1.0).unwrap().unwrap(); - let control_block_id2 = dbio.get_block_id_by_hash(control_hash2.0).unwrap().unwrap(); - let control_block_id3 = dbio - .get_block_id_by_tx_hash(control_tx_hash1.0) - .unwrap() - .unwrap(); - let control_block_id4 = dbio - .get_block_id_by_tx_hash(control_tx_hash2.0) - .unwrap() - .unwrap(); - - assert_eq!(control_block_id1, 1); - assert_eq!(control_block_id2, 2); - assert_eq!(control_block_id3, 3); - assert_eq!(control_block_id4, 4); - } - - #[test] - fn block_batch() { - let temp_dir = tempdir().unwrap(); - let temdir_path = temp_dir.path(); - - let mut block_res = vec![]; - - let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state()).unwrap(); - - let from = acc1(); - let to = acc2(); - let sign_key = acc1_sign_key(); - - let transfer_tx = - common::test_utils::create_transaction_native_token_transfer(from, 0, to, 1, &sign_key); - let block = produce_dummy_block(1, None, vec![transfer_tx]); - - block_res.push(block.clone()); - dbio.put_block(&block, [1; 32]).unwrap(); - - let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); - let last_block = dbio.get_block(last_id).unwrap().unwrap(); - - let prev_hash = last_block.header.hash; - let transfer_tx = - common::test_utils::create_transaction_native_token_transfer(from, 1, to, 1, &sign_key); - let block = produce_dummy_block(2, Some(prev_hash), vec![transfer_tx]); - - block_res.push(block.clone()); - dbio.put_block(&block, [2; 32]).unwrap(); - - let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); - let last_block = dbio.get_block(last_id).unwrap().unwrap(); - - let prev_hash = last_block.header.hash; - let transfer_tx = - common::test_utils::create_transaction_native_token_transfer(from, 2, to, 1, &sign_key); - - let block = produce_dummy_block(3, Some(prev_hash), vec![transfer_tx]); - block_res.push(block.clone()); - dbio.put_block(&block, [3; 32]).unwrap(); - - let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); - let last_block = dbio.get_block(last_id).unwrap().unwrap(); - - let prev_hash = last_block.header.hash; - let transfer_tx = - common::test_utils::create_transaction_native_token_transfer(from, 3, to, 1, &sign_key); - - let block = produce_dummy_block(4, Some(prev_hash), vec![transfer_tx]); - block_res.push(block.clone()); - dbio.put_block(&block, [4; 32]).unwrap(); - - let block_hashes_mem: Vec<[u8; 32]> = - block_res.into_iter().map(|bl| bl.header.hash.0).collect(); - - // Get blocks before ID 5 (i.e., starting from 4 going backwards), limit 4 - // This should return blocks 4, 3, 2, 1 in descending order - let mut batch_res = dbio.get_block_batch(Some(5), 4).unwrap(); - batch_res.reverse(); // Reverse to match ascending order for comparison - - let block_hashes_db: Vec<[u8; 32]> = - batch_res.into_iter().map(|bl| bl.header.hash.0).collect(); - - assert_eq!(block_hashes_mem, block_hashes_db); - - let block_hashes_mem_limited = &block_hashes_mem[1..]; - - // Get blocks before ID 5, limit 3 - // This should return blocks 4, 3, 2 in descending order - let mut batch_res_limited = dbio.get_block_batch(Some(5), 3).unwrap(); - batch_res_limited.reverse(); // Reverse to match ascending order for comparison - - let block_hashes_db_limited: Vec<[u8; 32]> = batch_res_limited - .into_iter() - .map(|bl| bl.header.hash.0) - .collect(); - - assert_eq!(block_hashes_mem_limited, block_hashes_db_limited.as_slice()); - - let block_batch_seq = dbio.get_block_batch_seq(1..=5).unwrap(); - let block_batch_ids = block_batch_seq - .into_iter() - .map(|block| block.header.block_id) - .collect::>(); - - assert_eq!(block_batch_ids, vec![1, 2, 3, 4]); - } - - #[test] - fn account_map() { - let temp_dir = tempdir().unwrap(); - let temdir_path = temp_dir.path(); - - let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state()).unwrap(); - - let from = acc1(); - let to = acc2(); - let sign_key = acc1_sign_key(); - - let mut tx_hash_res = vec![]; - - let transfer_tx1 = - common::test_utils::create_transaction_native_token_transfer(from, 0, to, 1, &sign_key); - let transfer_tx2 = - common::test_utils::create_transaction_native_token_transfer(from, 1, to, 1, &sign_key); - tx_hash_res.push(transfer_tx1.hash().0); - tx_hash_res.push(transfer_tx2.hash().0); - - let block = produce_dummy_block(1, None, vec![transfer_tx1, transfer_tx2]); - - dbio.put_block(&block, [1; 32]).unwrap(); - - let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); - let last_block = dbio.get_block(last_id).unwrap().unwrap(); - - let prev_hash = last_block.header.hash; - let transfer_tx1 = - common::test_utils::create_transaction_native_token_transfer(from, 2, to, 1, &sign_key); - let transfer_tx2 = - common::test_utils::create_transaction_native_token_transfer(from, 3, to, 1, &sign_key); - tx_hash_res.push(transfer_tx1.hash().0); - tx_hash_res.push(transfer_tx2.hash().0); - - let block = produce_dummy_block(2, Some(prev_hash), vec![transfer_tx1, transfer_tx2]); - - dbio.put_block(&block, [2; 32]).unwrap(); - - let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); - let last_block = dbio.get_block(last_id).unwrap().unwrap(); - - let prev_hash = last_block.header.hash; - let transfer_tx1 = - common::test_utils::create_transaction_native_token_transfer(from, 4, to, 1, &sign_key); - let transfer_tx2 = - common::test_utils::create_transaction_native_token_transfer(from, 5, to, 1, &sign_key); - tx_hash_res.push(transfer_tx1.hash().0); - tx_hash_res.push(transfer_tx2.hash().0); - - let block = produce_dummy_block(3, Some(prev_hash), vec![transfer_tx1, transfer_tx2]); - - dbio.put_block(&block, [3; 32]).unwrap(); - - let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); - let last_block = dbio.get_block(last_id).unwrap().unwrap(); - - let prev_hash = last_block.header.hash; - let transfer_tx = - common::test_utils::create_transaction_native_token_transfer(from, 6, to, 1, &sign_key); - tx_hash_res.push(transfer_tx.hash().0); - - let block = produce_dummy_block(4, Some(prev_hash), vec![transfer_tx]); - - dbio.put_block(&block, [4; 32]).unwrap(); - - let acc1_tx = dbio.get_acc_transactions(*acc1().value(), 0, 7).unwrap(); - let acc1_tx_hashes: Vec<[u8; 32]> = acc1_tx.into_iter().map(|tx| tx.hash().0).collect(); - - assert_eq!(acc1_tx_hashes, tx_hash_res); - - let acc1_tx_limited = dbio.get_acc_transactions(*acc1().value(), 1, 4).unwrap(); - let acc1_tx_limited_hashes: Vec<[u8; 32]> = - acc1_tx_limited.into_iter().map(|tx| tx.hash().0).collect(); - - assert_eq!(acc1_tx_limited_hashes.as_slice(), &tx_hash_res[1..5]); - } -} +mod tests; diff --git a/lez/storage/src/indexer/read_once.rs b/lez/storage/src/indexer/read_once.rs index 6e79adc4..3fbf6026 100644 --- a/lez/storage/src/indexer/read_once.rs +++ b/lez/storage/src/indexer/read_once.rs @@ -3,8 +3,8 @@ use crate::{ DBIO as _, cells::shared_cells::{BlockCell, FirstBlockCell, FirstBlockSetCell, LastBlockCell}, indexer::indexer_cells::{ - AccNumTxCell, BlockHashToBlockIdMapCell, BreakpointCellOwned, LastBreakpointIdCell, - LastObservedL1LibHeaderCell, TxHashToBlockIdMapCell, ZoneSdkIndexerCursorCellOwned, + AccNumTxCell, BlockHashToBlockIdMapCell, BreakpointCellOwned, LastObservedL1LibHeaderCell, + StallReasonCellOwned, TipSlotCell, TxHashToBlockIdMapCell, ZoneSdkIndexerCursorCellOwned, }, }; @@ -31,8 +31,8 @@ impl RocksDBIO { Ok(self.get_opt::(())?.is_some()) } - pub fn get_meta_last_breakpoint_id(&self) -> DbResult> { - self.get_opt::(()) + pub fn get_meta_tip_slot_in_db(&self) -> DbResult> { + self.get_opt::(()) .map(|opt| opt.map(|cell| cell.0)) } @@ -49,6 +49,11 @@ impl RocksDBIO { self.get::(br_id).map(|cell| cell.0) } + pub fn get_breakpoint_opt(&self, br_id: u64) -> DbResult> { + self.get_opt::(br_id) + .map(|opt| opt.map(|cell| cell.0)) + } + // Mappings pub fn get_block_id_by_hash(&self, hash: [u8; 32]) -> DbResult> { @@ -73,4 +78,8 @@ impl RocksDBIO { .get_opt::(())? .map(|cell| cell.0)) } + + pub fn get_stall_reason_bytes(&self) -> DbResult>> { + Ok(self.get_opt::(())?.map(|cell| cell.0)) + } } diff --git a/lez/storage/src/indexer/tests.rs b/lez/storage/src/indexer/tests.rs new file mode 100644 index 00000000..d87aaf1c --- /dev/null +++ b/lez/storage/src/indexer/tests.rs @@ -0,0 +1,492 @@ +use common::test_utils::produce_dummy_block; +use lee::{Account, AccountId, PublicKey}; +use tempfile::tempdir; + +use super::*; + +fn genesis_block() -> Block { + produce_dummy_block(1, None, vec![]) +} + +fn acc1_sign_key() -> lee::PrivateKey { + lee::PrivateKey::try_new([1; 32]).unwrap() +} + +fn acc2_sign_key() -> lee::PrivateKey { + lee::PrivateKey::try_new([2; 32]).unwrap() +} + +fn acc1() -> AccountId { + AccountId::from(&PublicKey::new_from_private_key(&acc1_sign_key())) +} + +fn acc2() -> AccountId { + AccountId::from(&PublicKey::new_from_private_key(&acc2_sign_key())) +} + +fn initial_state() -> lee::V03State { + let mut public_accounts = [(acc1(), 10000), (acc2(), 20000)] + .into_iter() + .map(|(id, balance)| { + ( + id, + Account { + program_owner: programs::authenticated_transfer().id(), + balance, + ..Account::default() + }, + ) + }) + .collect::>(); + for clock_id in system_accounts::clock_account_ids() { + public_accounts.push((clock_id, system_accounts::clock_account())); + } + + lee::V03State::new() + .with_public_accounts(public_accounts) + .with_programs([programs::authenticated_transfer(), programs::clock()]) +} + +#[test] +fn start_db() { + let initial_state = initial_state(); + let temp_dir = tempdir().unwrap(); + let temdir_path = temp_dir.path(); + + let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state).unwrap(); + + let last_id = dbio.get_meta_last_block_id_in_db().unwrap(); + let first_id = dbio.get_meta_first_block_id_in_db().unwrap(); + let is_first_set = dbio.get_meta_is_first_block_set().unwrap(); + let last_observed_l1_header = dbio.get_meta_last_observed_l1_lib_header_in_db().unwrap(); + let last_block = dbio.get_block(1).unwrap(); + let breakpoint = dbio.get_breakpoint(0).unwrap(); + let final_state = dbio.final_state().unwrap(); + + assert_eq!(last_id, None); + assert_eq!(first_id, None); + assert_eq!(last_observed_l1_header, None); + assert!(!is_first_set); + assert!(last_block.is_none()); + assert_eq!( + breakpoint.get_account_by_id(acc1()), + final_state.get_account_by_id(acc1()) + ); + assert_eq!( + breakpoint.get_account_by_id(acc2()), + final_state.get_account_by_id(acc2()) + ); +} + +#[test] +fn one_block_insertion() { + let initial_state = initial_state(); + let temp_dir = tempdir().unwrap(); + let temdir_path = temp_dir.path(); + + let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state).unwrap(); + + let genesis_block = genesis_block(); + dbio.put_block(&genesis_block, [0; 32], 0, &initial_state) + .unwrap(); + + let prev_hash = genesis_block.header.hash; + let from = acc1(); + let to = acc2(); + let sign_key = acc1_sign_key(); + + let transfer_tx = + common::test_utils::create_transaction_native_token_transfer(from, 0, to, 1, &sign_key); + let block = produce_dummy_block(2, Some(prev_hash), vec![transfer_tx]); + + dbio.put_block(&block, [1; 32], 0, &initial_state).unwrap(); + + let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); + let first_id = dbio.get_meta_first_block_id_in_db().unwrap(); + let last_observed_l1_header = dbio + .get_meta_last_observed_l1_lib_header_in_db() + .unwrap() + .unwrap(); + let is_first_set = dbio.get_meta_is_first_block_set().unwrap(); + let last_block = dbio.get_block(last_id).unwrap().unwrap(); + let breakpoint = dbio.get_breakpoint(0).unwrap(); + let final_state = dbio.final_state().unwrap(); + + assert_eq!(last_id, 2); + assert_eq!(first_id, Some(1)); + assert_eq!(last_observed_l1_header, [1; 32]); + assert!(is_first_set); + assert_eq!(last_block.header.hash, block.header.hash); + assert_eq!( + breakpoint.get_account_by_id(acc1()).balance + - final_state.get_account_by_id(acc1()).balance, + 1 + ); + assert_eq!( + final_state.get_account_by_id(acc2()).balance + - breakpoint.get_account_by_id(acc2()).balance, + 1 + ); +} + +#[test] +fn put_block_records_tip_inscription_slot() { + let initial_state = initial_state(); + let temp_dir = tempdir().unwrap(); + let dbio = RocksDBIO::open_or_create(temp_dir.path(), &initial_state).unwrap(); + + assert_eq!(dbio.get_meta_tip_slot_in_db().unwrap(), None); + + let genesis_block = genesis_block(); + dbio.put_block(&genesis_block, [0; 32], 1_000, &initial_state) + .unwrap(); + assert_eq!(dbio.get_meta_tip_slot_in_db().unwrap(), Some(1_000)); + + let block = produce_dummy_block(2, Some(genesis_block.header.hash), vec![]); + dbio.put_block(&block, [1; 32], 1_005, &initial_state) + .unwrap(); + assert_eq!(dbio.get_meta_tip_slot_in_db().unwrap(), Some(1_005)); + + // Re-inserting a block at/below the tip must not move the tip slot. + dbio.put_block(&genesis_block, [0; 32], 1_010, &initial_state) + .unwrap(); + assert_eq!(dbio.get_meta_tip_slot_in_db().unwrap(), Some(1_005)); +} + +#[test] +fn put_block_stores_breakpoint_in_same_batch() { + let initial_state = initial_state(); + let temp_dir = tempdir().unwrap(); + let dbio = RocksDBIO::open_or_create(temp_dir.path(), &initial_state).unwrap(); + + let from = acc1(); + let to = acc2(); + let sign_key = acc1_sign_key(); + + // Chain blocks 1..=BREAKPOINT_INTERVAL. The snapshot is scheduled internally + // by put_block at the boundary block; every call passes the same recognizable + // marker state (the initial one), proving it's stored verbatim rather than + // recomputed. + for i in 1..=BREAKPOINT_INTERVAL { + let prev_hash = dbio.get_meta_last_block_id_in_db().unwrap().map(|last_id| { + let last_block = dbio.get_block(last_id).unwrap().unwrap(); + last_block.header.hash + }); + + let transfer_tx = common::test_utils::create_transaction_native_token_transfer( + from, + (i - 1).into(), + to, + 1, + &sign_key, + ); + let block = produce_dummy_block(i.into(), prev_hash, vec![transfer_tx]); + + dbio.put_block(&block, [i; 32], 0, &initial_state).unwrap(); + } + + let bp1 = dbio.get_breakpoint(1).unwrap(); + assert_eq!(bp1.get_account_by_id(acc1()).balance, 10000); + assert_eq!(bp1.get_account_by_id(acc2()).balance, 20000); + // Only the boundary block schedules a write: breakpoint 0 must be the only other one. + assert_eq!( + dbio.get_breakpoint(0) + .unwrap() + .get_account_by_id(acc1()) + .balance, + 10000 + ); +} + +#[test] +fn state_replay_falls_back_over_missing_breakpoints() { + let initial_state = initial_state(); + let temp_dir = tempdir().unwrap(); + let dbio = RocksDBIO::open_or_create(temp_dir.path(), &initial_state).unwrap(); + + let from = acc1(); + let to = acc2(); + let sign_key = acc1_sign_key(); + + for i in 1..=u64::from(BREAKPOINT_INTERVAL) + 1 { + let prev_hash = dbio.get_meta_last_block_id_in_db().unwrap().map(|last_id| { + let last_block = dbio.get_block(last_id).unwrap().unwrap(); + last_block.header.hash + }); + let transfer_tx = common::test_utils::create_transaction_native_token_transfer( + from, + (i - 1).into(), + to, + 1, + &sign_key, + ); + let block = produce_dummy_block(i, prev_hash, vec![transfer_tx]); + dbio.put_block(&block, [0; 32], 0, &initial_state).unwrap(); + } + + // Simulate a store whose boundary snapshot was lost (#605). + dbio.delete_breakpoint(1).unwrap(); + assert!(dbio.get_breakpoint_opt(1).unwrap().is_none()); + let final_state = dbio.final_state().unwrap(); + assert_eq!( + 10000 - final_state.get_account_by_id(acc1()).balance, + u128::from(BREAKPOINT_INTERVAL) + 1 + ); + assert_eq!( + final_state.get_account_by_id(acc2()).balance - 20000, + u128::from(BREAKPOINT_INTERVAL) + 1 + ); +} + +#[test] +fn simple_maps() { + let initial_state = initial_state(); + let temp_dir = tempdir().unwrap(); + let temdir_path = temp_dir.path(); + + let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state).unwrap(); + + let from = acc1(); + let to = acc2(); + let sign_key = acc1_sign_key(); + + let transfer_tx = + common::test_utils::create_transaction_native_token_transfer(from, 0, to, 1, &sign_key); + let block = produce_dummy_block(1, None, vec![transfer_tx]); + + let control_hash1 = block.header.hash; + + dbio.put_block(&block, [1; 32], 0, &initial_state).unwrap(); + + let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); + let last_block = dbio.get_block(last_id).unwrap().unwrap(); + + let prev_hash = last_block.header.hash; + let transfer_tx = + common::test_utils::create_transaction_native_token_transfer(from, 1, to, 1, &sign_key); + let block = produce_dummy_block(2, Some(prev_hash), vec![transfer_tx]); + + let control_hash2 = block.header.hash; + + dbio.put_block(&block, [2; 32], 0, &initial_state).unwrap(); + + let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); + let last_block = dbio.get_block(last_id).unwrap().unwrap(); + + let prev_hash = last_block.header.hash; + let transfer_tx = + common::test_utils::create_transaction_native_token_transfer(from, 2, to, 1, &sign_key); + + let control_tx_hash1 = transfer_tx.hash(); + + let block = produce_dummy_block(3, Some(prev_hash), vec![transfer_tx]); + dbio.put_block(&block, [3; 32], 0, &initial_state).unwrap(); + + let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); + let last_block = dbio.get_block(last_id).unwrap().unwrap(); + + let prev_hash = last_block.header.hash; + let transfer_tx = + common::test_utils::create_transaction_native_token_transfer(from, 3, to, 1, &sign_key); + + let control_tx_hash2 = transfer_tx.hash(); + + let block = produce_dummy_block(4, Some(prev_hash), vec![transfer_tx]); + dbio.put_block(&block, [4; 32], 0, &initial_state).unwrap(); + + let control_block_id1 = dbio.get_block_id_by_hash(control_hash1.0).unwrap().unwrap(); + let control_block_id2 = dbio.get_block_id_by_hash(control_hash2.0).unwrap().unwrap(); + let control_block_id3 = dbio + .get_block_id_by_tx_hash(control_tx_hash1.0) + .unwrap() + .unwrap(); + let control_block_id4 = dbio + .get_block_id_by_tx_hash(control_tx_hash2.0) + .unwrap() + .unwrap(); + + assert_eq!(control_block_id1, 1); + assert_eq!(control_block_id2, 2); + assert_eq!(control_block_id3, 3); + assert_eq!(control_block_id4, 4); +} + +#[test] +fn block_batch() { + let initial_state = initial_state(); + let temp_dir = tempdir().unwrap(); + let temdir_path = temp_dir.path(); + + let mut block_res = vec![]; + + let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state).unwrap(); + + let from = acc1(); + let to = acc2(); + let sign_key = acc1_sign_key(); + + let transfer_tx = + common::test_utils::create_transaction_native_token_transfer(from, 0, to, 1, &sign_key); + let block = produce_dummy_block(1, None, vec![transfer_tx]); + + block_res.push(block.clone()); + dbio.put_block(&block, [1; 32], 0, &initial_state).unwrap(); + + let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); + let last_block = dbio.get_block(last_id).unwrap().unwrap(); + + let prev_hash = last_block.header.hash; + let transfer_tx = + common::test_utils::create_transaction_native_token_transfer(from, 1, to, 1, &sign_key); + let block = produce_dummy_block(2, Some(prev_hash), vec![transfer_tx]); + + block_res.push(block.clone()); + dbio.put_block(&block, [2; 32], 0, &initial_state).unwrap(); + + let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); + let last_block = dbio.get_block(last_id).unwrap().unwrap(); + + let prev_hash = last_block.header.hash; + let transfer_tx = + common::test_utils::create_transaction_native_token_transfer(from, 2, to, 1, &sign_key); + + let block = produce_dummy_block(3, Some(prev_hash), vec![transfer_tx]); + block_res.push(block.clone()); + dbio.put_block(&block, [3; 32], 0, &initial_state).unwrap(); + + let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); + let last_block = dbio.get_block(last_id).unwrap().unwrap(); + + let prev_hash = last_block.header.hash; + let transfer_tx = + common::test_utils::create_transaction_native_token_transfer(from, 3, to, 1, &sign_key); + + let block = produce_dummy_block(4, Some(prev_hash), vec![transfer_tx]); + block_res.push(block.clone()); + dbio.put_block(&block, [4; 32], 0, &initial_state).unwrap(); + + let block_hashes_mem: Vec<[u8; 32]> = + block_res.into_iter().map(|bl| bl.header.hash.0).collect(); + + // Get blocks before ID 5 (i.e., starting from 4 going backwards), limit 4 + // This should return blocks 4, 3, 2, 1 in descending order + let mut batch_res = dbio.get_block_batch(Some(5), 4).unwrap(); + batch_res.reverse(); // Reverse to match ascending order for comparison + + let block_hashes_db: Vec<[u8; 32]> = batch_res.into_iter().map(|bl| bl.header.hash.0).collect(); + + assert_eq!(block_hashes_mem, block_hashes_db); + + let block_hashes_mem_limited = &block_hashes_mem[1..]; + + // Get blocks before ID 5, limit 3 + // This should return blocks 4, 3, 2 in descending order + let mut batch_res_limited = dbio.get_block_batch(Some(5), 3).unwrap(); + batch_res_limited.reverse(); // Reverse to match ascending order for comparison + + let block_hashes_db_limited: Vec<[u8; 32]> = batch_res_limited + .into_iter() + .map(|bl| bl.header.hash.0) + .collect(); + + assert_eq!(block_hashes_mem_limited, block_hashes_db_limited.as_slice()); + + let block_batch_seq = dbio.get_block_batch_seq(1..=5).unwrap(); + let block_batch_ids = block_batch_seq + .into_iter() + .map(|block| block.header.block_id) + .collect::>(); + + assert_eq!(block_batch_ids, vec![1, 2, 3, 4]); +} + +#[test] +fn account_map() { + let initial_state = initial_state(); + let temp_dir = tempdir().unwrap(); + let temdir_path = temp_dir.path(); + + let dbio = RocksDBIO::open_or_create(temdir_path, &initial_state).unwrap(); + + let from = acc1(); + let to = acc2(); + let sign_key = acc1_sign_key(); + + let mut tx_hash_res = vec![]; + + let transfer_tx1 = + common::test_utils::create_transaction_native_token_transfer(from, 0, to, 1, &sign_key); + let transfer_tx2 = + common::test_utils::create_transaction_native_token_transfer(from, 1, to, 1, &sign_key); + tx_hash_res.push(transfer_tx1.hash().0); + tx_hash_res.push(transfer_tx2.hash().0); + + let block = produce_dummy_block(1, None, vec![transfer_tx1, transfer_tx2]); + + dbio.put_block(&block, [1; 32], 0, &initial_state).unwrap(); + + let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); + let last_block = dbio.get_block(last_id).unwrap().unwrap(); + + let prev_hash = last_block.header.hash; + let transfer_tx1 = + common::test_utils::create_transaction_native_token_transfer(from, 2, to, 1, &sign_key); + let transfer_tx2 = + common::test_utils::create_transaction_native_token_transfer(from, 3, to, 1, &sign_key); + tx_hash_res.push(transfer_tx1.hash().0); + tx_hash_res.push(transfer_tx2.hash().0); + + let block = produce_dummy_block(2, Some(prev_hash), vec![transfer_tx1, transfer_tx2]); + + dbio.put_block(&block, [2; 32], 0, &initial_state).unwrap(); + + let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); + let last_block = dbio.get_block(last_id).unwrap().unwrap(); + + let prev_hash = last_block.header.hash; + let transfer_tx1 = + common::test_utils::create_transaction_native_token_transfer(from, 4, to, 1, &sign_key); + let transfer_tx2 = + common::test_utils::create_transaction_native_token_transfer(from, 5, to, 1, &sign_key); + tx_hash_res.push(transfer_tx1.hash().0); + tx_hash_res.push(transfer_tx2.hash().0); + + let block = produce_dummy_block(3, Some(prev_hash), vec![transfer_tx1, transfer_tx2]); + + dbio.put_block(&block, [3; 32], 0, &initial_state).unwrap(); + + let last_id = dbio.get_meta_last_block_id_in_db().unwrap().unwrap(); + let last_block = dbio.get_block(last_id).unwrap().unwrap(); + + let prev_hash = last_block.header.hash; + let transfer_tx = + common::test_utils::create_transaction_native_token_transfer(from, 6, to, 1, &sign_key); + tx_hash_res.push(transfer_tx.hash().0); + + let block = produce_dummy_block(4, Some(prev_hash), vec![transfer_tx]); + + dbio.put_block(&block, [4; 32], 0, &initial_state).unwrap(); + + let acc1_tx = dbio.get_acc_transactions(*acc1().value(), 0, 7).unwrap(); + let acc1_tx_hashes: Vec<[u8; 32]> = acc1_tx.into_iter().map(|tx| tx.hash().0).collect(); + + assert_eq!(acc1_tx_hashes, tx_hash_res); + + let acc1_tx_limited = dbio.get_acc_transactions(*acc1().value(), 1, 4).unwrap(); + let acc1_tx_limited_hashes: Vec<[u8; 32]> = + acc1_tx_limited.into_iter().map(|tx| tx.hash().0).collect(); + + assert_eq!(acc1_tx_limited_hashes.as_slice(), &tx_hash_res[1..5]); +} + +#[test] +fn reopen_preserves_seeded_breakpoint() { + let initial_state = initial_state(); + let temp_dir = tempdir().unwrap(); + { + let dbio = RocksDBIO::open_or_create(temp_dir.path(), &initial_state).unwrap(); + assert!(dbio.get_breakpoint_opt(0).unwrap().is_some()); + } // drop releases the RocksDB lock + let dbio = RocksDBIO::open_or_create(temp_dir.path(), &initial_state).unwrap(); + assert!(dbio.get_breakpoint_opt(0).unwrap().is_some()); +} diff --git a/lez/storage/src/indexer/write_atomic.rs b/lez/storage/src/indexer/write_atomic.rs index a88e46ef..729bcc02 100644 --- a/lez/storage/src/indexer/write_atomic.rs +++ b/lez/storage/src/indexer/write_atomic.rs @@ -2,13 +2,13 @@ use std::collections::HashMap; use rocksdb::WriteBatch; -use super::{BREAKPOINT_INTERVAL, Block, DbError, DbResult, RocksDBIO}; +use super::{BREAKPOINT_INTERVAL, Block, DbError, DbResult, RocksDBIO, V03State}; use crate::{ DBIO as _, cells::shared_cells::{FirstBlockCell, FirstBlockSetCell, LastBlockCell}, indexer::indexer_cells::{ - AccNumTxCell, BlockHashToBlockIdMapCell, LastBreakpointIdCell, LastObservedL1LibHeaderCell, - TxHashToBlockIdMapCell, + AccNumTxCell, BlockHashToBlockIdMapCell, BreakpointCellRef, LastObservedL1LibHeaderCell, + TipSlotCell, TxHashToBlockIdMapCell, }, }; @@ -52,44 +52,8 @@ impl RocksDBIO { acc_id: [u8; 32], tx_hashes: &[[u8; 32]], ) -> DbResult<()> { - let acc_num_tx = self.get_acc_meta_num_tx(acc_id)?.unwrap_or(0); - let cf_att = self.account_id_to_tx_hash_column(); let mut write_batch = WriteBatch::new(); - - for (tx_id, tx_hash) in tx_hashes.iter().enumerate() { - let put_id = acc_num_tx - .checked_add(tx_id.try_into().expect("Must fit into u64")) - .expect("Tx count should be lesser that u64::MAX"); - - let mut prefix = borsh::to_vec(&acc_id).map_err(|berr| { - DbError::borsh_cast_message(berr, Some("Failed to serialize account id".to_owned())) - })?; - let suffix = borsh::to_vec(&put_id).map_err(|berr| { - DbError::borsh_cast_message(berr, Some("Failed to serialize tx id".to_owned())) - })?; - - prefix.extend_from_slice(&suffix); - - write_batch.put_cf( - &cf_att, - prefix, - borsh::to_vec(tx_hash).map_err(|berr| { - DbError::borsh_cast_message( - berr, - Some("Failed to serialize tx hash".to_owned()), - ) - })?, - ); - } - - self.update_acc_meta_batch( - acc_id, - acc_num_tx - .checked_add(tx_hashes.len().try_into().expect("Must fit into u64")) - .expect("Tx count should be lesser that u64::MAX"), - &mut write_batch, - )?; - + self.put_account_transactions_dependant(acc_id, tx_hashes, &mut write_batch)?; self.db.write(write_batch).map_err(|rerr| { DbError::rocksdb_cast_message(rerr, Some("Failed to write batch".to_owned())) }) @@ -167,21 +131,28 @@ impl RocksDBIO { self.put_batch(&LastObservedL1LibHeaderCell(l1_lib_header), (), write_batch) } - pub fn put_meta_last_breakpoint_id_batch( + pub fn put_meta_tip_slot_in_db_batch( &self, - br_id: u64, + l1_slot: u64, write_batch: &mut WriteBatch, ) -> DbResult<()> { - self.put_batch(&LastBreakpointIdCell(br_id), (), write_batch) + self.put_batch(&TipSlotCell(l1_slot), (), write_batch) } pub fn put_meta_is_first_block_set_batch(&self, write_batch: &mut WriteBatch) -> DbResult<()> { self.put_batch(&FirstBlockSetCell(true), (), write_batch) } - // Block - - pub fn put_block(&self, block: &Block, l1_lib_header: [u8; 32]) -> DbResult<()> { + /// Put a block atomically (via [`WriteBatch`]) along with its L1 header, `Slot`, + /// and (at interval-boundary blocks) a snapshot of `post_state`, the block's + /// post-application state. + pub fn put_block( + &self, + block: &Block, + l1_lib_header: [u8; 32], + l1_slot: u64, + post_state: &V03State, + ) -> DbResult<()> { let cf_block = self.block_column(); let last_curr_block = self.get_meta_last_block_id_in_db()?.unwrap_or(0); let mut write_batch = WriteBatch::default(); @@ -199,6 +170,7 @@ impl RocksDBIO { if block.header.block_id > last_curr_block { self.put_meta_last_block_in_db_batch(block.header.block_id, &mut write_batch)?; self.put_meta_last_observed_l1_lib_header_in_db_batch(l1_lib_header, &mut write_batch)?; + self.put_meta_tip_slot_in_db_batch(l1_slot, &mut write_batch)?; } if last_curr_block == 0 { self.put_meta_first_block_in_db_batch(block, &mut write_batch)?; @@ -244,18 +216,23 @@ impl RocksDBIO { self.put_account_transactions_dependant(acc_id, &tx_hashes, &mut write_batch)?; } - self.db.write(write_batch).map_err(|rerr| { - DbError::rocksdb_cast_message(rerr, Some("Failed to write batch".to_owned())) - })?; - if block .header .block_id .is_multiple_of(BREAKPOINT_INTERVAL.into()) { - self.put_next_breakpoint()?; + let br_id = block + .header + .block_id + .checked_div(BREAKPOINT_INTERVAL.into()) + .expect("Breakpoint interval is not zero"); + self.put_batch(&BreakpointCellRef(post_state), br_id, &mut write_batch)?; } + self.db.write(write_batch).map_err(|rerr| { + DbError::rocksdb_cast_message(rerr, Some("Failed to write batch".to_owned())) + })?; + Ok(()) } } diff --git a/lez/storage/src/indexer/write_non_atomic.rs b/lez/storage/src/indexer/write_non_atomic.rs index 7ddab1dd..a2c178ec 100644 --- a/lez/storage/src/indexer/write_non_atomic.rs +++ b/lez/storage/src/indexer/write_non_atomic.rs @@ -1,9 +1,11 @@ -use super::{BREAKPOINT_INTERVAL, DbError, DbResult, RocksDBIO, V03State}; +use super::{DbResult, RocksDBIO, V03State}; +#[cfg(test)] +use crate::error::DbError; use crate::{ DBIO as _, cells::shared_cells::{FirstBlockSetCell, LastBlockCell}, indexer::indexer_cells::{ - BreakpointCellRef, LastBreakpointIdCell, LastObservedL1LibHeaderCell, + BreakpointCellRef, LastObservedL1LibHeaderCell, StallReasonCellRef, ZoneSdkIndexerCursorCellRef, }, }; @@ -23,10 +25,6 @@ impl RocksDBIO { self.put(&LastObservedL1LibHeaderCell(l1_lib_header), ()) } - pub fn put_meta_last_breakpoint_id(&self, br_id: u64) -> DbResult<()> { - self.put(&LastBreakpointIdCell(br_id), ()) - } - pub fn put_meta_is_first_block_set(&self) -> DbResult<()> { self.put(&FirstBlockSetCell(true), ()) } @@ -35,32 +33,25 @@ impl RocksDBIO { self.put(&ZoneSdkIndexerCursorCellRef(bytes), ()) } + pub fn put_stall_reason_bytes(&self, bytes: &[u8]) -> DbResult<()> { + self.put(&StallReasonCellRef(bytes), ()) + } + // State pub fn put_breakpoint(&self, br_id: u64, breakpoint: &V03State) -> DbResult<()> { self.put(&BreakpointCellRef(breakpoint), br_id) } - pub fn put_next_breakpoint(&self) -> DbResult<()> { - let last_block = self.get_meta_last_block_id_in_db()?.unwrap_or(0); - let next_breakpoint_id = self - .get_meta_last_breakpoint_id()? - .unwrap_or(0) - .checked_add(1) - .expect("Breakpoint Id will be lesser than u64::MAX"); - let block_to_break_id = next_breakpoint_id - .checked_mul(u64::from(BREAKPOINT_INTERVAL)) - .expect("Reached maximum breakpoint id"); - - if block_to_break_id <= last_block { - let next_breakpoint = self.calculate_state_for_id(block_to_break_id)?; - - self.put_breakpoint(next_breakpoint_id, &next_breakpoint)?; - self.put_meta_last_breakpoint_id(next_breakpoint_id) - } else { - Err(DbError::db_interaction_error( - "Breakpoint not yet achieved".to_owned(), - )) - } + /// Deletes a breakpoint snapshot. Test-only fault injection for simulating + /// stores whose boundary snapshot was lost. + #[cfg(test)] + pub(crate) fn delete_breakpoint(&self, br_id: u64) -> DbResult<()> { + let key = borsh::to_vec(&br_id).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize breakpoint id".to_owned())) + })?; + self.db + .delete_cf(&self.breakpoint_column(), key) + .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None)) } } diff --git a/lez/storage/src/lib.rs b/lez/storage/src/lib.rs index 2edb0ee3..8ce473f2 100644 --- a/lez/storage/src/lib.rs +++ b/lez/storage/src/lib.rs @@ -1,7 +1,7 @@ use rocksdb::{DBWithThreadMode, MultiThreaded, WriteBatch}; use crate::{ - cells::{SimpleReadableCell, SimpleWritableCell}, + cells::{SimpleReadableCell, SimpleStorableCell, SimpleWritableCell}, error::DbError, }; @@ -66,4 +66,29 @@ pub trait DBIO { ) -> DbResult<()> { cell.put_batch(self.db(), params, write_batch) } + + /// Stage a cell deletion into `write_batch`, the counterpart of + /// [`Self::put_batch`]. Deleting an absent key is a no-op (rocksdb + /// semantics). + fn del_batch( + &self, + params: T::KeyParams, + write_batch: &mut WriteBatch, + ) -> DbResult<()> { + write_batch.delete_cf(&T::column_ref(self.db()), T::key_constructor(params)?); + Ok(()) + } + + /// Delete a cell. Deleting an absent key is a no-op (rocksdb semantics). + fn del(&self, params: T::KeyParams) -> DbResult<()> { + let cf_ref = T::column_ref(self.db()); + self.db() + .delete_cf(&cf_ref, T::key_constructor(params)?) + .map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some(format!("Failed to delete {:?}", T::CELL_NAME)), + ) + }) + } } diff --git a/lez/storage/src/sequencer/mod.rs b/lez/storage/src/sequencer/mod.rs index 44068517..ba6deaf9 100644 --- a/lez/storage/src/sequencer/mod.rs +++ b/lez/storage/src/sequencer/mod.rs @@ -1,26 +1,33 @@ -use std::{path::Path, sync::Arc}; +use std::{ + collections::BTreeMap, + path::Path, + sync::{Arc, Mutex, MutexGuard, PoisonError}, +}; +use borsh::{BorshDeserialize, BorshSerialize}; use common::{ HashType, block::{BedrockStatus, Block, BlockMeta}, }; use lee::V03State; use rocksdb::{ - BoundColumnFamily, ColumnFamilyDescriptor, DBWithThreadMode, MultiThreaded, Options, WriteBatch, + BoundColumnFamily, ColumnFamilyDescriptor, DBWithThreadMode, IteratorMode, MultiThreaded, + Options, WriteBatch, }; use crate::{ CF_BLOCK_NAME, CF_META_NAME, DB_META_FIRST_BLOCK_IN_DB_KEY, DBIO, DbResult, - cells::{ - SimpleStorableCell, - shared_cells::{BlockCell, FirstBlockCell, FirstBlockSetCell, LastBlockCell}, - }, + cells::shared_cells::{BlockCell, FirstBlockCell, FirstBlockSetCell, LastBlockCell}, error::DbError, sequencer::sequencer_cells::{ - LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell, LatestBlockMetaCellOwned, - LatestBlockMetaCellRef, PendingDepositEventRecord, PendingDepositEventsCellOwned, - PendingDepositEventsCellRef, UnseenWithdrawCountCell, WithdrawalReconciliationKey, - ZoneSdkCheckpointCellOwned, ZoneSdkCheckpointCellRef, + FinalBlockMetaCellOwned, FinalBlockMetaCellRef, FinalLeeStateCellOwned, + FinalLeeStateCellRef, LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell, + LatestBlockMetaCellOwned, LatestBlockMetaCellRef, PeerFloorCellOwned, PeerFloorCellRef, + PeerZoneKey, PendingCrossZoneDispatchRecord, PendingCrossZoneDispatchesCellOwned, + PendingCrossZoneDispatchesCellRef, PendingDepositEventRecord, + PendingDepositEventsCellOwned, PendingDepositEventsCellRef, UnseenWithdrawCountCell, + WithdrawalReconciliationKey, ZoneAnchorCell, ZoneAnchorRecord, ZoneSdkCheckpointCellOwned, + ZoneSdkCheckpointCellRef, }, }; @@ -32,20 +39,172 @@ pub const DB_META_LAST_FINALIZED_BLOCK_ID: &str = "last_finalized_block_id"; pub const DB_META_LATEST_BLOCK_META_KEY: &str = "latest_block_meta"; /// Key base for storing the zone-sdk sequencer checkpoint (opaque bytes). pub const DB_META_ZONE_SDK_CHECKPOINT_KEY: &str = "zone_sdk_checkpoint"; +/// Key base for storing the last channel block read back and verified from +/// Bedrock (its L1 slot + `id`/`hash`) — the anchor for the startup +/// consistency check and the resume point for reconstruction. +pub const DB_META_ZONE_CURSOR_KEY: &str = "zone_cursor"; /// Key base for storing queued deposit events that were not yet /// fulfilled on L2. pub const DB_META_PENDING_DEPOSIT_EVENTS_KEY: &str = "pending_deposit_events"; +/// Key base for storing a cross-zone watcher's delivery floor on one peer +/// channel (opaque bytes). Keyed per peer zone. +pub const DB_META_CROSS_ZONE_PEER_FLOOR_KEY: &str = "cross_zone_peer_floor"; +/// Key base for storing cross-zone deliveries the watcher has recorded but +/// which are not yet known to be irreversibly delivered. +pub const DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY: &str = "pending_cross_zone_dispatches"; + /// Key base for counting unseen L2 withdraw intents. pub const DB_META_UNSEEN_WITHDRAW_COUNT_KEY: &str = "unseen_withdraw_count"; +/// How many cross-zone deliveries may be pending at once. +/// +/// The whole list is a single value, read on every block and rewritten on every +/// change, and what fills it is chosen by peer zones rather than by us. Refusing +/// to record past this bound turns "a peer decides how large our store gets" +/// into "a peer's messages wait", since a watcher that cannot record holds its +/// delivery floor and reads the slot again later. +pub const MAX_PENDING_CROSS_ZONE_DISPATCHES: usize = 4096; + /// Key base for storing the LEE state. pub const DB_LEE_STATE_KEY: &str = "lee_state"; +/// Key base for storing the LEE state at the last L1-finalized block. +pub const DB_FINAL_LEE_STATE_KEY: &str = "final_lee_state"; +/// Key base for storing `(id, hash)` of the last L1-finalized block. +pub const DB_FINAL_BLOCK_META_KEY: &str = "final_block_meta"; /// Name of state column family. pub const CF_LEE_STATE_NAME: &str = "cf_lee_state"; +/// A single key/value entry from a column family, used inside [`DbDump`]. +#[derive(BorshSerialize, BorshDeserialize)] +pub struct DbDumpEntry { + pub cf_name: String, + pub key: Vec, + pub value: Vec, +} + +/// Schema-agnostic single-blob snapshot of a store: every key/value pair across all column +/// families. Lets a prebuilt store ship as one committed file instead of a rocksdb directory. +#[derive(BorshSerialize, BorshDeserialize)] +pub struct DbDump { + pub entries: Vec, +} + +impl DbDump { + /// Serialize the dump to a zstd-compressed borsh blob. + pub fn to_bytes(&self) -> DbResult> { + /// zstd compression level for [`DbDump::to_bytes`]. Level 19 keeps the committed fixture + /// small without a meaningful decompression cost. + const DUMP_ZSTD_LEVEL: i32 = 19; + + let borsh = borsh::to_vec(self).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize DbDump".to_owned())) + })?; + zstd::encode_all(borsh.as_slice(), DUMP_ZSTD_LEVEL).map_err(|err| { + DbError::compression_error(err, Some("Failed to compress DbDump".to_owned())) + }) + } + + /// Deserialize a dump produced by [`Self::to_bytes`]. + pub fn from_bytes(bytes: &[u8]) -> DbResult { + let borsh = zstd::decode_all(bytes).map_err(|err| { + DbError::db_interaction_error(format!("Failed to decompress DbDump: {err}")) + })?; + borsh::from_slice(&borsh).map_err(|err| { + DbError::compression_error(err, Some("Failed to deserialize DbDump".to_owned())) + }) + } +} + +/// Everything one sequencer event writes, staged into a single [`WriteBatch`] +/// by [`RocksDBIO::store_update`]. +/// +/// The point of the struct is the `checkpoint`: it is the zone-sdk's resume +/// cursor, so it must land in the *same* write as the effects it covers. +/// Persisted ahead of them, a crash in between resumes the stream past blocks +/// that never reached the store — a gap the node cannot backfill. +pub struct StoreUpdate<'update> { + /// Serialized zone-sdk checkpoint for this event. + pub checkpoint: Option<&'update [u8]>, + + /// `(block, finalized)` payloads to write. + pub blocks: &'update [(&'update Block, bool)], + + /// Head tip to pin the stored chain to; `None` only for an empty chain. + pub head_tip: Option<&'update BlockMeta>, + /// State after the last applied block. + pub head_state: &'update V03State, + + /// `(state, meta)` of the final tier, when it advanced. + pub final_snapshot: Option<(&'update V03State, &'update BlockMeta)>, + /// Highest block id this event made irreversible: stored blocks at or below + /// it become [`BedrockStatus::Finalized`]. + pub finalized_up_to: Option, + + /// Deposit events observed on L1, recorded unless already pending. + pub new_deposit_events: &'update [PendingDepositEventRecord], + /// Deposit op ids whose mint finalized: their pending records are dropped. + pub remove_deposit_records: &'update [HashType], + /// Message keys whose delivery finalized: their pending records are dropped. + pub remove_dispatch_records: &'update [[u8; 32]], + /// L1 withdraw events to reconcile against the local unseen counters. + pub consumed_withdrawals: &'update [WithdrawalReconciliationKey], + /// L2 withdraw intents this update raises, awaiting their L1 event. + pub new_withdraw_intents: &'update [WithdrawalReconciliationKey], + + /// Advance the channel-read anchor. + pub zone_anchor: Option<&'update ZoneAnchorRecord>, +} + +impl<'update> StoreUpdate<'update> { + /// An update that writes nothing but the caller's head `state`, to be + /// filled in with `..StoreUpdate::new(state)`. + #[must_use] + pub const fn new(head_state: &'update V03State) -> Self { + Self { + checkpoint: None, + blocks: &[], + head_tip: None, + head_state, + final_snapshot: None, + finalized_up_to: None, + new_deposit_events: &[], + remove_deposit_records: &[], + remove_dispatch_records: &[], + consumed_withdrawals: &[], + new_withdraw_intents: &[], + zone_anchor: None, + } + } +} + +/// What [`RocksDBIO::store_update`] observed while staging, for the caller to +/// act on *after* the write committed. +#[derive(Debug, Default)] +pub struct StoreUpdateOutcome { + /// How many deposit events were newly recorded; the rest were already + /// pending, and so already owed. + pub accepted_deposits: usize, + /// Withdraw events with no matching local unseen counter, one entry per + /// unmatched occurrence. + pub unmatched_withdrawals: Vec, +} + +#[expect( + clippy::partial_pub_fields, + reason = "the pending-record lock is an implementation detail and must stay private" +)] pub struct RocksDBIO { pub db: DBWithThreadMode, + /// Serializes the read-modify-write cycles over the pending cross-zone + /// dispatch list. + /// + /// The list is a single value holding the whole `Vec`, and three tasks + /// rewrite it: the watcher recording a delivery, the production loop + /// counting a failed attempt, and the publisher's drive task settling + /// finalized deliveries. Rocksdb makes the write atomic, not the cycle, so + /// without this the writer that read first silently drops the others. + pending_records: Mutex<()>, } impl DBIO for RocksDBIO { @@ -55,6 +214,18 @@ impl DBIO for RocksDBIO { } impl RocksDBIO { + /// Held across a pending-record read-modify-write. See + /// [`RocksDBIO::pending_records`]. + /// + /// A poisoned lock is recovered rather than propagated: the records behind + /// it are a plain `Vec` that a panicking writer cannot leave half-written, + /// since the write is a single rocksdb put. + fn lock_pending_records(&self) -> MutexGuard<'_, ()> { + self.pending_records + .lock() + .unwrap_or_else(PoisonError::into_inner) + } + pub fn open(path: &Path) -> DbResult { let db_opts = Options::default(); Self::open_inner(path, &db_opts) @@ -84,6 +255,71 @@ impl RocksDBIO { Ok(dbio) } + /// Dump every key/value pair across all column families into a [`DbDump`]. Column families are + /// discovered from disk, so new ones are captured without a hardcoded list. + pub fn dump_all(&self) -> DbResult { + let cf_names = + DBWithThreadMode::::list_cf(&Options::default(), self.db.path()) + .map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to list column families for dump".to_owned()), + ) + })?; + + let mut entries = Vec::new(); + for cf_name in cf_names { + let cf = self.db.cf_handle(&cf_name).ok_or_else(|| { + DbError::db_interaction_error(format!( + "Column family {cf_name:?} listed on disk but not opened; add it to `open_inner`" + )) + })?; + for item in self.db.iterator_cf(&cf, IteratorMode::Start) { + let (key, value) = item.map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some(format!( + "Failed to iterate column family {cf_name:?} for dump" + )), + ) + })?; + entries.push(DbDumpEntry { + cf_name: cf_name.clone(), + key: key.into_vec(), + value: value.into_vec(), + }); + } + } + Ok(DbDump { entries }) + } + + /// Create a fresh rocksdb at `path` populated from a [`DbDump`]. + pub fn restore_from_dump(path: &Path, dump: &DbDump) -> DbResult { + let mut db_opts = Options::default(); + db_opts.create_missing_column_families(true); + db_opts.create_if_missing(true); + let dbio = Self::open_inner(path, &db_opts)?; + + let mut batch = WriteBatch::default(); + for entry in &dump.entries { + let cf = dbio.db.cf_handle(&entry.cf_name).ok_or_else(|| { + DbError::db_interaction_error(format!( + "Unknown column family {:?} in dump", + entry.cf_name + )) + })?; + batch.put_cf(&cf, &entry.key, &entry.value); + } + dbio.db.write(batch).map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to write dump restore batch".to_owned()), + ) + })?; + + Ok(dbio) + } + fn open_inner(path: &Path, db_opts: &Options) -> DbResult { let mut cf_opts = Options::default(); cf_opts.set_max_write_buffer_number(16); @@ -103,7 +339,10 @@ impl RocksDBIO { additional_info: Some("Failed to open or create DB".to_owned()), })?; - let dbio = Self { db }; + let dbio = Self { + db, + pending_records: Mutex::new(()), + }; Ok(dbio) } @@ -232,8 +471,9 @@ impl RocksDBIO { self.put_batch(&LatestBlockMetaCellRef(block_meta), (), batch) } - pub fn latest_block_meta(&self) -> DbResult { - self.get::(()).map(|val| val.0) + pub fn latest_block_meta(&self) -> DbResult> { + self.get_opt::(()) + .map(|val| val.map(|cell| cell.0)) } pub fn get_zone_sdk_checkpoint_bytes(&self) -> DbResult>> { @@ -246,16 +486,25 @@ impl RocksDBIO { self.put(&ZoneSdkCheckpointCellRef(bytes), ()) } + /// Remove the persisted zone-sdk checkpoint so the next startup is treated as a fresh start. + pub fn delete_zone_sdk_checkpoint_bytes(&self) -> DbResult<()> { + self.del::(()) + } + + pub fn get_zone_anchor(&self) -> DbResult> { + Ok(self.get_opt::(())?.map(|cell| cell.0)) + } + + pub fn put_zone_anchor(&self, anchor: &ZoneAnchorRecord) -> DbResult<()> { + self.put(&ZoneAnchorCell(*anchor), ()) + } + pub fn get_pending_deposit_events(&self) -> DbResult> { Ok(self .get_opt::(())? .map_or_else(Vec::new, |cell| cell.0)) } - fn put_pending_deposit_events(&self, records: &[PendingDepositEventRecord]) -> DbResult<()> { - self.put(&PendingDepositEventsCellRef(records), ()) - } - fn put_pending_deposit_events_batch( &self, records: &[PendingDepositEventRecord], @@ -264,128 +513,430 @@ impl RocksDBIO { self.put_batch(&PendingDepositEventsCellRef(records), (), batch) } + /// Records a single deposit event, returning whether it was new. + /// One-shot form of [`RocksDBIO::store_update`]'s `new_deposit_events`. pub fn add_pending_deposit_event(&self, event: PendingDepositEventRecord) -> DbResult { - let mut records = self.get_pending_deposit_events()?; - if records - .iter() - .any(|record| record.deposit_op_id == event.deposit_op_id) - { + let mut batch = WriteBatch::default(); + let accepted = self.stage_pending_deposit_events(&[event], &[], &mut batch)?; + // A re-delivery of an already-pending deposit — the steady state — stages + // nothing; skip the write rather than sync an empty batch. + if batch.is_empty() { return Ok(false); } - records.push(event); - self.put_pending_deposit_events(&records)?; - Ok(true) + self.db.write(batch).map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to add pending deposit event".to_owned()), + ) + })?; + Ok(accepted > 0) } - fn mark_pending_deposit_events_submitted( + /// Stages every mutation of the pending-deposit records into `batch`, + /// returning how many were newly appended. + /// + /// The records live in a *single* whole-vector cell, so each mutation kind + /// cannot re-read it from disk and stage its own `put`: a later read would + /// not see the earlier staged write and would silently drop it. Everything + /// is folded in memory here instead, and written exactly once. + fn stage_pending_deposit_events( &self, - deposit_op_ids: &[HashType], - submitted_block_id: u64, + new_events: &[PendingDepositEventRecord], + remove_op_ids: &[HashType], batch: &mut WriteBatch, ) -> DbResult { - let mut records = self.get_pending_deposit_events()?; - let mut updated: usize = 0; - - for record in records - .iter_mut() - .filter(|record| deposit_op_ids.contains(&record.deposit_op_id)) - { - record.submitted_in_block_id = Some(submitted_block_id); - updated = updated.saturating_add(1); + if new_events.is_empty() && remove_op_ids.is_empty() { + return Ok(0); } - if updated > 0 { + // A set for the membership test: a backfill can finalize many deposits + // against many still-pending records at once, and a linear `contains` + // per record would be quadratic. + let to_remove: std::collections::HashSet<&HashType> = remove_op_ids.iter().collect(); + + let mut records = self.get_pending_deposit_events()?; + let before_append = records.len(); + + // `accepted` is the count of records that will actually be drained on a + // future turn, so an op id both observed and finalized in this same + // event (backfill can deliver both at once) is neither appended nor + // counted — its mint already happened, and counting it would log an + // incoming mint that never comes. It is a length delta of the appends + // alone; the retain below only touches pre-existing records. + for event in new_events { + if to_remove.contains(&event.deposit_op_id) + || records + .iter() + .any(|record| record.deposit_op_id == event.deposit_op_id) + { + continue; + } + records.push(event.clone()); + } + let accepted = records.len().saturating_sub(before_append); + + let removed = if remove_op_ids.is_empty() { + 0 + } else { + let before_retain = records.len(); + records.retain(|record| !to_remove.contains(&record.deposit_op_id)); + before_retain.saturating_sub(records.len()) + }; + + // Guard on both counts: the common finalizing event appends nothing yet + // still mutates the cell, and a pure re-delivery mutates neither and + // must not rewrite it. + if accepted > 0 || removed > 0 { self.put_pending_deposit_events_batch(&records, batch)?; } - - Ok(updated) + Ok(accepted) } - pub fn remove_fulfilled_pending_deposit_events_up_to_block( + /// One cross-zone watcher's delivery floor on `peer_zone`'s channel, or + /// `None` before it has delivered anything from that peer. + pub fn get_cross_zone_peer_floor_bytes( &self, - finalized_block_id: u64, - ) -> DbResult { - let mut records = self.get_pending_deposit_events()?; - let before = records.len(); - records.retain(|record| { - record - .submitted_in_block_id - .is_none_or(|submitted_id| submitted_id > finalized_block_id) - }); + peer_zone: PeerZoneKey, + ) -> DbResult>> { + Ok(self + .get_opt::(peer_zone)? + .map(|cell| cell.0)) + } - let removed = before.saturating_sub(records.len()); - if removed > 0 { - self.put_pending_deposit_events(&records)?; + pub fn put_cross_zone_peer_floor_bytes( + &self, + peer_zone: PeerZoneKey, + bytes: &[u8], + ) -> DbResult<()> { + self.put(&PeerFloorCellRef(bytes), peer_zone) + } + + pub fn get_pending_cross_zone_dispatches( + &self, + ) -> DbResult> { + Ok(self + .get_opt::(())? + .map_or_else(Vec::new, |cell| cell.0)) + } + + fn put_pending_cross_zone_dispatches( + &self, + records: &[PendingCrossZoneDispatchRecord], + ) -> DbResult<()> { + self.put(&PendingCrossZoneDispatchesCellRef(records), ()) + } + + fn put_pending_cross_zone_dispatches_batch( + &self, + records: &[PendingCrossZoneDispatchRecord], + batch: &mut WriteBatch, + ) -> DbResult<()> { + self.put_batch(&PendingCrossZoneDispatchesCellRef(records), (), batch) + } + + /// Records every delivery one peer block carries, in a single write. + /// + /// Returns how many were new. Ones already recorded are skipped, so a slot + /// the watcher re-reads is not double-tracked. + /// + /// Batched rather than one call per delivery because the whole list is one + /// value: recording a block's messages one at a time rewrites the list once + /// per message, which is quadratic in a block that carries many. + /// + /// Fails without writing anything if the list would exceed + /// [`MAX_PENDING_CROSS_ZONE_DISPATCHES`]. The caller's floor then stays put + /// and the slot is read again later, which is the difference between + /// backpressure and an unbounded list a peer controls the size of. + pub fn add_pending_cross_zone_dispatches( + &self, + dispatches: Vec, + ) -> DbResult { + if dispatches.is_empty() { + return Ok(0); } + let _pending = self.lock_pending_records(); + let mut records = self.get_pending_cross_zone_dispatches()?; + let before = records.len(); + + for dispatch in dispatches { + if records + .iter() + .any(|record| record.message_key == dispatch.message_key) + { + continue; + } + records.push(dispatch); + } + + let accepted = records.len().saturating_sub(before); + if accepted == 0 { + return Ok(0); + } + if records.len() > MAX_PENDING_CROSS_ZONE_DISPATCHES { + return Err(DbError::db_interaction_error(format!( + "Refusing to hold more than {MAX_PENDING_CROSS_ZONE_DISPATCHES} pending cross-zone deliveries; {before} already pending" + ))); + } + + self.put_pending_cross_zone_dispatches(&records)?; + Ok(accepted) + } + + /// Counts a failed production attempt against a delivery, dropping its + /// record once it reaches `retire_at`. Returns whether it was dropped. + /// + /// Dropped rather than flagged: a retired record is one the drain will never + /// turn into a block transaction again, so nothing would ever remove it, and + /// a peer that can make deliveries fail could grow the list without bound. + /// The delivery is given up on either way; this way the cost is a log line + /// rather than a permanent entry. + /// + /// A delivery with no record is already retired as far as this is concerned: + /// there is nothing left to count against. + pub fn record_dispatch_failure(&self, message_key: [u8; 32], retire_at: u32) -> DbResult { + let _pending = self.lock_pending_records(); + let mut records = self.get_pending_cross_zone_dispatches()?; + let Some(position) = records + .iter() + .position(|record| record.message_key == message_key) + else { + return Ok(true); + }; + + let attempts = { + let record = &mut records[position]; + record.failed_attempts = record.failed_attempts.saturating_add(1); + record.failed_attempts + }; + let retired = attempts >= retire_at; + if retired { + records.remove(position); + } + self.put_pending_cross_zone_dispatches(&records)?; + Ok(retired) + } + + /// Drops the records of deliveries that are settled for good, outside any + /// store update. + /// + /// The settlement path in [`Self::store_update`] catches a delivery as its + /// block becomes irreversible. This catches the ones that path cannot: a + /// record re-added after its delivery had already settled, which the watcher + /// does whenever it re-reads a slot it has already consumed. Nothing would + /// ever put such a key in a block again, so without this it stays for ever. + pub fn drop_settled_cross_zone_dispatches(&self, message_keys: &[[u8; 32]]) -> DbResult { + if message_keys.is_empty() { + return Ok(0); + } + + let _pending = self.lock_pending_records(); + let to_remove: std::collections::HashSet<&[u8; 32]> = message_keys.iter().collect(); + let mut records = self.get_pending_cross_zone_dispatches()?; + let before = records.len(); + records.retain(|record| !to_remove.contains(&record.message_key)); + let removed = before.saturating_sub(records.len()); + + if removed > 0 { + self.put_pending_cross_zone_dispatches(&records)?; + } Ok(removed) } - fn increment_unseen_withdraw_count( + /// Drops the pending records of deliveries that just became irreversible, + /// staged into `batch` so they go with the update that made them so. + /// + /// Removal only, unlike [`Self::stage_pending_deposit_events`]: a delivery is + /// recorded by the watcher through + /// [`Self::add_pending_cross_zone_dispatch`], on its own task and outside + /// any store update, so nothing ever adds one here. + fn stage_removed_dispatches( &self, - withdrawal: WithdrawalReconciliationKey, + remove_keys: &[[u8; 32]], batch: &mut WriteBatch, - ) -> DbResult { - let current = self - .get_opt::(withdrawal)? - .map_or(0, |cell| cell.0); + ) -> DbResult { + if remove_keys.is_empty() { + return Ok(0); + } - let next = current.checked_add(1).ok_or_else(|| { - DbError::db_interaction_error("Unseen withdraw counter overflow".to_owned()) - })?; + let to_remove: std::collections::HashSet<&[u8; 32]> = remove_keys.iter().collect(); + let mut records = self.get_pending_cross_zone_dispatches()?; + let before = records.len(); + records.retain(|record| !to_remove.contains(&record.message_key)); + let removed = before.saturating_sub(records.len()); - self.put_batch(&UnseenWithdrawCountCell(next), withdrawal, batch)?; - - Ok(next) + if removed > 0 { + self.put_pending_cross_zone_dispatches_batch(&records, batch)?; + } + Ok(removed) } + /// Stages the unseen-withdraw decrements for one update into `batch`, + /// returning one entry per occurrence that matched no local counter. + /// + /// Occurrences are folded per key for the same reason as the deposit + /// records: should two withdrawals in one update share a reconciliation + /// key, a per-occurrence disk read would miss the staged decrement. + fn stage_consumed_withdrawals( + &self, + withdrawals: &[WithdrawalReconciliationKey], + batch: &mut WriteBatch, + ) -> DbResult> { + let mut unmatched = Vec::new(); + if withdrawals.is_empty() { + return Ok(unmatched); + } + + // A `Vec` rather than a map: the per-update count is tiny, and it keeps + // the staging order deterministic. + let mut occurrences: Vec<(WithdrawalReconciliationKey, u64)> = Vec::new(); + for withdrawal in withdrawals { + match occurrences.iter_mut().find(|(key, _)| key == withdrawal) { + Some((_, times)) => *times = times.saturating_add(1), + None => occurrences.push((*withdrawal, 1)), + } + } + + for (withdrawal, times) in occurrences { + let stored = self + .get_opt::(withdrawal)? + .map(|cell| cell.0); + + // A stored `count` satisfies `count + 1` occurrences: the last one + // consumes the key by deleting it. Matches the one-shot + // [`Self::consume_unseen_withdraw_count`]. + let matched = times.min(stored.map_or(0, |count| count.saturating_add(1))); + unmatched.extend(std::iter::repeat_n( + withdrawal, + usize::try_from(times.saturating_sub(matched)) + .expect("unmatched withdrawal count fits usize"), + )); + + match stored.and_then(|count| count.checked_sub(times)) { + Some(count) => { + self.put_batch(&UnseenWithdrawCountCell(count), withdrawal, batch)?; + } + // Only stage a delete for a key that was actually there, so a + // fully unmatched update leaves the batch empty. + None if stored.is_some() => { + self.del_batch::(withdrawal, batch)?; + } + None => {} + } + } + + Ok(unmatched) + } + + /// Collects the [`BedrockStatus::Finalized`] flip for every stored pending + /// block at or below `last_finalized` into `to_write`. + /// + /// Reads from disk, so blocks the caller is writing itself are already in + /// `to_write` and keep their own version — one `put` per block id, no + /// reliance on the order writes are staged in. + fn collect_finalized_up_to(&self, last_finalized: u64, to_write: &mut BTreeMap) { + let newly_finalized: Vec = self + .get_all_blocks() + .filter_map(Result::ok) + .filter(|block| { + matches!(block.bedrock_status, BedrockStatus::Pending) + && block.header.block_id <= last_finalized + }) + .collect(); + + for mut block in newly_finalized { + block.bedrock_status = BedrockStatus::Finalized; + to_write.entry(block.header.block_id).or_insert(block); + } + } + + /// Stages the unseen-withdraw increments for one update into `batch`. + /// + /// Occurrences are folded per key for the same reason as + /// [`Self::stage_consumed_withdrawals`]: should two intents in one update + /// share a reconciliation key, a per-occurrence disk read would miss the + /// staged increment and count the pair once. + fn stage_new_withdraw_intents( + &self, + withdrawals: &[WithdrawalReconciliationKey], + batch: &mut WriteBatch, + ) -> DbResult<()> { + if withdrawals.is_empty() { + return Ok(()); + } + + let mut occurrences: Vec<(WithdrawalReconciliationKey, u64)> = Vec::new(); + for withdrawal in withdrawals { + match occurrences.iter_mut().find(|(key, _)| key == withdrawal) { + Some((_, times)) => *times = times.saturating_add(1), + None => occurrences.push((*withdrawal, 1)), + } + } + + for (withdrawal, times) in occurrences { + let current = self + .get_opt::(withdrawal)? + .map_or(0, |cell| cell.0); + + let next = current.checked_add(times).ok_or_else(|| { + DbError::db_interaction_error("Unseen withdraw counter overflow".to_owned()) + })?; + + self.put_batch(&UnseenWithdrawCountCell(next), withdrawal, batch)?; + } + + Ok(()) + } + + /// Reconciles a single L1 withdraw event, returning whether it matched a + /// local intent. One-shot form of [`RocksDBIO::store_update`]'s + /// `consumed_withdrawals`. pub fn consume_unseen_withdraw_count( &self, withdrawal: WithdrawalReconciliationKey, ) -> DbResult { - let Some(current) = self - .get_opt::(withdrawal)? - .map(|cell| cell.0) - else { - return Ok(false); - }; - - if let Some(next) = current.checked_sub(1) { - self.put(&UnseenWithdrawCountCell(next), withdrawal)?; - } else { - let cf_meta = self.meta_column(); - let db_key = - ::key_constructor(withdrawal)?; - - self.db.delete_cf(&cf_meta, db_key).map_err(|rerr| { - DbError::rocksdb_cast_message( - rerr, - Some("Failed to delete unseen withdraw count".to_owned()), - ) - })?; - } - - Ok(true) + let mut batch = WriteBatch::default(); + let unmatched = self.stage_consumed_withdrawals(&[withdrawal], &mut batch)?; + self.db.write(batch).map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to consume unseen withdraw count".to_owned()), + ) + })?; + Ok(unmatched.is_empty()) } pub fn put_block(&self, block: &Block, first: bool, batch: &mut WriteBatch) -> DbResult<()> { - let cf_block = self.block_column(); - if !first { + // A produced block is the new head tip by construction: pin the + // tip meta and drop any stale higher blocks a preceding reorg left + // behind (mirrors `store_followed_blocks`). let last_curr_block = self.get_meta_last_block_in_db()?; - - if block.header.block_id > last_curr_block { - self.put_meta_last_block_in_db_batch(block.header.block_id, batch)?; - self.put_meta_latest_block_meta_batch( - &BlockMeta { - id: block.header.block_id, - hash: block.header.hash, - }, - batch, - )?; + for stale_id in block.header.block_id.saturating_add(1)..=last_curr_block { + self.delete_block_payload(stale_id, batch)?; } + self.put_meta_last_block_in_db_batch(block.header.block_id, batch)?; + self.put_meta_latest_block_meta_batch(&BlockMeta::from(block), batch)?; } + self.put_block_payload(block, batch) + } + + /// Stages deletion of a block payload into `batch`. + fn delete_block_payload(&self, block_id: u64, batch: &mut WriteBatch) -> DbResult<()> { + let cf_block = self.block_column(); + batch.delete_cf( + &cf_block, + borsh::to_vec(&block_id).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize block id".to_owned())) + })?, + ); + Ok(()) + } + + /// Stages just the block payload into `batch`, without touching the tip meta. + fn put_block_payload(&self, block: &Block, batch: &mut WriteBatch) -> DbResult<()> { + let cf_block = self.block_column(); batch.put_cf( &cf_block, borsh::to_vec(&block.header.block_id).map_err(|err| { @@ -403,6 +954,26 @@ impl RocksDBIO { .map(|opt| opt.map(|val| val.0)) } + /// `(state, meta)` at the last L1-finalized block; `None` until the first + /// finalization is observed. + pub fn get_final_snapshot(&self) -> DbResult> { + let Some(meta) = self.get_opt::(())? else { + return Ok(None); + }; + let state = self.get::(())?; + Ok(Some((state.0, meta.0))) + } + + fn put_final_snapshot_batch( + &self, + state: &V03State, + meta: &BlockMeta, + batch: &mut WriteBatch, + ) -> DbResult<()> { + self.put_batch(&FinalLeeStateCellRef(state), (), batch)?; + self.put_batch(&FinalBlockMetaCellRef(meta), (), batch) + } + pub fn get_lee_state(&self) -> DbResult { self.get::(()).map(|val| val.0) } @@ -431,27 +1002,49 @@ impl RocksDBIO { Ok(()) } - /// Mark every pending block with `block_id <= last_finalized` as finalized. - /// Idempotent — already-finalized blocks are skipped. + /// Mark every pending block with `block_id <= last_finalized` as finalized, + /// in one atomic write. Idempotent — already-finalized blocks are skipped. + /// One-shot form of [`RocksDBIO::store_update`]'s `finalized_up_to`. pub fn clean_pending_blocks_up_to(&self, last_finalized: u64) -> DbResult<()> { - let pending_ids: Vec = self + let mut to_write = BTreeMap::new(); + self.collect_finalized_up_to(last_finalized, &mut to_write); + + let mut batch = WriteBatch::default(); + for block in to_write.values() { + self.put_block_payload(block, &mut batch)?; + } + self.db.write(batch).map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to mark pending blocks finalized".to_owned()), + ) + }) + } + + pub fn mark_block_as_finalized(&self, block_id: u64) -> DbResult<()> { + self.set_block_bedrock_status(block_id, BedrockStatus::Finalized) + } + + /// Reset every stored block to [`BedrockStatus::Pending`], for snapshotting a store to replay + /// against a fresh Bedrock instance that knows none of the blocks yet. + pub fn reset_all_blocks_to_pending(&self) -> DbResult<()> { + let block_ids: Vec = self .get_all_blocks() .filter_map(Result::ok) - .filter(|b| matches!(b.bedrock_status, BedrockStatus::Pending)) - .map(|b| b.header.block_id) - .filter(|id| *id <= last_finalized) + .filter(|block| !matches!(block.bedrock_status, BedrockStatus::Pending)) + .map(|block| block.header.block_id) .collect(); - for id in pending_ids { - self.mark_block_as_finalized(id)?; + for id in block_ids { + self.set_block_bedrock_status(id, BedrockStatus::Pending)?; } Ok(()) } - pub fn mark_block_as_finalized(&self, block_id: u64) -> DbResult<()> { + fn set_block_bedrock_status(&self, block_id: u64, status: BedrockStatus) -> DbResult<()> { let mut block = self.get_block(block_id)?.ok_or_else(|| { DbError::db_interaction_error(format!("Block with id {block_id} not found")) })?; - block.bedrock_status = BedrockStatus::Finalized; + block.bedrock_status = status; let cf_block = self.block_column(); self.db @@ -473,13 +1066,154 @@ impl RocksDBIO { .map_err(|rerr| { DbError::rocksdb_cast_message( rerr, - Some(format!("Failed to mark block {block_id} as finalized")), + Some(format!("Failed to set block {block_id} bedrock status")), ) })?; Ok(()) } + /// One-block form of [`Self::store_update`], with the block as the head tip + /// and no final snapshot. Production always uses the batch form. + #[cfg(test)] + fn store_followed_block( + &self, + block: &Block, + state: &V03State, + finalized: bool, + ) -> DbResult<()> { + self.store_update(&StoreUpdate { + blocks: &[(block, finalized)], + head_tip: Some(&BlockMeta::from(block)), + ..StoreUpdate::new(state) + }) + .map(|_outcome| ()) + } + + /// Persists everything one sequencer event produced — checkpoint, blocks, + /// tip meta, head state, final snapshot, deposit and withdraw bookkeeping + /// and the channel anchor — in one atomic write. + /// + /// The tip meta is pinned to `head_tip`, and blocks stored above it (left + /// behind by a net-shortening reorg) are deleted in the same write, so + /// restart replay never walks past the tip. + /// + /// Per block: skips the payload write when the store already holds it (by + /// id and hash), unless `finalized` is set, which rewrites it with the + /// finalized status. + /// + /// The head state and tip meta are only rewritten when the chain actually + /// moved. A checkpoint alone (the common case — every follow event carries + /// one, most carry nothing else) must not drag a full state serialization + /// with it. + pub fn store_update(&self, update: &StoreUpdate<'_>) -> DbResult { + let _pending = self.lock_pending_records(); + let StoreUpdate { + checkpoint, + blocks, + head_tip, + head_state, + final_snapshot, + finalized_up_to, + new_deposit_events, + remove_deposit_records, + remove_dispatch_records, + consumed_withdrawals, + new_withdraw_intents, + zone_anchor, + } = *update; + + let last_block_in_db = self.get_meta_last_block_in_db()?; + let mut batch = WriteBatch::default(); + + if let Some(bytes) = checkpoint { + self.put_batch(&ZoneSdkCheckpointCellRef(bytes), (), &mut batch)?; + } + if let Some(anchor) = zone_anchor { + self.put_batch(&ZoneAnchorCell(*anchor), (), &mut batch)?; + } + + // Every block payload this update writes, keyed by id so a block that + // is both explicitly written and swept by `finalized_up_to` is written + // once, with the caller's version. + let mut to_write: BTreeMap = BTreeMap::new(); + + // Whether the stored chain moved, and with it the head state. A + // shrink-only update (orphans without adopted replacements) writes no + // payloads but still rewinds the tip, or the stored state tears + // against the stale disk head on the next produce. + let mut chain_changed = + final_snapshot.is_some() || head_tip.is_some_and(|tip| tip.id != last_block_in_db); + + for (block, finalized) in blocks { + let already_stored = self + .get_block(block.header.block_id)? + .filter(|stored| stored.header.hash == block.header.hash); + + let mut block_to_write = match already_stored { + Some(_) if !finalized => continue, + Some(stored) => stored, + None => (*block).clone(), + }; + if *finalized { + block_to_write.bedrock_status = BedrockStatus::Finalized; + } + to_write.insert(block_to_write.header.block_id, block_to_write); + chain_changed = true; + } + + if let Some(last_finalized) = finalized_up_to { + self.collect_finalized_up_to(last_finalized, &mut to_write); + } + for block in to_write.values() { + self.put_block_payload(block, &mut batch)?; + } + + let accepted_deposits = self.stage_pending_deposit_events( + new_deposit_events, + remove_deposit_records, + &mut batch, + )?; + self.stage_removed_dispatches(remove_dispatch_records, &mut batch)?; + let unmatched_withdrawals = + self.stage_consumed_withdrawals(consumed_withdrawals, &mut batch)?; + self.stage_new_withdraw_intents(new_withdraw_intents, &mut batch)?; + + // `head_tip` is `None` only for a chain holding no blocks at all, which + // the store — created with genesis — cannot represent. Nothing to pin. + if chain_changed && let Some(tip) = head_tip { + // `last_block_in_db` predates this batch, so on its own it misses + // payloads staged above the pinned tip — a finalized block landing + // below an adopted one rewinds the tip under blocks this same update + // wrote. Leaving one there fails the restart replay. The deletes are + // staged after the puts, so the batch order resolves the overlap. + let highest_staged = to_write.last_key_value().map_or(0, |(id, _)| *id); + for stale_id in tip.id.saturating_add(1)..=last_block_in_db.max(highest_staged) { + self.delete_block_payload(stale_id, &mut batch)?; + } + self.put_meta_last_block_in_db_batch(tip.id, &mut batch)?; + self.put_meta_latest_block_meta_batch(tip, &mut batch)?; + self.put_lee_state_in_db_batch(head_state, &mut batch)?; + if let Some((final_state, final_meta)) = final_snapshot { + self.put_final_snapshot_batch(final_state, final_meta, &mut batch)?; + } + } + + let outcome = StoreUpdateOutcome { + accepted_deposits, + unmatched_withdrawals, + }; + + if batch.is_empty() { + return Ok(outcome); + } + + self.db.write(batch).map_err(|rerr| { + DbError::rocksdb_cast_message(rerr, Some("Failed to write store update".to_owned())) + })?; + Ok(outcome) + } + pub fn get_all_blocks(&self) -> impl Iterator> { let cf_block = self.block_column(); self.db @@ -501,31 +1235,32 @@ impl RocksDBIO { }) } + /// Persists a block we produced, its withdraw intents, the resulting state + /// and the publish `checkpoint` in one atomic write. + /// + /// The produce path is [`Self::store_update`] with a single block that is + /// the new tip; the checkpoint belongs in the same write for the same + /// reason it does there — it carries the sdk's `pending_txs`, so a + /// checkpoint persisted without this block would restore a pending set + /// that no longer contains the inscription we just published, and the sdk + /// would never resubmit it. pub fn atomic_update( &self, block: &Block, - deposit_op_ids: &[HashType], - withdrawals: Vec, + withdrawals: &[WithdrawalReconciliationKey], state: &V03State, + checkpoint: Option<&[u8]>, ) -> DbResult<()> { - let block_id = block.header.block_id; - let mut batch = WriteBatch::default(); - - self.put_block(block, false, &mut batch)?; - - self.mark_pending_deposit_events_submitted(deposit_op_ids, block_id, &mut batch)?; - - for withdrawal in withdrawals { - self.increment_unseen_withdraw_count(withdrawal, &mut batch)?; - } - - self.put_lee_state_in_db_batch(state, &mut batch)?; - - self.db.write(batch).map_err(|rerr| { - DbError::rocksdb_cast_message( - rerr, - Some(format!("Failed to udpate db with block {block_id}")), - ) + self.store_update(&StoreUpdate { + checkpoint, + blocks: &[(block, false)], + head_tip: Some(&BlockMeta::from(block)), + new_withdraw_intents: withdrawals, + ..StoreUpdate::new(state) }) + .map(|_outcome| ()) } } + +#[cfg(test)] +mod tests; diff --git a/lez/storage/src/sequencer/sequencer_cells.rs b/lez/storage/src/sequencer/sequencer_cells.rs index 7672e271..521fff5e 100644 --- a/lez/storage/src/sequencer/sequencer_cells.rs +++ b/lez/storage/src/sequencer/sequencer_cells.rs @@ -7,9 +7,11 @@ use crate::{ cells::{SimpleReadableCell, SimpleStorableCell, SimpleWritableCell}, error::DbError, sequencer::{ - CF_LEE_STATE_NAME, DB_LEE_STATE_KEY, DB_META_LAST_FINALIZED_BLOCK_ID, - DB_META_LATEST_BLOCK_META_KEY, DB_META_PENDING_DEPOSIT_EVENTS_KEY, - DB_META_UNSEEN_WITHDRAW_COUNT_KEY, DB_META_ZONE_SDK_CHECKPOINT_KEY, + CF_LEE_STATE_NAME, DB_FINAL_BLOCK_META_KEY, DB_FINAL_LEE_STATE_KEY, DB_LEE_STATE_KEY, + DB_META_CROSS_ZONE_PEER_FLOOR_KEY, DB_META_LAST_FINALIZED_BLOCK_ID, + DB_META_LATEST_BLOCK_META_KEY, DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY, + DB_META_PENDING_DEPOSIT_EVENTS_KEY, DB_META_UNSEEN_WITHDRAW_COUNT_KEY, + DB_META_ZONE_CURSOR_KEY, DB_META_ZONE_SDK_CHECKPOINT_KEY, }, }; @@ -43,6 +45,72 @@ impl SimpleWritableCell for LEEStateCellRef<'_> { } } +/// State at the last L1-finalized block, written atomically with +/// [`FinalBlockMetaCellRef`]. +#[derive(BorshDeserialize)] +pub struct FinalLeeStateCellOwned(pub V03State); + +impl SimpleStorableCell for FinalLeeStateCellOwned { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_FINAL_LEE_STATE_KEY; + const CF_NAME: &'static str = CF_LEE_STATE_NAME; +} + +impl SimpleReadableCell for FinalLeeStateCellOwned {} + +#[derive(BorshSerialize)] +pub struct FinalLeeStateCellRef<'state>(pub &'state V03State); + +impl SimpleStorableCell for FinalLeeStateCellRef<'_> { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_FINAL_LEE_STATE_KEY; + const CF_NAME: &'static str = CF_LEE_STATE_NAME; +} + +impl SimpleWritableCell for FinalLeeStateCellRef<'_> { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize final state".to_owned())) + }) + } +} + +/// `(id, hash)` of the last L1-finalized block, paired with [`FinalLeeStateCellRef`]. +#[derive(BorshDeserialize)] +pub struct FinalBlockMetaCellOwned(pub BlockMeta); + +impl SimpleStorableCell for FinalBlockMetaCellOwned { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_FINAL_BLOCK_META_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for FinalBlockMetaCellOwned {} + +#[derive(BorshSerialize)] +pub struct FinalBlockMetaCellRef<'blockmeta>(pub &'blockmeta BlockMeta); + +impl SimpleStorableCell for FinalBlockMetaCellRef<'_> { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_FINAL_BLOCK_META_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleWritableCell for FinalBlockMetaCellRef<'_> { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize final block meta".to_owned()), + ) + }) + } +} + #[derive(Debug, BorshSerialize, BorshDeserialize)] pub struct LastFinalizedBlockIdCell(pub Option); @@ -132,14 +200,126 @@ impl SimpleWritableCell for ZoneSdkCheckpointCellRef<'_> { } } +/// The last channel block read back and verified from Bedrock. +/// +/// Holds its L1 inscription `slot` plus the block's `id`/`hash`, and serves as +/// both the anchor for the startup consistency check and the resume point for +/// reconstruction. `slot` is stored as a raw `u64` because the zone-sdk `Slot` +/// does not derive borsh; the caller converts to/from `Slot`. +#[derive(Debug, Clone, Copy, BorshSerialize, BorshDeserialize)] +pub struct ZoneAnchorRecord { + pub slot: u64, + pub block_id: u64, + pub hash: HashType, +} + +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct ZoneAnchorCell(pub ZoneAnchorRecord); + +impl SimpleStorableCell for ZoneAnchorCell { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_ZONE_CURSOR_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for ZoneAnchorCell {} + +impl SimpleWritableCell for ZoneAnchorCell { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize zone cursor".to_owned())) + }) + } +} + +/// An L1 deposit event observed but not yet seen finalized. +/// +/// Purely a liveness queue: whether to actually emit a mint is decided against +/// chain state (the deposit-receipt PDA), and the record is dropped once its +/// mint finalizes. #[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct PendingDepositEventRecord { pub deposit_op_id: HashType, pub source_tx_hash: HashType, pub amount: u64, pub metadata: Vec, - /// Set when block containing the deposit event is submitted, but not necessarily finalized. - pub submitted_in_block_id: Option, +} + +/// A cross-zone delivery the watcher has read off a peer block but which is not +/// yet known to be irreversibly delivered. +/// +/// The watcher's delivery floor is durable, so once it advances past a peer +/// block that block is never re-read. This record is what stands in its place: +/// block production drains it every turn, and it survives a restart. Mirrors +/// [`PendingDepositEventRecord`], which solves the same problem for deposits, +/// and like it carries no "submitted" mark: the record is dropped when the +/// delivery itself finalizes, and re-including one meanwhile is harmless +/// because the inbox no-ops a replay on chain. +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct PendingCrossZoneDispatchRecord { + /// Content-addressed replay key of the delivered message, and this record's + /// identity. + pub message_key: [u8; 32], + /// The borsh-encoded dispatch transaction, so production can re-feed it + /// without re-reading the peer channel. + pub transaction: Vec, + /// Production attempts that ended in an execution failure. + /// + /// A dispatch's payload and target accounts are chosen on the peer zone and + /// validated by nobody in between, so one can fail for good. A failure can + /// equally be a property of the moment, so a single one is not enough to + /// give up on a delivery. Once too many accumulate the record is dropped + /// rather than flagged, since a delivery nothing will retry is also a + /// delivery nothing would ever remove. + pub failed_attempts: u32, +} + +impl PendingCrossZoneDispatchRecord { + /// A delivery the watcher has just read: never attempted. + #[must_use] + pub const fn recorded(message_key: [u8; 32], transaction: Vec) -> Self { + Self { + message_key, + transaction, + failed_attempts: 0, + } + } +} + +#[derive(BorshDeserialize)] +pub struct PendingCrossZoneDispatchesCellOwned(pub Vec); + +impl SimpleStorableCell for PendingCrossZoneDispatchesCellOwned { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for PendingCrossZoneDispatchesCellOwned {} + +#[derive(BorshSerialize)] +pub struct PendingCrossZoneDispatchesCellRef<'records>( + pub &'records [PendingCrossZoneDispatchRecord], +); + +impl SimpleStorableCell for PendingCrossZoneDispatchesCellRef<'_> { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleWritableCell for PendingCrossZoneDispatchesCellRef<'_> { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize pending cross-zone dispatches cell".to_owned()), + ) + }) + } } #[derive(BorshDeserialize)] @@ -175,10 +355,78 @@ impl SimpleWritableCell for PendingDepositEventsCellRef<'_> { } } -#[derive(Debug, Clone, Copy)] +/// Identifies which peer channel a cross-zone watcher cursor belongs to. The +/// 32-byte peer channel id doubles as the peer's zone id. +pub type PeerZoneKey = [u8; 32]; + +/// Opaque bytes for one peer's cross-zone read cursor. As with the zone-sdk +/// checkpoint, the caller owns the encoding, since the cursor type derives serde +/// rather than borsh. +#[derive(BorshDeserialize)] +pub struct PeerFloorCellOwned(pub Vec); + +impl SimpleStorableCell for PeerFloorCellOwned { + type KeyParams = PeerZoneKey; + + const CELL_NAME: &'static str = DB_META_CROSS_ZONE_PEER_FLOOR_KEY; + const CF_NAME: &'static str = CF_META_NAME; + + /// Folds the peer zone into the key so each peer keeps its own cursor. + fn key_constructor(peer_zone: Self::KeyParams) -> DbResult> { + borsh::to_vec(&(Self::CELL_NAME, peer_zone)).map_err(|err| { + DbError::borsh_cast_message( + err, + Some(format!( + "Failed to serialize {:?} key params", + Self::CELL_NAME + )), + ) + }) + } +} + +impl SimpleReadableCell for PeerFloorCellOwned {} + +#[derive(BorshSerialize)] +pub struct PeerFloorCellRef<'bytes>(pub &'bytes [u8]); + +impl SimpleStorableCell for PeerFloorCellRef<'_> { + type KeyParams = PeerZoneKey; + + const CELL_NAME: &'static str = DB_META_CROSS_ZONE_PEER_FLOOR_KEY; + const CF_NAME: &'static str = CF_META_NAME; + + /// Folds the peer zone into the key so each peer keeps its own cursor. + fn key_constructor(peer_zone: Self::KeyParams) -> DbResult> { + borsh::to_vec(&(Self::CELL_NAME, peer_zone)).map_err(|err| { + DbError::borsh_cast_message( + err, + Some(format!( + "Failed to serialize {:?} key params", + Self::CELL_NAME + )), + ) + }) + } +} + +impl SimpleWritableCell for PeerFloorCellRef<'_> { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize cross-zone peer floor cell".to_owned()), + ) + }) + } +} + +/// Identity of one withdrawal, shared by the intent recorded when the +/// sequencer publishes it and the Bedrock Withdraw event that later reports +/// it: the id of the channel note the withdrawal releases. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct WithdrawalReconciliationKey { - pub amount: u64, - pub bedrock_account_pk: [u8; 32], + pub released_note_id: [u8; 32], } #[derive(Debug, BorshSerialize, BorshDeserialize)] @@ -191,12 +439,9 @@ impl SimpleStorableCell for UnseenWithdrawCountCell { const CF_NAME: &'static str = CF_META_NAME; fn key_constructor(key_params: Self::KeyParams) -> DbResult> { - let WithdrawalReconciliationKey { - amount, - bedrock_account_pk, - } = key_params; + let WithdrawalReconciliationKey { released_note_id } = key_params; - borsh::to_vec(&(Self::CELL_NAME, amount, bedrock_account_pk)).map_err(|err| { + borsh::to_vec(&(Self::CELL_NAME, released_note_id)).map_err(|err| { DbError::borsh_cast_message( err, Some(format!( diff --git a/lez/storage/src/sequencer/tests.rs b/lez/storage/src/sequencer/tests.rs new file mode 100644 index 00000000..4f71ab77 --- /dev/null +++ b/lez/storage/src/sequencer/tests.rs @@ -0,0 +1,692 @@ +use common::test_utils::produce_dummy_block; +use lee::{Account, AccountId}; +use tempfile::tempdir; + +use super::*; + +fn marker_id() -> AccountId { + AccountId::new([1; 32]) +} + +/// A state distinguishable by the marker account's balance, so tests can tell +/// which snapshot a write persisted. +/// +/// TODO: is this a bit too much of a hot-fix for test snapshot? +fn state_with_balance(balance: u128) -> V03State { + V03State::new().with_public_accounts([( + marker_id(), + Account { + balance, + ..Account::default() + }, + )]) +} + +fn dbio_with_genesis(path: &Path) -> (RocksDBIO, Block) { + let genesis = produce_dummy_block(1, None, vec![]); + let dbio = RocksDBIO::create(path, &genesis, &state_with_balance(100)).unwrap(); + (dbio, genesis) +} + +fn deposit_record(seed: u8) -> PendingDepositEventRecord { + PendingDepositEventRecord { + deposit_op_id: HashType([seed; 32]), + source_tx_hash: HashType([seed; 32]), + amount: u64::from(seed), + metadata: vec![seed], + } +} + +fn dispatch_record(seed: u8) -> PendingCrossZoneDispatchRecord { + PendingCrossZoneDispatchRecord::recorded([seed; 32], vec![seed; 4]) +} + +/// A distinct message key per index, for filling the pending list. +fn key_from_index(index: usize) -> [u8; 32] { + let mut key = [0_u8; 32]; + key[..8].copy_from_slice(&u64::try_from(index).expect("test index fits").to_le_bytes()); + key +} + +fn stored_balance(dbio: &RocksDBIO) -> u128 { + dbio.get_lee_state() + .unwrap() + .get_account_by_id(marker_id()) + .balance +} + +#[test] +fn store_followed_block_persists_new_block_and_state() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2, &state_with_balance(200), false) + .unwrap(); + + let stored = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert_eq!(stored.header.hash, block2.header.hash); + assert!(matches!(stored.bedrock_status, BedrockStatus::Pending)); + assert_eq!( + dbio.latest_block_meta().unwrap().expect("meta is set").id, + 2 + ); + assert_eq!(stored_balance(&dbio), 200); +} + +#[test] +fn store_followed_block_finalized_marks_block() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2, &state_with_balance(200), true) + .unwrap(); + + let stored = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert!(matches!(stored.bedrock_status, BedrockStatus::Finalized)); +} + +#[test] +fn store_followed_block_redelivery_is_a_noop_and_keeps_finalized() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2, &state_with_balance(200), true) + .unwrap(); + dbio.store_followed_block(&block2, &state_with_balance(300), false) + .unwrap(); + + let stored = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert!( + matches!(stored.bedrock_status, BedrockStatus::Finalized), + "re-delivery must not demote a finalized block" + ); + assert_eq!( + stored_balance(&dbio), + 200, + "re-delivery must not overwrite the persisted state" + ); +} + +#[test] +fn store_followed_blocks_batch_lands_meta_and_state_on_last_block() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + // Block 2 is already stored (own production); one update then finalizes it + // and adopts block 3. + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2, &state_with_balance(200), false) + .unwrap(); + + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + let head_tip = BlockMeta { + id: 3, + hash: block3.header.hash, + }; + dbio.store_update(&StoreUpdate { + blocks: &[(&block2, true), (&block3, false)], + head_tip: Some(&head_tip), + ..StoreUpdate::new(&state_with_balance(300)) + }) + .unwrap(); + + let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert!(matches!(stored2.bedrock_status, BedrockStatus::Finalized)); + let stored3 = dbio.get_block(3).unwrap().expect("block 3 is stored"); + assert!(matches!(stored3.bedrock_status, BedrockStatus::Pending)); + + // Meta and state land together on the last block of the batch. + let meta = dbio.latest_block_meta().unwrap().expect("meta is set"); + assert_eq!(meta.id, 3); + assert_eq!(meta.hash, block3.header.hash); + assert_eq!(stored_balance(&dbio), 300); +} + +#[test] +fn final_snapshot_round_trips_and_is_absent_on_fresh_store() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + // Fresh store: no finalization observed yet. + assert!(dbio.get_final_snapshot().unwrap().is_none()); + + // A follow update that finalizes block 2 lands the snapshot in the same batch. + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + let final_meta = BlockMeta { + id: 2, + hash: block2.header.hash, + }; + dbio.store_update(&StoreUpdate { + blocks: &[(&block2, true)], + head_tip: Some(&final_meta), + final_snapshot: Some((&state_with_balance(200), &final_meta)), + ..StoreUpdate::new(&state_with_balance(300)) + }) + .unwrap(); + + let (final_state, meta) = dbio + .get_final_snapshot() + .unwrap() + .expect("final snapshot is stored"); + assert_eq!(meta.id, 2); + assert_eq!(meta.hash, block2.header.hash); + assert_eq!(final_state.get_account_by_id(marker_id()).balance, 200); + // The head state is stored independently of the final snapshot. + assert_eq!(stored_balance(&dbio), 300); +} + +#[test] +fn store_followed_block_overwrites_competing_block_at_same_id() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2a, &state_with_balance(200), false) + .unwrap(); + + // A reorg replaces block 2: the competing block wins the slot. + let block2b = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + dbio.store_followed_block(&block2b, &state_with_balance(300), false) + .unwrap(); + + let stored = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert_eq!(stored.header.hash, block2b.header.hash); + assert!(matches!(stored.bedrock_status, BedrockStatus::Pending)); + assert_eq!(stored_balance(&dbio), 300); + + // The tip meta must follow the reorg winner, or a restart seeds the chain + // from the orphaned block's hash. + let meta = dbio.latest_block_meta().unwrap().expect("meta is set"); + assert_eq!(meta.id, 2); + assert_eq!(meta.hash, block2b.header.hash); +} + +#[test] +fn net_shortening_reorg_drops_stale_blocks() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2a, &state_with_balance(200), false) + .unwrap(); + let block3 = produce_dummy_block(3, Some(block2a.header.hash), vec![]); + dbio.store_followed_block(&block3, &state_with_balance(300), false) + .unwrap(); + + // A shorter competing chain wins: block 2 is replaced, block 3 gets no + // replacement. + let block2b = produce_dummy_block(2, Some(HashType([9; 32])), vec![]); + let head_tip = BlockMeta { + id: 2, + hash: block2b.header.hash, + }; + dbio.store_update(&StoreUpdate { + blocks: &[(&block2b, false)], + head_tip: Some(&head_tip), + ..StoreUpdate::new(&state_with_balance(400)) + }) + .unwrap(); + + let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert_eq!(stored2.header.hash, block2b.header.hash); + assert!( + dbio.get_block(3).unwrap().is_none(), + "stale block above the new head must be deleted, or restart replay panics on its broken link" + ); + let meta = dbio.latest_block_meta().unwrap().expect("meta is set"); + assert_eq!(meta.id, 2); + assert_eq!(meta.hash, block2b.header.hash); + assert_eq!(stored_balance(&dbio), 400); +} + +#[test] +fn shrink_only_reorg_rewinds_tip_meta() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2, &state_with_balance(200), false) + .unwrap(); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + dbio.store_followed_block(&block3, &state_with_balance(300), false) + .unwrap(); + + // Orphan-only update: block 3 falls off the branch with no replacement. + let head_tip = BlockMeta { + id: 2, + hash: block2.header.hash, + }; + dbio.store_update(&StoreUpdate { + head_tip: Some(&head_tip), + ..StoreUpdate::new(&state_with_balance(200)) + }) + .unwrap(); + + assert!( + dbio.get_block(3).unwrap().is_none(), + "the orphaned block must not survive the tip rewind" + ); + let meta = dbio.latest_block_meta().unwrap().expect("meta is set"); + assert_eq!(meta.id, 2); + assert_eq!(meta.hash, block2.header.hash); + assert_eq!(stored_balance(&dbio), 200); +} + +#[test] +fn checkpoint_lands_with_an_orphan_only_update() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2, &state_with_balance(200), false) + .unwrap(); + let block3 = produce_dummy_block(3, Some(block2.header.hash), vec![]); + dbio.store_followed_block(&block3, &state_with_balance(300), false) + .unwrap(); + + // Orphan-only update: no payload to write, but the checkpoint covering it + // must still land, or a restart resumes past the orphan. + let head_tip = BlockMeta { + id: 2, + hash: block2.header.hash, + }; + dbio.store_update(&StoreUpdate { + checkpoint: Some(b"cp-orphan"), + head_tip: Some(&head_tip), + ..StoreUpdate::new(&state_with_balance(200)) + }) + .unwrap(); + + assert_eq!( + dbio.get_zone_sdk_checkpoint_bytes().unwrap().as_deref(), + Some(b"cp-orphan".as_slice()) + ); + assert!(dbio.get_block(3).unwrap().is_none()); +} + +#[test] +fn checkpoint_only_update_does_not_rewrite_the_head_state() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2, &state_with_balance(200), false) + .unwrap(); + + // An event carrying nothing but a checkpoint (the common case) must not + // drag a full state serialization along with it — the caller's state is + // ignored while the chain stands still. + let head_tip = BlockMeta::from(&block2); + dbio.store_update(&StoreUpdate { + checkpoint: Some(b"cp-idle"), + head_tip: Some(&head_tip), + ..StoreUpdate::new(&state_with_balance(999)) + }) + .unwrap(); + + assert_eq!( + dbio.get_zone_sdk_checkpoint_bytes().unwrap().as_deref(), + Some(b"cp-idle".as_slice()) + ); + assert_eq!(stored_balance(&dbio), 200); +} + +#[test] +fn several_deposits_in_one_update_are_all_recorded() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + // The records live in one whole-vector cell: staged per event against a + // fresh disk read, the second would clobber the first. + let first = deposit_record(1); + let second = deposit_record(2); + let already_known = dbio.get_pending_deposit_events().unwrap(); + assert!(already_known.is_empty()); + + let outcome = dbio + .store_update(&StoreUpdate { + new_deposit_events: &[first.clone(), second.clone()], + ..StoreUpdate::new(&state_with_balance(100)) + }) + .unwrap(); + + assert_eq!(outcome.accepted_deposits, 2); + let stored = dbio.get_pending_deposit_events().unwrap(); + assert_eq!(stored.len(), 2); + assert!(stored.contains(&first)); + assert!(stored.contains(&second)); +} + +#[test] +fn redelivered_deposit_is_not_accepted_twice() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + let record = deposit_record(1); + dbio.store_update(&StoreUpdate { + new_deposit_events: std::slice::from_ref(&record), + ..StoreUpdate::new(&state_with_balance(100)) + }) + .unwrap(); + + let outcome = dbio + .store_update(&StoreUpdate { + new_deposit_events: &[record], + ..StoreUpdate::new(&state_with_balance(100)) + }) + .unwrap(); + + assert_eq!( + outcome.accepted_deposits, 0, + "a re-delivered deposit is already owed, not newly accepted" + ); + assert_eq!(dbio.get_pending_deposit_events().unwrap().len(), 1); +} + +#[test] +fn finalized_deposit_records_are_removed_by_op_id() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + let first = deposit_record(1); + let second = deposit_record(2); + dbio.store_update(&StoreUpdate { + new_deposit_events: &[first.clone(), second.clone()], + ..StoreUpdate::new(&state_with_balance(100)) + }) + .unwrap(); + + // Only the finalized op id is dropped; the other record stays. + dbio.store_update(&StoreUpdate { + remove_deposit_records: &[first.deposit_op_id], + ..StoreUpdate::new(&state_with_balance(100)) + }) + .unwrap(); + + let stored = dbio.get_pending_deposit_events().unwrap(); + assert_eq!(stored, vec![second]); +} + +#[test] +fn dispatch_records_round_trip_and_dedupe_by_message_key() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + let record = dispatch_record(1); + assert_eq!( + dbio.add_pending_cross_zone_dispatches(vec![record.clone()]) + .unwrap(), + 1 + ); + // The watcher re-reads a slot it stalled on, so the same delivery arrives + // again; recording it twice would double-count its failed attempts. + assert_eq!( + dbio.add_pending_cross_zone_dispatches(vec![record.clone(), dispatch_record(2)]) + .unwrap(), + 1, + "only the delivery not already held is newly recorded" + ); + + assert_eq!( + dbio.get_pending_cross_zone_dispatches().unwrap(), + vec![record, dispatch_record(2)] + ); +} + +#[test] +fn recording_past_the_cap_writes_nothing() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + // What fills this list is chosen by peer zones, so the bound is what stops a + // peer deciding how large our store gets. Refusing the whole write leaves + // the watcher's floor where it is, so the slot is read again later and + // nothing is lost. + let full: Vec<_> = (0..MAX_PENDING_CROSS_ZONE_DISPATCHES) + .map(|seed| PendingCrossZoneDispatchRecord::recorded(key_from_index(seed), vec![0_u8; 4])) + .collect(); + assert_eq!( + dbio.add_pending_cross_zone_dispatches(full).unwrap(), + MAX_PENDING_CROSS_ZONE_DISPATCHES + ); + + let over = PendingCrossZoneDispatchRecord::recorded( + key_from_index(MAX_PENDING_CROSS_ZONE_DISPATCHES), + vec![0_u8; 4], + ); + assert!( + dbio.add_pending_cross_zone_dispatches(vec![over]).is_err(), + "recording past the cap must fail so the caller holds its floor" + ); + assert_eq!( + dbio.get_pending_cross_zone_dispatches().unwrap().len(), + MAX_PENDING_CROSS_ZONE_DISPATCHES, + "a refused write must leave the list untouched" + ); + + // Re-offering only what is already held is not growth, so it still succeeds. + assert_eq!( + dbio.add_pending_cross_zone_dispatches(vec![PendingCrossZoneDispatchRecord::recorded( + key_from_index(0), + vec![0_u8; 4] + )]) + .unwrap(), + 0 + ); +} + +#[test] +fn settled_dispatch_records_are_dropped_outside_an_update() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + // The watcher re-reads a slot it already consumed and re-records a delivery + // that settled long ago. Its key will never appear in a future block, so the + // store-update path cannot reach it and this is the only thing that does. + let first = dispatch_record(1); + let second = dispatch_record(2); + dbio.add_pending_cross_zone_dispatches(vec![first.clone(), second.clone()]) + .unwrap(); + + assert_eq!( + dbio.drop_settled_cross_zone_dispatches(&[first.message_key]) + .unwrap(), + 1 + ); + assert_eq!( + dbio.get_pending_cross_zone_dispatches().unwrap(), + vec![second] + ); + + // Dropping one that is already gone is a no-op, not an error. + assert_eq!( + dbio.drop_settled_cross_zone_dispatches(&[first.message_key]) + .unwrap(), + 0 + ); +} + +#[test] +fn finalized_dispatch_records_are_removed_by_message_key() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + let first = dispatch_record(1); + let second = dispatch_record(2); + dbio.add_pending_cross_zone_dispatches(vec![first.clone(), second.clone()]) + .unwrap(); + + // Only the finalized delivery's key is dropped. Two deliveries can sit in + // the same block, so a record must go by its own identity rather than by + // anything about the height its delivery landed at. + dbio.store_update(&StoreUpdate { + remove_dispatch_records: &[first.message_key], + ..StoreUpdate::new(&state_with_balance(100)) + }) + .unwrap(); + + assert_eq!( + dbio.get_pending_cross_zone_dispatches().unwrap(), + vec![second] + ); +} + +#[test] +fn record_dispatch_failure_drops_the_record_at_the_limit() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + let record = dispatch_record(1); + let key = record.message_key; + let survivor = dispatch_record(2); + dbio.add_pending_cross_zone_dispatches(vec![record, survivor.clone()]) + .unwrap(); + + assert!(!dbio.record_dispatch_failure(key, 3).unwrap()); + assert_eq!( + dbio.get_pending_cross_zone_dispatches().unwrap()[0].failed_attempts, + 1, + "a failure short of the limit is counted, not given up on" + ); + assert!(!dbio.record_dispatch_failure(key, 3).unwrap()); + assert!( + dbio.record_dispatch_failure(key, 3).unwrap(), + "the third failure is the one it is given up on" + ); + + // Dropped rather than flagged: a delivery the drain will never feed into a + // block again is one nothing would ever remove, so flagging it would let a + // peer that can make deliveries fail grow the list without bound. + assert_eq!( + dbio.get_pending_cross_zone_dispatches().unwrap(), + vec![survivor], + "giving up on a delivery drops its record and leaves the others alone" + ); + + // A key with no record reads as given up on: there is nothing left to count + // against, and nothing will feed it into a block. + assert!( + dbio.record_dispatch_failure(key, 3).unwrap(), + "a failure against a dropped delivery must not re-create its record" + ); + assert_eq!(dbio.get_pending_cross_zone_dispatches().unwrap().len(), 1); +} + +#[test] +fn repeated_withdrawal_key_in_one_update_folds_once_per_occurrence() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + let key = WithdrawalReconciliationKey { + released_note_id: [3; 32], + }; + + // Two local intents for the same key in one update. A per-occurrence disk + // read would miss the staged increment and record the pair as one. + dbio.store_update(&StoreUpdate { + new_withdraw_intents: &[key, key], + ..StoreUpdate::new(&state_with_balance(100)) + }) + .unwrap(); + let recorded = dbio + .get_opt::(key) + .unwrap() + .map(|cell| cell.0); + assert_eq!(recorded, Some(2)); + + // Both L1 events arrive in one update; a per-occurrence disk read would + // miss the staged decrement and consume only one. + let outcome = dbio + .store_update(&StoreUpdate { + consumed_withdrawals: &[key, key], + ..StoreUpdate::new(&state_with_balance(100)) + }) + .unwrap(); + + assert!(outcome.unmatched_withdrawals.is_empty()); + // Both decrements landed; a per-occurrence disk read would leave `Some(1)`. + // (The absolute value trails the intent count by one — `consume` still + // treats a stored 0 as consumable — but that predates the batching and is + // replicated as-is.) + let remaining = dbio + .get_opt::(key) + .unwrap() + .map(|cell| cell.0); + assert_eq!(remaining, Some(0)); +} + +#[test] +fn unmatched_withdrawal_is_reported_and_writes_nothing() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + let key = WithdrawalReconciliationKey { + released_note_id: [4; 32], + }; + let outcome = dbio + .store_update(&StoreUpdate { + consumed_withdrawals: &[key], + ..StoreUpdate::new(&state_with_balance(100)) + }) + .unwrap(); + + assert_eq!(outcome.unmatched_withdrawals.len(), 1); + assert!( + dbio.get_opt::(key) + .unwrap() + .is_none(), + "an unmatched withdraw must not leave a counter behind" + ); +} + +#[test] +fn produced_block_persists_its_publish_checkpoint() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.atomic_update(&block2, &[], &state_with_balance(200), Some(b"cp-produced")) + .unwrap(); + + // Storing the block without the checkpoint would let a restart restore a + // pending set that no longer holds the inscription we just published. + assert_eq!( + dbio.get_zone_sdk_checkpoint_bytes().unwrap().as_deref(), + Some(b"cp-produced".as_slice()) + ); + assert_eq!( + dbio.get_block(2).unwrap().unwrap().header.hash, + block2.header.hash + ); +} + +#[test] +fn produced_block_below_disk_head_pins_meta_and_prunes() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let block2a = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_followed_block(&block2a, &state_with_balance(200), false) + .unwrap(); + let block3 = produce_dummy_block(3, Some(block2a.header.hash), vec![]); + dbio.store_followed_block(&block3, &state_with_balance(300), false) + .unwrap(); + + // Producing at height 2 while the disk head is still 3: the produce path + // pins the tip meta to the produced block and drops the stale suffix in + // the same write, mirroring the follow path. + let block2b = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.atomic_update(&block2b, &[], &state_with_balance(400), None) + .unwrap(); + + let stored2 = dbio.get_block(2).unwrap().expect("block 2 is stored"); + assert_eq!(stored2.header.hash, block2b.header.hash); + assert!(dbio.get_block(3).unwrap().is_none()); + let meta = dbio.latest_block_meta().unwrap().expect("meta is set"); + assert_eq!(meta.id, 2); + assert_eq!(meta.hash, block2b.header.hash); + assert_eq!(stored_balance(&dbio), 400); +} diff --git a/lez/testnet_initial_state/src/lib.rs b/lez/testnet_initial_state/src/lib.rs index 423d6eda..f77a083f 100644 --- a/lez/testnet_initial_state/src/lib.rs +++ b/lez/testnet_initial_state/src/lib.rs @@ -86,6 +86,7 @@ pub struct PublicAccountPublicInitialData { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PrivateAccountPublicInitialData { pub npk: lee_core::NullifierPublicKey, + pub vpk: lee_core::encryption::ViewingPublicKey, pub account: lee_core::account::Account, } @@ -108,6 +109,7 @@ impl PrivateAccountPrivateInitialData { pub fn account_id(&self) -> lee::AccountId { lee::AccountId::for_regular_private_account( &self.key_chain.nullifier_public_key, + &self.key_chain.viewing_public_key, self.identifier, ) } @@ -183,6 +185,7 @@ fn initial_commitments() -> Vec { .into_iter() .map(|data| PrivateAccountPublicInitialData { npk: data.key_chain.nullifier_public_key, + vpk: data.key_chain.viewing_public_key.clone(), account: data.account, }) .collect() @@ -193,7 +196,8 @@ fn initial_private_accounts() -> Vec<(lee_core::Commitment, lee_core::Nullifier) .iter() .map(|init_comm_data| { let npk = &init_comm_data.npk; - let account_id = lee::AccountId::for_regular_private_account(npk, 0); + let account_id = + lee::AccountId::for_regular_private_account(npk, &init_comm_data.vpk, 0); let mut acc = init_comm_data.account.clone(); @@ -267,6 +271,16 @@ fn initial_programs() -> Vec { programs::vault(), programs::faucet(), programs::bridge(), + // Cross-zone programs are builtins: their bytecode is baked into every node, + // so registering them in the base state (rather than shipping ELFs through + // the genesis block, which exceeds the inscription size limit) keeps the two + // nodes in lock-step with nothing to desync. + programs::cross_zone_inbox(), + programs::cross_zone_outbox(), + programs::ping_sender(), + programs::ping_receiver(), + programs::bridge_lock(), + programs::wrapped_token(), ] } @@ -304,8 +318,8 @@ mod tests { const PUB_ACC_A_TEXT_ADDR: &str = "6iArKUXxhUJqS7kCaPNhwMWt3ro71PDyBj7jwAyE2VQV"; const PUB_ACC_B_TEXT_ADDR: &str = "7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo"; - const PRIV_ACC_A_TEXT_ADDR: &str = "4eGX3M3rgjHsme8n3sSp89af8JRZtYVTesbJjLqaX1VQ"; - const PRIV_ACC_B_TEXT_ADDR: &str = "3m6HQmCgmAvsxZtxAHPqqEqoBG4335fCG8TzxigyW7rE"; + const PRIV_ACC_A_TEXT_ADDR: &str = "EVesBKsYRVtkjnTcsbk8tWHkBn2xZmzAXzwgrP3ZaVoZ"; + const PRIV_ACC_B_TEXT_ADDR: &str = "94MXhZnueurjX6v37CYDKVEKYBiyhYArvtEdceq2XDQP"; #[test] fn pub_state_consistency() { @@ -440,6 +454,10 @@ mod tests { init_comms[0], PrivateAccountPublicInitialData { npk: NullifierPublicKey(NPK_PRIV_ACC_A), + vpk: init_private_accs_keys[0] + .key_chain + .viewing_public_key + .clone(), account: Account { program_owner: DEFAULT_PROGRAM_OWNER, balance: PRIV_ACC_A_INITIAL_BALANCE, @@ -453,6 +471,10 @@ mod tests { init_comms[1], PrivateAccountPublicInitialData { npk: NullifierPublicKey(NPK_PRIV_ACC_B), + vpk: init_private_accs_keys[1] + .key_chain + .viewing_public_key + .clone(), account: Account { program_owner: DEFAULT_PROGRAM_OWNER, balance: PRIV_ACC_B_INITIAL_BALANCE, diff --git a/lez/wallet-ffi/Cargo.toml b/lez/wallet-ffi/Cargo.toml index 47d18c53..5440bee2 100644 --- a/lez/wallet-ffi/Cargo.toml +++ b/lez/wallet-ffi/Cargo.toml @@ -14,8 +14,6 @@ crate-type = ["rlib", "cdylib", "staticlib"] wallet.workspace = true lee.workspace = true lee_core.workspace = true -sequencer_service_rpc = { workspace = true, features = ["client"] } -common.workspace = true programs.workspace = true tokio.workspace = true @@ -28,9 +26,6 @@ vault_core.workspace = true [build-dependencies] cbindgen = "0.29" -[dev-dependencies] -tempfile = "3" - [features] default = ["prove"] prove = ["lee/prove"] diff --git a/lez/wallet-ffi/src/label.rs b/lez/wallet-ffi/src/label.rs new file mode 100644 index 00000000..1f6f1236 --- /dev/null +++ b/lez/wallet-ffi/src/label.rs @@ -0,0 +1,316 @@ +use std::{ + ffi::{c_char, CString}, + str::FromStr as _, +}; + +use crate::{ + c_str_to_string, + error::{print_error, WalletFfiError}, + wallet::get_wallet, + FfiAccountIdWithPrivacy, WalletHandle, +}; + +#[repr(C)] +pub struct LabelAvailability { + pub is_available: bool, + pub error: WalletFfiError, +} + +impl LabelAvailability { + #[must_use] + pub const fn availability(is_available: bool) -> Self { + Self { + is_available, + error: WalletFfiError::Success, + } + } + + #[must_use] + pub const fn error(error: WalletFfiError) -> Self { + Self { + is_available: false, + error, + } + } +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct AccountIdResolvedFromLabel { + pub account_id: FfiAccountIdWithPrivacy, + pub error: WalletFfiError, +} + +impl AccountIdResolvedFromLabel { + #[must_use] + pub const fn account_id(account_id: FfiAccountIdWithPrivacy) -> Self { + Self { + account_id, + error: WalletFfiError::Success, + } + } + + #[must_use] + pub fn error(error: WalletFfiError) -> Self { + Self { + account_id: FfiAccountIdWithPrivacy::default(), + error, + } + } +} + +#[repr(C)] +pub struct LabelList { + pub labels_data: *mut *const c_char, + pub labels_size: usize, + pub error: WalletFfiError, +} + +impl LabelList { + #[must_use] + pub fn from_labels(labels: Vec<*const c_char>) -> Self { + let labels_size = labels.len(); + let boxed_slice = labels.into_boxed_slice(); + let labels_data = Box::into_raw(boxed_slice).cast::<*const c_char>(); + + Self { + labels_data, + labels_size, + error: WalletFfiError::Success, + } + } + + #[must_use] + pub const fn error(error: WalletFfiError) -> Self { + Self { + labels_data: std::ptr::null_mut(), + labels_size: 0, + error, + } + } +} + +/// Check if label is available. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `label`: Input null terminated C string for a label +/// +/// # Returns +/// - `LabelAvailability` struct +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +/// - `label` must be a valid pointer to a null-terminated C string +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_check_label_available( + handle: *mut WalletHandle, + label: *const c_char, +) -> LabelAvailability { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return LabelAvailability::error(e), + }; + + let label = match c_str_to_string(label, "label") { + Ok(value) => value, + Err(e) => return LabelAvailability::error(e), + }; + + let wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {e}")); + return LabelAvailability::error(WalletFfiError::InternalError); + } + }; + + let is_available = wallet + .storage() + .check_label_availability(&label.into()) + .is_ok(); + + LabelAvailability::availability(is_available) +} + +/// Add new label. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `label`: Input null terminated C string for a label +/// - `account_id_with_privacy`: The account ID (32 bytes) and its privacy. +/// +/// # Returns +/// - `Success` on successful query +/// - Error code on failure +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +/// - `label` must be a valid pointer to a null-terminated C string +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_add_label( + handle: *mut WalletHandle, + label: *const c_char, + account_id_with_privacy: FfiAccountIdWithPrivacy, +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + let label = match c_str_to_string(label, "label") { + Ok(value) => value, + Err(e) => return e, + }; + + let mut wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {e}")); + return WalletFfiError::InternalError; + } + }; + + match wallet + .storage_mut() + .add_label(label.into(), account_id_with_privacy.into()) + { + Ok(()) => WalletFfiError::Success, + Err(err) => { + print_error(format!("Failed to add label : {err}")); + WalletFfiError::InternalError + } + } +} + +/// Resolve a label. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `label`: Input null terminated C string for a label +/// +/// # Returns +/// - `AccountIdResolvedFromLabel` struct +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +/// - `label` must be a valid pointer to a null-terminated C string +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_resolve_label( + handle: *mut WalletHandle, + label: *const c_char, +) -> AccountIdResolvedFromLabel { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return AccountIdResolvedFromLabel::error(e), + }; + + let label = match c_str_to_string(label, "label") { + Ok(value) => value, + Err(e) => return AccountIdResolvedFromLabel::error(e), + }; + + let mut wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {e}")); + return AccountIdResolvedFromLabel::error(WalletFfiError::InternalError); + } + }; + + wallet + .storage_mut() + .resolve_label(&label.into()) + .map_or_else( + || { + print_error("Failed to resolve label"); + AccountIdResolvedFromLabel::error(WalletFfiError::InternalError) + }, + |acc_id| AccountIdResolvedFromLabel::account_id(acc_id.into()), + ) +} + +/// Get all labels for account. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `account_id_with_privacy`: The account ID (32 bytes) and its privacy. +/// +/// # Returns +/// - `LabelList` struct +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_get_all_labels_for_account( + handle: *mut WalletHandle, + account_id_with_privacy: FfiAccountIdWithPrivacy, +) -> LabelList { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return LabelList::error(e), + }; + + let wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {e}")); + return LabelList::error(WalletFfiError::InternalError); + } + }; + + let mut labels = vec![]; + + for label in wallet + .storage() + .labels_for_account(account_id_with_privacy.into()) + { + let Ok(label_c) = CString::from_str(label.as_ref()) else { + print_error(format!("Failed to cast label into C string: {label}")); + return LabelList::error(WalletFfiError::InternalError); + }; + + let label_raw = label_c.into_raw().cast_const(); + + labels.push(label_raw); + } + + LabelList::from_labels(labels) +} + +/// Free label list. +/// +/// # Parameters +/// - `label_list`: Input list of labels +/// +/// # Returns +/// - `Success` on successful query +/// - Error code on failure +/// +/// # Safety +/// - `label_list` must be a valid pointer to `LabelList`, received from +/// `wallet_ffi_get_all_labels_for_account` +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_free_label_list(label_list: *mut LabelList) -> WalletFfiError { + if label_list.is_null() { + return WalletFfiError::NullPointer; + } + + let labels_raw = unsafe { &*label_list }; + + if !labels_raw.labels_data.is_null() && labels_raw.labels_size > 0 { + let labels_slice = + std::slice::from_raw_parts_mut(labels_raw.labels_data, labels_raw.labels_size); + + for label_ptr in labels_slice.iter() { + if !(*label_ptr).is_null() { + drop(CString::from_raw((*label_ptr).cast_mut())); + } + } + + let boxed_slice = Box::from_raw(std::ptr::from_mut::<[*const c_char]>(labels_slice)); + drop(boxed_slice); + } + + WalletFfiError::Success +} diff --git a/lez/wallet-ffi/src/lib.rs b/lez/wallet-ffi/src/lib.rs index 6c86b0c8..c91185e6 100644 --- a/lez/wallet-ffi/src/lib.rs +++ b/lez/wallet-ffi/src/lib.rs @@ -46,6 +46,8 @@ pub mod bridge; pub mod error; pub mod generic_transaction; pub mod keys; +pub mod label; +pub mod pda; pub mod pinata; pub mod program_deployment; pub mod sync; diff --git a/lez/wallet-ffi/src/pda.rs b/lez/wallet-ffi/src/pda.rs new file mode 100644 index 00000000..35a9482b --- /dev/null +++ b/lez/wallet-ffi/src/pda.rs @@ -0,0 +1,142 @@ +use lee::AccountId; + +use crate::{ + error::WalletFfiError, FfiBytes32, FfiNullifierPublicKey, FfiPdaSeed, FfiPrivateAccountKeys, + FfiProgramId, FfiU128, +}; + +/// Produce account id for public PDA. +/// +/// # Parameters +/// - `program_id`: Id of the owner program +/// - `pda_seed`: 32 byte seed +/// +/// # Returns +/// - `FfiBytes32` representing account id bytes +#[no_mangle] +pub extern "C" fn wallet_ffi_account_id_for_public_pda( + program_id: FfiProgramId, + pda_seed: FfiPdaSeed, +) -> FfiBytes32 { + AccountId::for_public_pda(&program_id.data, &pda_seed.into()).into() +} + +/// Produce account id for private PDA. +/// +/// # Parameters +/// - `program_id`: Id of the owner program +/// - `pda_seed`: 32 byte seed +/// - `npk`: 32 byte nullifier public key (can be obtained from +/// `wallet_ffi_get_private_account_keys`) +/// - `viewing_public_key`: pointer to u8 (can be obtained from +/// `wallet_ffi_get_private_account_keys`) +/// - `viewing_public_key_len`: length of a `viewing_public_key` (can be obtained from +/// `wallet_ffi_get_private_account_keys`), must be `1184` +/// - `identifier`: little endian encoded `u128` +/// - `account_id`: valid pointer to `FfiBytes32` +/// +/// # Returns +/// - `Success` on successful parsing +/// - Error code on failure +/// +/// # Safety +/// - `viewing_public_key` must be a valid pointer to a `u8` +/// - `account_id` must be a valid pointer to a `FfiBytes32` struct +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_account_id_for_private_pda( + program_id: FfiProgramId, + pda_seed: FfiPdaSeed, + npk: FfiNullifierPublicKey, + viewing_public_key: *const u8, + viewing_public_key_len: usize, + identifier: FfiU128, + account_id: *mut FfiBytes32, +) -> WalletFfiError { + if viewing_public_key.is_null() { + return WalletFfiError::NullPointer; + } + + let ffi_private_keys = FfiPrivateAccountKeys { + nullifier_public_key: npk, + viewing_public_key, + viewing_public_key_len, + }; + + let vpk = ffi_private_keys.vpk(); + + if vpk.is_err() { + return vpk.err().unwrap(); + } + + unsafe { + *account_id = AccountId::for_private_pda( + &program_id.data, + &pda_seed.into(), + &ffi_private_keys.npk(), + &vpk.unwrap(), + identifier.into(), + ) + .into(); + } + + WalletFfiError::Success +} + +#[cfg(test)] +mod tests { + use lee::AccountId; + use lee_core::{encryption::ViewingPublicKey, NullifierPublicKey}; + use vault_core::PdaSeed; + + use crate::{ + error::WalletFfiError, + pda::{wallet_ffi_account_id_for_private_pda, wallet_ffi_account_id_for_public_pda}, + FfiBytes32, + }; + + #[test] + fn public_pda_consistent_derivation() { + let program_id = [100_u32, 101, 102, 103, 104, 105, 106, 107]; + let pda_seed = PdaSeed::new([42; 32]); + + let pda_id = AccountId::for_public_pda(&program_id, &pda_seed); + let ffi_pda_id = wallet_ffi_account_id_for_public_pda(program_id.into(), pda_seed.into()); + + assert_eq!(pda_id.into_value(), ffi_pda_id.data); + } + + #[test] + fn private_pda_consistent_derivation() { + let program_id = [100_u32, 101, 102, 103, 104, 105, 106, 107]; + let pda_seed = PdaSeed::new([42; 32]); + let vpk = ViewingPublicKey::from_bytes(vec![43; 1184]).unwrap(); + let npk = NullifierPublicKey([44; 32]); + let identifier = 100_000_u128; + + let pda_id = AccountId::for_private_pda(&program_id, &pda_seed, &npk, &vpk, identifier); + + let vpk_ptr = Box::into_raw(vpk.to_bytes().to_vec().into_boxed_slice()) as *const u8; + + let mut ffi_pda_id_base = FfiBytes32 { data: [0; 32] }; + let ffi_pda_id = &raw mut ffi_pda_id_base; + + let err = unsafe { + wallet_ffi_account_id_for_private_pda( + program_id.into(), + pda_seed.into(), + npk.into(), + vpk_ptr, + 1184, + identifier.into(), + ffi_pda_id, + ) + }; + + assert_eq!(err, WalletFfiError::Success); + + assert_eq!(pda_id.into_value(), unsafe { (*ffi_pda_id).data }); + + let vpk_slice = unsafe { std::slice::from_raw_parts_mut(vpk_ptr.cast_mut(), 1184) }; + drop(unsafe { Box::from_raw(std::ptr::from_mut(vpk_slice)) }); + } +} diff --git a/lez/wallet-ffi/src/program_deployment.rs b/lez/wallet-ffi/src/program_deployment.rs index 2086fa5c..2e5550ae 100644 --- a/lez/wallet-ffi/src/program_deployment.rs +++ b/lez/wallet-ffi/src/program_deployment.rs @@ -1,9 +1,5 @@ use std::{ffi::CString, ptr, slice}; -use common::transaction::LeeTransaction; -use lee::ProgramDeploymentTransaction; -use sequencer_service_rpc::RpcClient as _; - use crate::{ block_on, error::{print_error, WalletFfiError}, @@ -60,14 +56,7 @@ pub unsafe extern "C" fn wallet_ffi_program_deployment( let elf = unsafe { slice::from_raw_parts(elf_data, elf_size) }.to_vec(); - let message = lee::program_deployment_transaction::Message::new(elf); - let transaction = ProgramDeploymentTransaction::new(message); - - match block_on( - wallet - .sequencer_client - .send_transaction(LeeTransaction::ProgramDeployment(transaction)), - ) { + match block_on(wallet.send_program_deployment_transaction(elf)) { Ok(tx_hash) => { let tx_hash = CString::new(tx_hash.to_string()) .map_or(ptr::null_mut(), std::ffi::CString::into_raw); diff --git a/lez/wallet-ffi/src/sync.rs b/lez/wallet-ffi/src/sync.rs index 5f7a4413..b65a0944 100644 --- a/lez/wallet-ffi/src/sync.rs +++ b/lez/wallet-ffi/src/sync.rs @@ -1,7 +1,5 @@ //! Block synchronization functions. -use sequencer_service_rpc::RpcClient as _; - use crate::{ block_on, error::{print_error, WalletFfiError}, @@ -136,7 +134,7 @@ pub unsafe extern "C" fn wallet_ffi_get_current_block_height( } }; - match block_on(wallet.sequencer_client.get_last_block_id()) { + match block_on(wallet.get_last_block_id()) { Ok(last_block_id) => { unsafe { *out_block_height = last_block_id; diff --git a/lez/wallet-ffi/src/transfer.rs b/lez/wallet-ffi/src/transfer.rs index 1fcd3133..6f174f98 100644 --- a/lez/wallet-ffi/src/transfer.rs +++ b/lez/wallet-ffi/src/transfer.rs @@ -191,7 +191,7 @@ pub unsafe extern "C" fn wallet_ffi_transfer_shielded( let transfer = NativeTokenTransfer(&wallet); match block_on(transfer.send_shielded_transfer_to_outer_account( - from_mention.into_public_identity(from_id), + from_mention.into_public_identity(from_id, true), to_npk, to_vpk, to_identifier, @@ -464,7 +464,7 @@ pub unsafe extern "C" fn wallet_ffi_transfer_shielded_owned( let transfer = NativeTokenTransfer(&wallet); match block_on(transfer.send_shielded_transfer( - from_mention.into_public_identity(from_id), + from_mention.into_public_identity(from_id, true), to_id, amount, )) { diff --git a/lez/wallet-ffi/src/types.rs b/lez/wallet-ffi/src/types.rs index cbe9fab7..3779ba01 100644 --- a/lez/wallet-ffi/src/types.rs +++ b/lez/wallet-ffi/src/types.rs @@ -8,8 +8,8 @@ use std::{ }; use lee::{Data, ProgramId, SharedSecretKey}; -use lee_core::{encryption::MlKem768EncapsulationKey, NullifierPublicKey}; -use wallet::AccountIdentity; +use lee_core::{encryption::MlKem768EncapsulationKey, program::PdaSeed, NullifierPublicKey}; +use wallet::{account::AccountIdWithPrivacy, AccountIdentity}; use crate::error::WalletFfiError; @@ -24,11 +24,41 @@ pub struct WalletHandle { /// 32-byte array type for `AccountId`, keys, hashes, etc. #[repr(C)] -#[derive(Clone, Copy, Default)] +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)] pub struct FfiBytes32 { pub data: [u8; 32], } +pub type FfiPdaSeed = FfiBytes32; + +impl From for PdaSeed { + fn from(value: FfiPdaSeed) -> Self { + Self::new(value.data) + } +} + +impl From for FfiPdaSeed { + fn from(value: PdaSeed) -> Self { + Self { + data: *value.as_bytes(), + } + } +} + +pub type FfiNullifierPublicKey = FfiBytes32; + +impl From for NullifierPublicKey { + fn from(value: FfiNullifierPublicKey) -> Self { + Self(value.data) + } +} + +impl From for FfiNullifierPublicKey { + fn from(value: NullifierPublicKey) -> Self { + Self { data: value.0 } + } +} + /// Program ID - 8 u32 values (32 bytes total). #[repr(C)] #[derive(Clone, Copy, Default)] @@ -593,6 +623,38 @@ impl From for ProgramId { } } +#[repr(C)] +#[derive(Default, PartialEq, Eq, Debug, Clone, Copy)] +pub struct FfiAccountIdWithPrivacy { + pub account_id: FfiBytes32, + pub is_private: bool, +} + +impl From for FfiAccountIdWithPrivacy { + fn from(value: AccountIdWithPrivacy) -> Self { + match value { + AccountIdWithPrivacy::Public(acc) => Self { + account_id: acc.into(), + is_private: false, + }, + AccountIdWithPrivacy::Private(acc) => Self { + account_id: acc.into(), + is_private: true, + }, + } + } +} + +impl From for AccountIdWithPrivacy { + fn from(value: FfiAccountIdWithPrivacy) -> Self { + if value.is_private { + Self::Private(value.account_id.into()) + } else { + Self::Public(value.account_id.into()) + } + } +} + #[cfg(test)] mod tests { use lee::{AccountId, PrivateKey, PublicKey}; @@ -613,9 +675,10 @@ mod tests { let identifier = u128::from_le_bytes([45; 16]); let private_reg_acc_id = - AccountId::for_private_account(&npk, &PrivateAccountKind::Regular(identifier)); + AccountId::for_private_account(&npk, &vpk, &PrivateAccountKind::Regular(identifier)); let private_pda_acc_id = AccountId::for_private_account( &npk, + &vpk, &PrivateAccountKind::Pda { program_id: [46; 8], seed: PdaSeed::new([47; 32]), diff --git a/lez/wallet-ffi/src/wallet.rs b/lez/wallet-ffi/src/wallet.rs index b19aaae5..cbee32a7 100644 --- a/lez/wallet-ffi/src/wallet.rs +++ b/lez/wallet-ffi/src/wallet.rs @@ -86,6 +86,7 @@ fn c_str_to_path(ptr: *const c_char, name: &str) -> Result Result FfiCreateWalletOutput { let Ok(config_path) = c_str_to_path(config_path, "config_path") else { @@ -112,7 +114,17 @@ pub unsafe extern "C" fn wallet_ffi_create_new( return FfiCreateWalletOutput::default(); }; - match WalletCore::new_init_storage(config_path, storage_path, None, &password) { + let Ok(statistics_path) = c_str_to_path(statistics_path, "statistics_path") else { + return FfiCreateWalletOutput::default(); + }; + + match block_on(WalletCore::new_init_storage( + config_path, + storage_path, + statistics_path, + None, + &password, + )) { Ok((core, mnemonic)) => { let wrapper = Box::new(WalletWrapper { core: Mutex::new(core), @@ -143,7 +155,8 @@ pub unsafe extern "C" fn wallet_ffi_create_new( /// /// # Parameters /// - `config_path`: Path to the wallet configuration file (JSON) -/// - `storage_path`: Path where wallet data is stored +/// - `storage_path`: Path to the wallet storage (JSON) +/// - `statistics_path`: Path to the wallet statistics file (JSON) /// /// # Returns /// - Opaque wallet handle on success @@ -155,6 +168,7 @@ pub unsafe extern "C" fn wallet_ffi_create_new( pub unsafe extern "C" fn wallet_ffi_open( config_path: *const c_char, storage_path: *const c_char, + statistics_path: *const c_char, ) -> *mut WalletHandle { let Ok(config_path) = c_str_to_path(config_path, "config_path") else { return ptr::null_mut(); @@ -164,7 +178,16 @@ pub unsafe extern "C" fn wallet_ffi_open( return ptr::null_mut(); }; - match WalletCore::new_update_chain(config_path, storage_path, None) { + let Ok(statistics_path) = c_str_to_path(statistics_path, "statistics_path") else { + return ptr::null_mut(); + }; + + match block_on(WalletCore::new_update_chain( + config_path, + storage_path, + statistics_path, + None, + )) { Ok(core) => { let wrapper = Box::new(WalletWrapper { core: Mutex::new(core), @@ -216,7 +239,7 @@ pub unsafe extern "C" fn wallet_ffi_save(handle: *mut WalletHandle) -> WalletFfi Err(e) => return e, }; - let wallet = match wrapper.core.lock() { + let mut wallet = match wrapper.core.lock() { Ok(w) => w, Err(e) => { print_error(format!("Failed to lock wallet: {e}")); @@ -224,7 +247,10 @@ pub unsafe extern "C" fn wallet_ffi_save(handle: *mut WalletHandle) -> WalletFfi } }; - match wallet.store_persistent_data() { + match wallet + .store_persistent_data() + .and_then(|()| block_on(wallet.client_rotation())) + { Ok(()) => WalletFfiError::Success, Err(e) => { print_error(format!("Failed to save wallet: {e}")); @@ -334,7 +360,7 @@ pub unsafe extern "C" fn wallet_ffi_get_sequencer_addr(handle: *mut WalletHandle } }; - let addr = wallet.config().sequencer_addr.clone().to_string(); + let addr = wallet.helm_url().to_string(); match std::ffi::CString::new(addr) { Ok(s) => s.into_raw(), diff --git a/lez/wallet-ffi/wallet_ffi.h b/lez/wallet-ffi/wallet_ffi.h index d83c520e..bbd7da1f 100644 --- a/lez/wallet-ffi/wallet_ffi.h +++ b/lez/wallet-ffi/wallet_ffi.h @@ -299,6 +299,31 @@ typedef struct FfiPublicAccountKey { struct FfiBytes32 public_key; } FfiPublicAccountKey; +typedef struct LabelAvailability { + bool is_available; + enum WalletFfiError error; +} LabelAvailability; + +typedef struct FfiAccountIdWithPrivacy { + struct FfiBytes32 account_id; + bool is_private; +} FfiAccountIdWithPrivacy; + +typedef struct AccountIdResolvedFromLabel { + struct FfiAccountIdWithPrivacy account_id; + enum WalletFfiError error; +} AccountIdResolvedFromLabel; + +typedef struct LabelList { + const char **labels_data; + uintptr_t labels_size; + enum WalletFfiError error; +} LabelList; + +typedef struct FfiBytes32 FfiPdaSeed; + +typedef struct FfiBytes32 FfiNullifierPublicKey; + typedef struct FfiCreateWalletOutput { struct WalletHandle *wallet; /** @@ -807,6 +832,136 @@ enum WalletFfiError wallet_ffi_resolve_private_account(struct WalletHandle *hand */ void wallet_ffi_free_account_identity(struct FfiAccountIdentity *account_identity); +/** + * Check if label is available. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `label`: Input null terminated C string for a label + * + * # Returns + * - `LabelAvailability` struct + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + * - `label` must be a valid pointer to a null-terminated C string + */ +struct LabelAvailability wallet_ffi_check_label_available(struct WalletHandle *handle, + const char *label); + +/** + * Add new label. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `label`: Input null terminated C string for a label + * - `account_id_with_privacy`: The account ID (32 bytes) and its privacy. + * + * # Returns + * - `Success` on successful query + * - Error code on failure + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + * - `label` must be a valid pointer to a null-terminated C string + */ +enum WalletFfiError wallet_ffi_add_label(struct WalletHandle *handle, + const char *label, + struct FfiAccountIdWithPrivacy account_id_with_privacy); + +/** + * Resolve a label. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `label`: Input null terminated C string for a label + * + * # Returns + * - `AccountIdResolvedFromLabel` struct + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + * - `label` must be a valid pointer to a null-terminated C string + */ +struct AccountIdResolvedFromLabel wallet_ffi_resolve_label(struct WalletHandle *handle, + const char *label); + +/** + * Get all labels for account. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `account_id_with_privacy`: The account ID (32 bytes) and its privacy. + * + * # Returns + * - `LabelList` struct + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + */ +struct LabelList wallet_ffi_get_all_labels_for_account(struct WalletHandle *handle, + struct FfiAccountIdWithPrivacy account_id_with_privacy); + +/** + * Free label list. + * + * # Parameters + * - `label_list`: Input list of labels + * + * # Returns + * - `Success` on successful query + * - Error code on failure + * + * # Safety + * - `label_list` must be a valid pointer to `LabelList`, received from + * `wallet_ffi_get_all_labels_for_account` + */ +enum WalletFfiError wallet_ffi_free_label_list(struct LabelList *label_list); + +/** + * Produce account id for public PDA. + * + * # Parameters + * - `program_id`: Id of the owner program + * - `pda_seed`: 32 byte seed + * + * # Returns + * - `FfiBytes32` representing account id bytes + */ +struct FfiBytes32 wallet_ffi_account_id_for_public_pda(struct FfiProgramId program_id, + FfiPdaSeed pda_seed); + +/** + * Produce account id for private PDA. + * + * # Parameters + * - `program_id`: Id of the owner program + * - `pda_seed`: 32 byte seed + * - `npk`: 32 byte nullifier public key (can be obtained from + * `wallet_ffi_get_private_account_keys`) + * - `viewing_public_key`: pointer to u8 (can be obtained from + * `wallet_ffi_get_private_account_keys`) + * - `viewing_public_key_len`: length of a `viewing_public_key` (can be obtained from + * `wallet_ffi_get_private_account_keys`), must be `1184` + * - `identifier`: little endian encoded `u128` + * - `account_id`: valid pointer to `FfiBytes32` + * + * # Returns + * - `Success` on successful parsing + * - Error code on failure + * + * # Safety + * - `viewing_public_key` must be a valid pointer to a `u8` + * - `account_id` must be a valid pointer to a `FfiBytes32` struct + */ +enum WalletFfiError wallet_ffi_account_id_for_private_pda(struct FfiProgramId program_id, + FfiPdaSeed pda_seed, + FfiNullifierPublicKey npk, + const uint8_t *viewing_public_key, + uintptr_t viewing_public_key_len, + struct FfiU128 identifier, + struct FfiBytes32 *account_id); + /** * Claim a pinata reward using a public transaction. * @@ -1457,6 +1612,7 @@ enum WalletFfiError wallet_ffi_vault_claim_private(struct WalletHandle *handle, * # Parameters * - `config_path`: Path to the wallet configuration file (JSON) * - `storage_path`: Path where wallet data will be stored + * - `statistics_path`: Path to the wallet statistics file (JSON) * - `password`: Password for encrypting the wallet seed * * # Returns @@ -1468,6 +1624,7 @@ enum WalletFfiError wallet_ffi_vault_claim_private(struct WalletHandle *handle, */ struct FfiCreateWalletOutput wallet_ffi_create_new(const char *config_path, const char *storage_path, + const char *statistics_path, const char *password); /** @@ -1477,7 +1634,8 @@ struct FfiCreateWalletOutput wallet_ffi_create_new(const char *config_path, * * # Parameters * - `config_path`: Path to the wallet configuration file (JSON) - * - `storage_path`: Path where wallet data is stored + * - `storage_path`: Path to the wallet storage (JSON) + * - `statistics_path`: Path to the wallet statistics file (JSON) * * # Returns * - Opaque wallet handle on success @@ -1486,7 +1644,9 @@ struct FfiCreateWalletOutput wallet_ffi_create_new(const char *config_path, * # Safety * All string parameters must be valid null-terminated UTF-8 strings. */ -struct WalletHandle *wallet_ffi_open(const char *config_path, const char *storage_path); +struct WalletHandle *wallet_ffi_open(const char *config_path, + const char *storage_path, + const char *statistics_path); /** * Destroy a wallet handle and free its resources. diff --git a/lez/wallet/Cargo.toml b/lez/wallet/Cargo.toml index 974d6a71..c03c1505 100644 --- a/lez/wallet/Cargo.toml +++ b/lez/wallet/Cargo.toml @@ -25,7 +25,6 @@ system_accounts.workspace = true associated_token_account_core.workspace = true bip39.workspace = true -pyo3.workspace = true rpassword = "7" zeroize.workspace = true diff --git a/lez/wallet/configs/debug/wallet_config.json b/lez/wallet/configs/debug/wallet_config.json index 926ee298..c8bd872b 100644 --- a/lez/wallet/configs/debug/wallet_config.json +++ b/lez/wallet/configs/debug/wallet_config.json @@ -1,7 +1,10 @@ { - "sequencer_addr": "http://127.0.0.1:3040", + "sequencers": [{ + "sequencer_addr": "http://127.0.0.1:3040" + }], "seq_poll_timeout": "30s", "seq_tx_poll_max_blocks": 15, "seq_poll_max_retries": 10, - "seq_block_poll_max_amount": 100 + "seq_block_poll_max_amount": 100, + "calibration_limit": 100 } \ No newline at end of file diff --git a/lez/wallet/src/account.rs b/lez/wallet/src/account.rs index 64eee575..8caa7366 100644 --- a/lez/wallet/src/account.rs +++ b/lez/wallet/src/account.rs @@ -21,6 +21,12 @@ impl Label { } } +impl AsRef for Label { + fn as_ref(&self) -> &str { + &self.0 + } +} + impl FromStr for Label { type Err = std::convert::Infallible; diff --git a/lez/wallet/src/account_manager.rs b/lez/wallet/src/account_manager.rs index ce9d1833..3f7c43e0 100644 --- a/lez/wallet/src/account_manager.rs +++ b/lez/wallet/src/account_manager.rs @@ -1,15 +1,18 @@ use core::fmt; use anyhow::Result; -use key_protocol::key_management::ephemeral_key_holder::EphemeralKeyHolder; -use keycard_wallet::{KeycardWallet, python_path}; +use keycard_wallet::KeycardWallet; use lee::{AccountId, PrivateKey, PublicKey, Signature}; use lee_core::{ - Identifier, InputAccountIdentity, MembershipProof, NullifierPublicKey, NullifierSecretKey, - SharedSecretKey, - account::{AccountWithMetadata, Nonce}, - encryption::{EncryptedAccountData, EphemeralPublicKey, ViewingPublicKey}, + Commitment, CommitmentSetDigest, DummyInput, Identifier, InputAccountIdentity, MembershipProof, + NullifierPublicKey, NullifierSecretKey, PrivateAccountKind, SharedSecretKey, + account::{Account, AccountWithMetadata, Nonce}, + compute_digest_for_path, + encryption::{ + Ciphertext, EncryptedAccountData, MlKem768EncapsulationKey, ViewTag, ViewingPublicKey, + }, }; +use rand::{RngCore as _, rngs::OsRng}; use crate::{ExecutionFailureKind, WalletCore}; @@ -187,9 +190,18 @@ enum State { pub struct AccountManager { states: Vec, pin: Option, + dummy_commitment_root: CommitmentSetDigest, } impl AccountManager { + /// The private-account count that every privacy-preserving transaction is padded up to with + /// dummy inputs via the default interface. + /// + /// The value is selected based on the largest account number per-tx currently supported + /// (it is 7 for AMM). It is recommended to reassess this value per new actively supported + /// application and that all users share the value for a larger anonymity set. + const MAX_PRIVATE_ACCOUNTS: usize = 7; + pub async fn new( wallet: &WalletCore, accounts: Vec, @@ -235,14 +247,7 @@ impl AccountManager { if pin.is_none() { pin = Some( crate::helperfunctions::read_pin() - .map_err(|e| { - ExecutionFailureKind::KeycardError(pyo3::PyErr::new::< - pyo3::exceptions::PyRuntimeError, - _, - >( - e.to_string() - )) - })? + .map_err(ExecutionFailureKind::SignError)? .as_str() .to_owned(), ); @@ -251,7 +256,7 @@ impl AccountManager { State::PublicKeycard { account, key_path } } AccountIdentity::PrivateOwned(account_id) => { - let pre = private_key_tree_acc_preparation(wallet, account_id, false).await?; + let pre = private_key_tree_acc_preparation(wallet, account_id, false)?; State::Private(pre) } @@ -261,10 +266,8 @@ impl AccountManager { identifier, } => { let acc = lee_core::account::Account::default(); - let auth_acc = AccountWithMetadata::new(acc, false, (&npk, identifier)); - let eph_holder = EphemeralKeyHolder::new(&vpk); - let ssk = eph_holder.calculate_shared_secret_sender(); - let epk = eph_holder.ephemeral_public_key().clone(); + let auth_acc = AccountWithMetadata::new(acc, true, (&npk, &vpk, identifier)); + let random_seed = random_bytes(); let pre = AccountPreparedData { nsk: None, npk, @@ -272,15 +275,14 @@ impl AccountManager { vpk, pre_state: auth_acc, proof: None, - ssk, - epk, + random_seed, is_pda: false, }; State::Private(pre) } AccountIdentity::PrivatePdaOwned(account_id) => { - let pre = private_key_tree_acc_preparation(wallet, account_id, true).await?; + let pre = private_key_tree_acc_preparation(wallet, account_id, true)?; State::Private(pre) } AccountIdentity::PrivatePdaForeign { @@ -291,9 +293,7 @@ impl AccountManager { } => { let acc = lee_core::account::Account::default(); let auth_acc = AccountWithMetadata::new(acc, false, account_id); - let eph_holder = EphemeralKeyHolder::new(&vpk); - let ssk = eph_holder.calculate_shared_secret_sender(); - let epk = eph_holder.ephemeral_public_key().clone(); + let random_seed = random_bytes(); let pre = AccountPreparedData { nsk: None, npk, @@ -301,8 +301,7 @@ impl AccountManager { vpk, pre_state: auth_acc, proof: None, - ssk, - epk, + random_seed, is_pda: true, }; State::Private(pre) @@ -313,11 +312,10 @@ impl AccountManager { vpk, identifier, } => { - let account_id = lee::AccountId::from((&npk, identifier)); + let account_id = lee::AccountId::from((&npk, &vpk, identifier)); let pre = private_shared_acc_preparation( wallet, account_id, nsk, npk, vpk, identifier, false, - ) - .await?; + ); State::Private(pre) } @@ -330,8 +328,7 @@ impl AccountManager { } => { let pre = private_shared_acc_preparation( wallet, account_id, nsk, npk, vpk, identifier, true, - ) - .await?; + ); State::Private(pre) } @@ -340,7 +337,13 @@ impl AccountManager { states.push(state); } - Ok(Self { states, pin }) + let dummy_commitment_root = fetch_private_proofs_and_root(wallet, &mut states).await?; + + Ok(Self { + states, + pin, + dummy_commitment_root, + }) } pub fn pre_states(&self) -> Vec { @@ -373,12 +376,60 @@ impl AccountManager { self.states .iter() .filter_map(|state| match state { - State::Private(pre) => Some(PrivateAccountKeys { ssk: pre.ssk }), + State::Private(pre) => Some(pre), State::Public { .. } | State::PublicKeycard { .. } => None, }) + .map(|pre| { + let nonce = if pre.proof.is_some() { + pre.pre_state.account.nonce.private_account_nonce_increment( + pre.nsk.as_ref().expect("update variant must have nsk"), + ) + } else { + lee_core::account::Nonce::private_account_nonce_init(&pre.pre_state.account_id) + }; + let esk = lee_core::EphemeralSecretKey::new( + &pre.pre_state.account_id, + &pre.random_seed, + &nonce, + ); + PrivateAccountKeys { + ssk: SharedSecretKey::encapsulate_deterministic(&pre.vpk, &esk).0, + } + }) .collect() } + /// Given a count, generate that many dummy inputs with randomized seeds and notes. + /// Uses the given commitment root from the account. + pub fn dummy_inputs(&self, count: usize) -> Vec { + std::iter::repeat_with(|| DummyInput { + nullifier_seed: random_bytes(), + commitment_seed: random_bytes(), + note: random_dummy_note(), + commitment_root: self.dummy_commitment_root, + }) + .take(count) + .collect() + } + + /// Generate the dummy inputs that pad this transaction's private-account count up to + /// `MAX_PRIVATE_ACCOUNTS`. + pub fn dummy_inputs_default(&self) -> Vec { + let private_count = self + .states + .iter() + .filter(|state| matches!(state, State::Private(_))) + .count(); + if private_count > Self::MAX_PRIVATE_ACCOUNTS { + log::warn!( + "private account count {private_count} exceeds MAX_PRIVATE_ACCOUNTS ({}); \ + padding saturates and the private-input count is not hidden", + Self::MAX_PRIVATE_ACCOUNTS + ); + } + self.dummy_inputs(Self::MAX_PRIVATE_ACCOUNTS.saturating_sub(private_count)) + } + /// Build the per-account input vec for the privacy-preserving circuit. Each variant carries /// exactly the fields the circuit's code path for that account needs, with the ephemeral /// keys (`ssk`) drawn from the cached values that `private_account_keys` and the message @@ -390,47 +441,47 @@ impl AccountManager { State::Public { .. } | State::PublicKeycard { .. } => InputAccountIdentity::Public, State::Private(pre) if pre.is_pda => match (pre.nsk, pre.proof.clone()) { (Some(nsk), Some(membership_proof)) => InputAccountIdentity::PrivatePdaUpdate { - epk: pre.epk.clone(), - view_tag: EncryptedAccountData::compute_view_tag(&pre.npk, &pre.vpk), - ssk: pre.ssk, + vpk: pre.vpk.clone(), + random_seed: pre.random_seed, + view_tag: random_view_tag(), nsk, membership_proof, identifier: pre.identifier, seed: None, }, _ => InputAccountIdentity::PrivatePdaInit { - epk: pre.epk.clone(), - view_tag: EncryptedAccountData::compute_view_tag(&pre.npk, &pre.vpk), + vpk: pre.vpk.clone(), + random_seed: pre.random_seed, npk: pre.npk, - ssk: pre.ssk, identifier: pre.identifier, + commitment_root: self.dummy_commitment_root, seed: None, }, }, State::Private(pre) => match (pre.nsk, pre.proof.clone()) { (Some(nsk), Some(membership_proof)) => { InputAccountIdentity::PrivateAuthorizedUpdate { - epk: pre.epk.clone(), - view_tag: EncryptedAccountData::compute_view_tag(&pre.npk, &pre.vpk), - ssk: pre.ssk, + vpk: pre.vpk.clone(), + random_seed: pre.random_seed, + view_tag: random_view_tag(), nsk, membership_proof, identifier: pre.identifier, } } (Some(nsk), None) => InputAccountIdentity::PrivateAuthorizedInit { - epk: pre.epk.clone(), - view_tag: EncryptedAccountData::compute_view_tag(&pre.npk, &pre.vpk), - ssk: pre.ssk, + vpk: pre.vpk.clone(), + random_seed: pre.random_seed, nsk, identifier: pre.identifier, + commitment_root: self.dummy_commitment_root, }, - (None, _) => InputAccountIdentity::PrivateUnauthorized { - epk: pre.epk.clone(), - view_tag: EncryptedAccountData::compute_view_tag(&pre.npk, &pre.vpk), + (None, _) => InputAccountIdentity::PrivateForeignInit { + vpk: pre.vpk.clone(), + random_seed: pre.random_seed, npk: pre.npk, - ssk: pre.ssk, identifier: pre.identifier, + commitment_root: self.dummy_commitment_root, }, }, }) @@ -481,17 +532,11 @@ impl AccountManager { .collect(); if let Some(pin) = self.pin.clone() { - pyo3::Python::attach(|py| -> pyo3::PyResult<()> { - python_path::add_python_path(py)?; - let wallet = KeycardWallet::new(py)?; - wallet.connect(py, &pin)?; - for path in keycard_paths { - sigs.push(wallet.sign_message_for_path(py, path, &message_hash)?); - } - let _res = wallet.close_session(py); - Ok(()) - }) - .map_err(anyhow::Error::from)?; + let mut wallet = KeycardWallet::new()?; + wallet.connect(&pin)?; + for path in keycard_paths { + sigs.push(wallet.sign_message_for_path(path, &message_hash)?); + } } Ok(sigs) @@ -505,19 +550,13 @@ struct AccountPreparedData { vpk: ViewingPublicKey, pre_state: AccountWithMetadata, proof: Option, - /// Cached shared-secret key derived once at `AccountManager::new`. Reused for both the - /// circuit input variant (`account_identities()`) and the message ephemeral-key tuples - /// (`private_account_keys()`), so all consumers see the same key. The corresponding - /// `EphemeralKeyHolder` uses `OsRng` and would produce a different value on a second call. - ssk: SharedSecretKey, - /// Cached ephemeral public key, paired with `ssk`. - epk: EphemeralPublicKey, + random_seed: [u8; 32], /// True when this account is a private PDA (owned or foreign). Used by `account_identities()` /// to select `PrivatePdaInit`/`PrivatePdaUpdate` rather than the standalone private variants. is_pda: bool, } -async fn private_key_tree_acc_preparation( +fn private_key_tree_acc_preparation( wallet: &WalletCore, account_id: AccountId, is_pda: bool, @@ -532,19 +571,11 @@ async fn private_key_tree_acc_preparation( let from_npk = from_keys.nullifier_public_key; let from_vpk = from_keys.viewing_public_key.clone(); - // TODO: Remove this unwrap, error types must be compatible - let proof = wallet - .check_private_account_initialized(account_id) - .await - .unwrap(); - // TODO: Technically we could allow unauthorized owned accounts, but currently we don't have // support from that in the wallet. let sender_pre = AccountWithMetadata::new(from_acc.account.clone(), true, account_id); - let eph_holder = EphemeralKeyHolder::new(&from_vpk); - let ssk = eph_holder.calculate_shared_secret_sender(); - let epk = eph_holder.ephemeral_public_key().clone(); + let random_seed = random_bytes(); Ok(AccountPreparedData { nsk: Some(nsk), @@ -552,14 +583,13 @@ async fn private_key_tree_acc_preparation( identifier: from_identifier, vpk: from_vpk, pre_state: sender_pre, - proof, - ssk, - epk, + proof: None, + random_seed, is_pda, }) } -async fn private_shared_acc_preparation( +fn private_shared_acc_preparation( wallet: &WalletCore, account_id: AccountId, nsk: NullifierSecretKey, @@ -567,7 +597,7 @@ async fn private_shared_acc_preparation( vpk: ViewingPublicKey, identifier: Identifier, is_pda: bool, -) -> Result { +) -> AccountPreparedData { let acc = wallet .storage() .key_chain() @@ -577,26 +607,108 @@ async fn private_shared_acc_preparation( let pre_state = AccountWithMetadata::new(acc, true, account_id); - let proof = wallet - .check_private_account_initialized(account_id) - .await - .unwrap_or(None); + let random_seed = random_bytes(); - let eph_holder = EphemeralKeyHolder::new(&vpk); - let ssk = eph_holder.calculate_shared_secret_sender(); - let epk = eph_holder.ephemeral_public_key().clone(); - - Ok(AccountPreparedData { + AccountPreparedData { nsk: Some(nsk), npk, identifier, vpk, pre_state, - proof, - ssk, - epk, + proof: None, + random_seed, is_pda, - }) + } +} + +async fn fetch_private_proofs_and_root( + wallet: &WalletCore, + states: &mut [State], +) -> Result { + let (mut private, commitments): (Vec<&mut AccountPreparedData>, Vec) = states + .iter_mut() + .filter_map(|state| match state { + State::Private(pre) => { + let commitment = wallet.get_private_account_commitment(pre.pre_state.account_id)?; + Some((pre, commitment)) + } + State::Public { .. } | State::PublicKeycard { .. } => None, + }) + .unzip(); + + let (proofs, root) = wallet + .get_proofs_and_root(&commitments) + .await + .map_err(ExecutionFailureKind::SequencerError)?; + + validate_proofs_against_root(&commitments, &proofs, root)?; + + for (pre, proof) in private.iter_mut().zip(proofs) { + pre.proof = proof; + } + + Ok(root) +} + +fn validate_proofs_against_root( + commitments: &[Commitment], + proofs: &[Option], + root: CommitmentSetDigest, +) -> Result<(), ExecutionFailureKind> { + if proofs.len() != commitments.len() { + return Err(ExecutionFailureKind::SequencerError(anyhow::anyhow!( + "Sequencer returned {} proofs for {} commitments.", + proofs.len(), + commitments.len(), + ))); + } + + for (commitment, proof) in commitments.iter().zip(proofs) { + if let Some(proof) = proof + && compute_digest_for_path(commitment, proof) != root + { + return Err(ExecutionFailureKind::SequencerError(anyhow::anyhow!( + "Membership proof for {commitment:?} does not reproduce the appropriate root {root:?}.", + ))); + } + } + + Ok(()) +} + +/// Generate random byte using OS randomness. +fn random_view_tag() -> ViewTag { + let mut byte: [u8; 1] = [0; 1]; + OsRng.fill_bytes(&mut byte); + byte[0] +} + +fn random_bytes() -> [u8; 32] { + let mut bytes = [0; 32]; + OsRng.fill_bytes(&mut bytes); + bytes +} + +fn random_vec(len: usize) -> Vec { + let mut bytes = vec![0; len]; + OsRng.fill_bytes(&mut bytes); + bytes +} + +/// Generates a dummy note: random bytes sized to a default-account ciphertext, a real +/// ML-KEM ciphertext epk toward a throwaway key, and a random view tag. +fn random_dummy_note() -> EncryptedAccountData { + // Sized to a default-account ciphertext; matching real data sizes is a separate issue. + let ciphertext_len = PrivateAccountKind::HEADER_LEN + .checked_add(Account::default().to_bytes().len()) + .expect("dummy ciphertext length fits in usize"); + let throwaway_ek = MlKem768EncapsulationKey::from_seed(&random_bytes(), &random_bytes()); + let (_, epk) = SharedSecretKey::encapsulate(&throwaway_ek); + EncryptedAccountData { + ciphertext: Ciphertext::from_inner(random_vec(ciphertext_len)), + epk, + view_tag: random_view_tag(), + } } #[cfg(test)] @@ -614,4 +726,66 @@ mod tests { assert!(acc.is_private()); assert!(!acc.is_public()); } + + fn private_state() -> State { + let npk = NullifierPublicKey([0; 32]); + let vpk = ViewingPublicKey::from_seed(&[0; 32], &[0; 32]); + let pre_state = AccountWithMetadata::new(Account::default(), false, (&npk, &vpk, 0)); + State::Private(AccountPreparedData { + nsk: None, + npk, + identifier: 0, + vpk, + pre_state, + proof: None, + random_seed: [0; 32], + is_pda: false, + }) + } + + fn public_state() -> State { + let npk = NullifierPublicKey([0; 32]); + let vpk = ViewingPublicKey::from_seed(&[0; 32], &[0; 32]); + let account = AccountWithMetadata::new(Account::default(), false, (&npk, &vpk, 0)); + State::Public { account, sk: None } + } + + fn manager(states: Vec) -> AccountManager { + AccountManager { + states, + pin: None, + dummy_commitment_root: [0; 32], + } + } + + #[test] + fn dummy_inputs_default_pads_private_count_to_max() { + let max = AccountManager::MAX_PRIVATE_ACCOUNTS; + + // Empty txs get padded to the max. + assert_eq!(manager(vec![]).dummy_inputs_default().len(), max); + // In a padded transaction, the padding amount depends on + // the amount of private accounts used. + assert_eq!( + manager(vec![private_state(), private_state()]) + .dummy_inputs_default() + .len(), + max - 2 + ); + assert_eq!( + manager(vec![private_state(), public_state(), private_state()]) + .dummy_inputs_default() + .len(), + max - 2 + ); + + // If the private accounts in the transaction exceed the max, no padding + // is done. + let full: Vec = std::iter::repeat_with(private_state).take(max).collect(); + assert_eq!(manager(full).dummy_inputs_default().len(), 0); + let over: Vec = std::iter::repeat_with(private_state) + .take(max + 2) + .collect(); + assert_eq!(manager(over).dummy_inputs_default().len(), 0); + } } diff --git a/lez/wallet/src/cli/account.rs b/lez/wallet/src/cli/account.rs index fb0db2cb..c165deee 100644 --- a/lez/wallet/src/cli/account.rs +++ b/lez/wallet/src/cli/account.rs @@ -111,8 +111,9 @@ pub enum NewSubcommand { #[arg(long, requires = "pda")] /// Program ID as hex string. program_id: Option, - #[arg(long, requires = "pda")] - /// Identifier that diversifies this PDA within the (`program_id`, seed, npk) family. + #[arg(long)] + /// Identifier selecting the shared account. + /// Co-owners must supply the same value to derive the same account. /// Defaults to a random value if not specified. identifier: Option, }, @@ -125,75 +126,177 @@ pub enum NewSubcommand { }, } +impl NewSubcommand { + fn handle_public( + cci: Option, + label: Option