diff --git a/.deny.toml b/.deny.toml index ed628f09..fb1ce3cf 100644 --- a/.deny.toml +++ b/.deny.toml @@ -13,6 +13,9 @@ ignore = [ { id = "RUSTSEC-2025-0055", reason = "`tracing-subscriber` v0.2.25 pulled in by ark-relations v0.4.0 - will be addressed before mainnet" }, { id = "RUSTSEC-2025-0141", reason = "`bincode` is unmaintained but continuing to use it." }, { id = "RUSTSEC-2023-0089", reason = "atomic-polyfill is pulled transitively via risc0-zkvm; waiting on upstream fix (see https://github.com/risc0/risc0/issues/3453)" }, + { id = "RUSTSEC-2026-0097", reason = "`rand` v0.8.5 is present transitively from logos crates, modification may break integration" }, + { id = "RUSTSEC-2026-0118", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration" }, + { id = "RUSTSEC-2026-0119", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration" }, ] yanked = "deny" unused-ignored-advisory = "deny" diff --git a/.github/workflows/bench-regression.yml b/.github/workflows/bench-regression.yml new file mode 100644 index 00000000..994989bf --- /dev/null +++ b/.github/workflows/bench-regression.yml @@ -0,0 +1,44 @@ +on: + pull_request: + paths: + - "tools/crypto_primitives_bench/**" + - "key_protocol/**" + - "nssa/core/**" + - ".github/workflows/bench-regression.yml" + +permissions: + contents: read + pull-requests: write + +name: bench-regression + +jobs: + crypto-primitives: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} + # criterion-compare-action checks out the base branch in a second + # 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: + branchName: ${{ github.base_ref }} + cwd: tools/crypto_primitives_bench + benchName: primitives + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 858a43c9..49ceaab9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,10 @@ on: - "**.md" - "!.github/workflows/*.yml" +permissions: + contents: read + pull-requests: read + name: General jobs: @@ -19,7 +23,7 @@ jobs: steps: - uses: actions/checkout@v5 with: - ref: ${{ github.head_ref }} + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - name: Install nightly toolchain for rustfmt run: rustup install nightly --profile minimal --component rustfmt @@ -32,7 +36,7 @@ jobs: steps: - uses: actions/checkout@v5 with: - ref: ${{ github.head_ref }} + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - name: Install taplo-cli run: cargo install --locked taplo-cli @@ -45,7 +49,7 @@ jobs: steps: - uses: actions/checkout@v5 with: - ref: ${{ github.head_ref }} + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - name: Install active toolchain run: rustup install @@ -61,7 +65,7 @@ jobs: steps: - uses: actions/checkout@v5 with: - ref: ${{ github.head_ref }} + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - name: Install cargo-deny run: cargo install --locked cargo-deny @@ -77,7 +81,7 @@ jobs: steps: - uses: actions/checkout@v5 with: - ref: ${{ github.head_ref }} + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - uses: ./.github/actions/install-system-deps @@ -90,6 +94,12 @@ jobs: - 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' }} + - name: Lint workspace env: RISC0_SKIP_BUILD: "1" @@ -106,7 +116,7 @@ jobs: steps: - uses: actions/checkout@v5 with: - ref: ${{ github.head_ref }} + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - uses: ./.github/actions/install-system-deps @@ -119,6 +129,12 @@ jobs: - 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' }} + - name: Install nextest run: cargo install --locked cargo-nextest @@ -126,15 +142,16 @@ jobs: env: RISC0_DEV_MODE: "1" RUST_LOG: "info" - run: cargo nextest run --workspace --exclude integration_tests + run: cargo nextest run --workspace --exclude integration_tests --all-features - integration-tests: + integration-tests-prebuild: runs-on: ubuntu-latest - timeout-minutes: 60 + outputs: + targets: ${{ steps.discover-targets.outputs.targets }} steps: - uses: actions/checkout@v5 with: - ref: ${{ github.head_ref }} + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - uses: ./.github/actions/install-system-deps @@ -147,6 +164,75 @@ jobs: - 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' }} + + - name: Install nextest + run: cargo install --locked cargo-nextest + + - 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 + + - name: Upload integration test archive + uses: actions/upload-artifact@v4 + with: + name: integration-tests-archive + path: integration-tests.tar.zst + + - 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 + echo "No integration test targets were discovered." >&2 + exit 1 + fi + + echo "targets=$targets_json" >> "$GITHUB_OUTPUT" + echo "Discovered integration targets: $targets_json" + + integration-tests: + needs: integration-tests-prebuild + runs-on: ubuntu-latest + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + target: ${{ fromJson(needs.integration-tests-prebuild.outputs.targets) }} + name: integration-tests (${{ matrix.target }}) + 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 + + - uses: ./.github/actions/install-logos-blockchain-circuits + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install active toolchain + run: rustup install + + - name: Download integration test archive + uses: actions/download-artifact@v4 + with: + name: integration-tests-archive + - name: Install nextest run: cargo install --locked cargo-nextest @@ -154,45 +240,15 @@ jobs: env: RISC0_DEV_MODE: "1" RUST_LOG: "info" - run: cargo nextest run -p integration_tests -- --skip tps_test --skip indexer - - # # TODO: Bring this back once we find the source of the errors. - # # - # integration-tests-indexer: - # runs-on: ubuntu-latest - # timeout-minutes: 60 - # steps: - # - uses: actions/checkout@v5 - # with: - # ref: ${{ github.head_ref }} - - # - 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: Install nextest - # run: cargo install --locked cargo-nextest - - # - name: Run tests - # env: - # RISC0_DEV_MODE: "1" - # RUST_LOG: "info" - # run: cargo nextest run -p integration_tests indexer -- --skip tps_test + run: cargo nextest run --archive-file integration-tests.tar.zst -E "binary(${{ matrix.target }})" valid-proof-test: runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 90 steps: - uses: actions/checkout@v5 with: - ref: ${{ github.head_ref }} + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - uses: ./.github/actions/install-system-deps @@ -205,6 +261,12 @@ jobs: - 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' }} + - name: Test valid proof env: RUST_LOG: "info" @@ -218,12 +280,18 @@ jobs: steps: - uses: actions/checkout@v5 with: - ref: ${{ github.head_ref }} + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} - uses: ./.github/actions/install-risc0 + - 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 just + run: cargo install --locked just - name: Build artifacts run: just build-artifacts diff --git a/.github/workflows/publish_images.yml b/.github/workflows/publish_images.yml index 5076a430..dbf6a68d 100644 --- a/.github/workflows/publish_images.yml +++ b/.github/workflows/publish_images.yml @@ -50,7 +50,7 @@ jobs: type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - type=sha,prefix={{branch}}- + type=sha,prefix=sha- type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..41bb535d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +# Contributing + +We're glad you're interested in contributing to Logos Execution Zone! + +This document describes the guidelines for contributing to the project. We will be updating it as we grow and we figure out what works best for us. + +If you have any questions, come say hi to our [Discord](https://discord.gg/tGJwgGrSPN)! + +## Commit title format + +We use [Conventional Commits](https://www.conventionalcommits.org/). + +Use: +- `type(scope): description` +- `type(scope)!: description` for breaking changes + +Allowed `type` values: +- `feat` +- `fix` +- `chore` +- `docs` +- `test` +- `refactor` +- `perf` +- `build` +- `ci` +- `revert` + +Examples: +- `feat(nssa): add private PDA support` +- `fix(wallet): correct fee calculation` +- `feat(nssa)!: rename AccountId::from((prog, seed)) to AccountId::for_public_pda` + +Breaking changes: +- Mark with `!` in the title. + +`CHANGELOG.md` is generated from these markers on every `v*` tag via `git-cliff`, and GitHub Releases are created from the same content. + +## Pull requests + +PR titles should follow the same Conventional Commits format: +- `type(scope): description` +- `type(scope)!: description` for breaking changes + +Before marking a PR as ready for review: +- Fill out the PR template. + +Breaking changes in PRs: +- Optionally add a `BREAKING CHANGE:` footer in the PR body with migration notes. + +Before merging a PR, consider squashing non-meaningful commits. E.g.: + +``` +- refactor(wallet): move user keys to a separate module +- revert(wallet): revert "refactor(wallet): move user keys to a separate module" +``` + +Could be squashed to an empty commit if they belong to the same PR. + +## Branch workflow + +When bringing your feature branch up to date, prefer rebasing on top of `main`. + +- Preferred: `git rebase main` +- Avoid: `git merge main` in feature branches + +This keeps commit history cleaner and makes reviews easier. + +## Useful commands + +We have [`Justfile`](./Justfile) which contains some useful utilities which may help you. + +To list all of them run the command: `just`. + +Any change to our core crates may invalidate our RISC0 [`artifacts`](./artifacts/), in that case you're required to run `just build-artifacts` to update them. + +## AI-assisted contributions + +AI tools are allowed for drafting code, docs, tests, and review suggestions. + +Requirements: +- A human author is fully responsible for all submitted code and text. +- The person opening the PR must review, verify, and be able to explain every change. +- Do not open PRs automatically via AI agents or bots. Automatic AI-created PRs are not allowed. diff --git a/Cargo.lock b/Cargo.lock index cf582227..3d7d2921 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -126,6 +135,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.21" @@ -503,7 +518,7 @@ dependencies = [ "ark-ff 0.4.2", "ark-std 0.4.0", "tracing", - "tracing-subscriber", + "tracing-subscriber 0.2.25", ] [[package]] @@ -515,7 +530,7 @@ dependencies = [ "ark-ff 0.5.0", "ark-std 0.5.0", "tracing", - "tracing-subscriber", + "tracing-subscriber 0.2.25", ] [[package]] @@ -628,10 +643,55 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "astral-tokio-tar" +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c23f3af104b40a3430ccb90ed5f7bd877a8dc5c26fc92fde51a22b40890dcf9" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "asn1_der" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4858a9d740c5007a9069007c3b4e91152d0506f13c1b31dd49051fd537656156" + +[[package]] +name = "astral-tokio-tar" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb50a7aae84a03bf55b067832bc376f4961b790c97e64d3eacee97d389b90277" dependencies = [ "filetime", "futures-core", @@ -677,6 +737,36 @@ dependencies = [ "serde", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + [[package]] name = "async-lock" version = "3.4.2" @@ -694,6 +784,17 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -727,6 +828,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "asynchronous-codec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + [[package]] name = "ata_core" version = "0.1.0" @@ -760,6 +874,29 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attohttpc" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9a9bf8b79a749ee0b911b91b671cc2b6c670bdbc7e3dfd537576ddc94bb2a2" +dependencies = [ + "http 0.2.12", + "log", + "url", +] + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http 1.4.0", + "log", + "url", +] + [[package]] name = "attribute-derive" version = "0.10.5" @@ -790,6 +927,13 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "authenticated_transfer_core" +version = "0.1.0" +dependencies = [ + "serde", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -806,7 +950,7 @@ dependencies = [ "axum-core 0.4.5", "bytes", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -840,7 +984,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -875,7 +1019,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "mime", @@ -894,7 +1038,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.0", "http-body", "http-body-util", "mime", @@ -905,6 +1049,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers 0.3.0", + "tokio", +] + [[package]] name = "base-x" version = "0.2.11" @@ -957,24 +1112,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bedrock_client" -version = "0.1.0" -dependencies = [ - "anyhow", - "common", - "futures", - "humantime-serde", - "log", - "logos-blockchain-chain-broadcast-service", - "logos-blockchain-chain-service", - "logos-blockchain-common-http-client", - "logos-blockchain-core", - "reqwest", - "serde", - "tokio-retry", -] - [[package]] name = "bincode" version = "1.3.3" @@ -1101,7 +1238,7 @@ dependencies = [ "futures-util", "hex", "home", - "http", + "http 1.4.0", "http-body-util", "hyper", "hyper-named-pipe", @@ -1111,7 +1248,7 @@ dependencies = [ "log", "num", "pin-project-lite", - "rand 0.9.2", + "rand 0.9.3", "rustls", "rustls-native-certs", "rustls-pki-types", @@ -1137,7 +1274,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" dependencies = [ "prost 0.14.3", - "prost-types", + "prost-types 0.14.3", "tonic", "tonic-prost", "ureq", @@ -1159,19 +1296,6 @@ dependencies = [ "time", ] -[[package]] -name = "bonsai-sdk" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a381a5f681e536070483826412fcfcd6f6637921717c6aa0a3759926899ee9c2" -dependencies = [ - "duplicate", - "maybe-async", - "reqwest", - "serde", - "thiserror 2.0.18", -] - [[package]] name = "borsh" version = "1.6.0" @@ -1296,6 +1420,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cbindgen" version = "0.29.2" @@ -1354,17 +1484,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "cfg_eval" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "chacha20" version = "0.10.0" @@ -1374,6 +1493,7 @@ dependencies = [ "cfg-if", "cipher 0.5.1", "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -1390,6 +1510,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1462,6 +1609,14 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "clock_core" +version = "0.1.0" +dependencies = [ + "borsh", + "nssa_core", +] + [[package]] name = "cobs" version = "0.3.0" @@ -1509,8 +1664,10 @@ name = "common" version = "0.1.0" dependencies = [ "anyhow", + "authenticated_transfer_core", "base64 0.22.1", "borsh", + "clock_core", "hex", "log", "logos-blockchain-common-http-client", @@ -1651,6 +1808,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "convert_case" version = "0.11.0" @@ -1706,15 +1872,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - [[package]] name = "cpp_demangle" version = "0.4.5" @@ -1751,12 +1908,56 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools 0.13.0", +] + [[package]] name = "critical-section" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1820,6 +2021,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "crypto_primitives_bench" +version = "0.1.0" +dependencies = [ + "criterion", + "key_protocol", + "nssa_core", + "rand 0.8.5", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1857,6 +2068,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "cycle_bench" +version = "0.1.0" +dependencies = [ + "amm_core", + "anyhow", + "ata_core", + "authenticated_transfer_core", + "borsh", + "clap", + "clock_core", + "criterion", + "nssa", + "nssa_core", + "risc0-zkvm", + "serde", + "serde_json", + "token_core", +] + [[package]] name = "darling" version = "0.20.11" @@ -1964,6 +2195,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint 0.4.6", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2053,6 +2298,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -2114,10 +2360,21 @@ dependencies = [ ] [[package]] -name = "docker-compose-types" -version = "0.22.0" +name = "dlopen2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edb75a85449fd9c34d9fb3376c6208ec4115d2ca43b965175a52d71349ecab8" +checksum = "09b4f5f101177ff01b8ec4ecc81eead416a8aa42819a2869311b3420fa114ffa" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "docker-compose-types" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea51e75cfa9371c4d760270c3da13516d7206121d668c1fbdd6fd83d1782b0f" dependencies = [ "derive_builder", "indexmap 2.13.0", @@ -2169,15 +2426,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" [[package]] -name = "duplicate" -version = "2.0.1" +name = "dtoa" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e92f10a49176cbffacaedabfaa11d51db1ea0f80a83c26e1873b43cd1742c24" -dependencies = [ - "heck", - "proc-macro2", - "proc-macro2-diagnostics", -] +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" [[package]] name = "dyn-clone" @@ -2206,6 +2458,7 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ + "pkcs8", "serde", "signature", ] @@ -2307,6 +2560,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "enum-map" version = "2.7.3" @@ -2490,14 +2755,22 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "faucet_core" +version = "0.1.0" +dependencies = [ + "nssa_core", + "serde", +] + [[package]] name = "ferroid" -version = "0.8.9" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb330bbd4cb7a5b9f559427f06f98a4f853a137c8298f3bd3f8ca57663e21986" +checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" dependencies = [ "portable-atomic", - "rand 0.9.2", + "rand 0.10.1", "web-time", ] @@ -2574,15 +2847,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" @@ -2590,7 +2854,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]] @@ -2604,12 +2868,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" @@ -2646,6 +2904,16 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-bounded" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91f328e7fb845fc832912fb6a34f40cf6d1888c92f974d1893a54e97b5ff542e" +dependencies = [ + "futures-timer", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -2679,6 +2947,16 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -2690,6 +2968,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "futures-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" +dependencies = [ + "futures-io", + "rustls", + "rustls-pki-types", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -2708,7 +2997,7 @@ version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" dependencies = [ - "gloo-timers", + "gloo-timers 0.2.6", "send_wrapper 0.4.0", ] @@ -2812,6 +3101,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", "wasm-bindgen", @@ -2854,7 +3144,7 @@ dependencies = [ "futures-core", "futures-sink", "gloo-utils", - "http", + "http 1.4.0", "js-sys", "pin-project", "serde", @@ -2877,6 +3167,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gloo-utils" version = "0.2.0" @@ -2918,7 +3220,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap 2.13.0", "slab", "tokio", @@ -2926,6 +3228,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hash32" version = "0.2.1" @@ -2966,6 +3279,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", + "equivalent", "foldhash", ] @@ -2975,6 +3289,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "hashlink" version = "0.10.0" @@ -3004,6 +3327,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -3031,6 +3360,59 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" +[[package]] +name = "hex_fmt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b07f60793ff0a4d9cef0f18e63b5357e06209987153a64648c972c1e5aff336f" + +[[package]] +name = "hickory-proto" +version = "0.25.0-alpha.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d00147af6310f4392a31680db52a3ed45a2e0f68eb18e8c3fe5537ecc96d9e2" +dependencies = [ + "async-recursion", + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.3", + "socket2 0.5.10", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.0-alpha.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5762f69ebdbd4ddb2e975cd24690bf21fe6b2604039189c26acddbc427f12887" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.3", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -3064,6 +3446,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hostname" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +dependencies = [ + "libc", + "match_cfg", + "winapi", +] + [[package]] name = "html-escape" version = "0.2.13" @@ -3073,6 +3466,17 @@ dependencies = [ "utf8-width", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -3090,7 +3494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -3101,7 +3505,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.0", "http-body", "pin-project-lite", ] @@ -3176,7 +3580,7 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", + "http 1.4.0", "http-body", "httparse", "httpdate", @@ -3209,7 +3613,7 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", + "http 1.4.0", "hyper", "hyper-util", "log", @@ -3234,22 +3638,6 @@ 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" @@ -3260,19 +3648,17 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", + "http 1.4.0", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", - "system-configuration", + "socket2 0.6.3", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -3428,6 +3814,81 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "if-watch" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c02a5161c313f0cbdbadc511611893584a10a7b6153cb554bdf83ddce99ec2" +dependencies = [ + "async-io", + "core-foundation 0.9.4", + "fnv", + "futures", + "if-addrs", + "ipnet", + "log", + "netlink-packet-core 0.8.1", + "netlink-packet-route 0.28.0", + "netlink-proto", + "netlink-sys", + "rtnetlink", + "system-configuration 0.7.0", + "tokio", + "windows", +] + +[[package]] +name = "igd-next" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b0d7d4541def58a37bf8efc559683f21edce7c82f0d866c93ac21f7e098f93" +dependencies = [ + "async-trait", + "attohttpc 0.24.1", + "bytes", + "futures", + "http 1.4.0", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.8.5", + "tokio", + "url", + "xmltree", +] + +[[package]] +name = "igd-next" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97" +dependencies = [ + "async-trait", + "attohttpc 0.30.1", + "bytes", + "futures", + "http 1.4.0", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.9.3", + "tokio", + "url", + "xmltree", +] + [[package]] name = "include_bytes_aligned" version = "0.1.4" @@ -3440,13 +3901,14 @@ version = "0.1.0" dependencies = [ "anyhow", "async-stream", - "bedrock_client", + "authenticated_transfer_core", "borsh", "common", "futures", "humantime-serde", "log", "logos-blockchain-core", + "logos-blockchain-zone-sdk", "nssa", "nssa_core", "serde", @@ -3458,6 +3920,22 @@ dependencies = [ "url", ] +[[package]] +name = "indexer_ffi" +version = "0.1.0" +dependencies = [ + "anyhow", + "cbindgen", + "indexer_service", + "indexer_service_protocol", + "indexer_service_rpc", + "jsonrpsee", + "log", + "nssa", + "tokio", + "url", +] + [[package]] name = "indexer_service" version = "0.1.0" @@ -3540,6 +4018,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -3558,33 +4045,51 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "integration_bench" +version = "0.1.0" +dependencies = [ + "anyhow", + "borsh", + "clap", + "common", + "indexer_service_rpc", + "jsonrpsee", + "nssa", + "sequencer_service_rpc", + "serde", + "serde_json", + "test_fixtures", + "tokio", + "wallet", +] + [[package]] name = "integration_tests" version = "0.1.0" dependencies = [ "anyhow", "ata_core", + "authenticated_transfer_core", "bytesize", "common", - "env_logger", - "futures", + "faucet_core", "hex", - "indexer_service", + "indexer_ffi", + "indexer_service_protocol", "indexer_service_rpc", "key_protocol", "log", "nssa", "nssa_core", "sequencer_core", - "sequencer_service", "sequencer_service_rpc", "serde_json", "tempfile", - "testcontainers", - "testnet_initial_state", + "test_fixtures", "token_core", "tokio", - "url", + "vault_core", "wallet", "wallet-ffi", ] @@ -3604,6 +4109,19 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.3", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3787,7 +4305,7 @@ dependencies = [ "futures-channel", "futures-util", "gloo-net", - "http", + "http 1.4.0", "jsonrpsee-core", "pin-project", "rustls", @@ -3812,13 +4330,13 @@ dependencies = [ "bytes", "futures-timer", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "jsonrpsee-types", "parking_lot", "pin-project", - "rand 0.9.2", + "rand 0.9.3", "rustc-hash", "serde", "serde_json", @@ -3873,7 +4391,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c51b7c290bb68ce3af2d029648148403863b982f138484a73f02a9dd52dbd7f" dependencies = [ "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -3899,7 +4417,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc88ff4688e43cc3fa9883a8a95c6fa27aa2e76c96e610b737b6554d650d7fd5" dependencies = [ - "http", + "http 1.4.0", "serde", "serde_json", "thiserror 2.0.18", @@ -3923,7 +4441,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b6fceceeb05301cc4c065ab3bd2fa990d41ff4eb44e4ca1b30fa99c057c3e79" dependencies = [ - "http", + "http 1.4.0", "jsonrpsee-client-transport", "jsonrpsee-core", "jsonrpsee-types", @@ -3962,6 +4480,7 @@ dependencies = [ "aes-gcm", "anyhow", "base58", + "bincode", "bip39", "common", "hex", @@ -3976,6 +4495,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "keycard_wallet" +version = "0.1.0" +dependencies = [ + "log", + "nssa", + "pyo3", + "serde", + "serde_json", +] + [[package]] name = "lazy-regex" version = "3.6.0" @@ -4035,7 +4565,7 @@ dependencies = [ "oco_ref", "or_poisoned", "paste", - "rand 0.9.2", + "rand 0.9.3", "reactive_graph", "rustc-hash", "rustc_version", @@ -4278,18 +4808,401 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libp2p" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72dc443ddd0254cb49a794ed6b6728400ee446a0f7ab4a07d0209ee98de20e9" +dependencies = [ + "bytes", + "either", + "futures", + "futures-timer", + "getrandom 0.2.17", + "libp2p-allow-block-list", + "libp2p-autonat", + "libp2p-connection-limits", + "libp2p-core", + "libp2p-dns", + "libp2p-gossipsub", + "libp2p-identify", + "libp2p-identity", + "libp2p-kad", + "libp2p-mdns", + "libp2p-metrics", + "libp2p-quic", + "libp2p-swarm", + "libp2p-tcp", + "libp2p-upnp", + "multiaddr", + "pin-project", + "rw-stream-sink", + "thiserror 2.0.18", +] + +[[package]] +name = "libp2p-allow-block-list" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38944b7cb981cc93f2f0fb411ff82d0e983bd226fbcc8d559639a3a73236568b" +dependencies = [ + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", +] + +[[package]] +name = "libp2p-autonat" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e297bfc6cabb70c6180707f8fa05661b77ecb9cb67e8e8e1c469301358fa21d0" +dependencies = [ + "async-trait", + "asynchronous-codec", + "either", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-request-response", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "rand 0.8.5", + "rand_core 0.6.4", + "thiserror 2.0.18", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-connection-limits" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efe9323175a17caa8a2ed4feaf8a548eeef5e0b72d03840a0eab4bcb0210ce1c" +dependencies = [ + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", +] + +[[package]] +name = "libp2p-core" +version = "0.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "249128cd37a2199aff30a7675dffa51caf073b51aa612d2f544b19932b9aebca" +dependencies = [ + "either", + "fnv", + "futures", + "futures-timer", + "libp2p-identity", + "multiaddr", + "multihash", + "multistream-select", + "parking_lot", + "pin-project", + "quick-protobuf", + "rand 0.8.5", + "rw-stream-sink", + "thiserror 2.0.18", + "tracing", + "unsigned-varint 0.8.0", + "web-time", +] + +[[package]] +name = "libp2p-dns" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b780a1150214155b0ed1cdf09fbd2e1b0442604f9146a431d1b21d23eef7bd7" +dependencies = [ + "async-trait", + "futures", + "hickory-resolver", + "libp2p-core", + "libp2p-identity", + "parking_lot", + "smallvec", + "tracing", +] + +[[package]] +name = "libp2p-gossipsub" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d558548fa3b5a8e9b66392f785921e363c57c05dcadfda4db0d41ae82d313e4a" +dependencies = [ + "async-channel", + "asynchronous-codec", + "base64 0.22.1", + "byteorder", + "bytes", + "either", + "fnv", + "futures", + "futures-timer", + "getrandom 0.2.17", + "hashlink 0.9.1", + "hex_fmt", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "prometheus-client", + "quick-protobuf", + "quick-protobuf-codec", + "rand 0.8.5", + "regex", + "serde", + "sha2", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-identify" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c06862544f02d05d62780ff590cc25a75f5c2b9df38ec7a370dcae8bb873cf" +dependencies = [ + "asynchronous-codec", + "either", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "smallvec", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "libp2p-identity" version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0c7892c221730ba55f7196e98b0b8ba5e04b4155651736036628e9f73ed6fc3" dependencies = [ + "asn1_der", "bs58", + "ed25519-dalek", "hkdf", + "k256", "multihash", + "quick-protobuf", + "rand 0.8.5", + "serde", "sha2", "thiserror 2.0.18", "tracing", + "zeroize", +] + +[[package]] +name = "libp2p-kad" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bab0466a27ebe955bcbc27328fae5429c5b48c915fd6174931414149802ec23" +dependencies = [ + "asynchronous-codec", + "bytes", + "either", + "fnv", + "futures", + "futures-bounded", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "quick-protobuf", + "quick-protobuf-codec", + "rand 0.8.5", + "serde", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tracing", + "uint", + "web-time", +] + +[[package]] +name = "libp2p-mdns" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d0ba095e1175d797540e16b62e7576846b883cb5046d4159086837b36846cc" +dependencies = [ + "futures", + "hickory-proto", + "if-watch", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand 0.8.5", + "smallvec", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-metrics" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce58c64292e87af624fcb86465e7dd8342e46a388d71e8fec0ab37ee789630a" +dependencies = [ + "futures", + "libp2p-core", + "libp2p-gossipsub", + "libp2p-identify", + "libp2p-identity", + "libp2p-kad", + "libp2p-swarm", + "pin-project", + "prometheus-client", + "web-time", +] + +[[package]] +name = "libp2p-quic" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41432a159b00424a0abaa2c80d786cddff81055ac24aa127e0cf375f7858d880" +dependencies = [ + "futures", + "futures-timer", + "if-watch", + "libp2p-core", + "libp2p-identity", + "libp2p-tls", + "quinn", + "rand 0.8.5", + "ring", + "rustls", + "socket2 0.5.10", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-request-response" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "548fe44a80ff275d400f1b26b090d441d83ef73efabbeb6415f4ce37e5aed865" +dependencies = [ + "async-trait", + "futures", + "futures-bounded", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand 0.8.5", + "smallvec", + "tracing", +] + +[[package]] +name = "libp2p-stream" +version = "0.3.0-alpha" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826716f1ee125895f1fb44911413cba023485b552ff96c7a2159bd037ac619bb" +dependencies = [ + "futures", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm", + "rand 0.8.5", + "tracing", +] + +[[package]] +name = "libp2p-swarm" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803399b4b6f68adb85e63ab573ac568154b193e9a640f03e0f2890eabbcb37f8" +dependencies = [ + "either", + "fnv", + "futures", + "futures-timer", + "libp2p-core", + "libp2p-identity", + "libp2p-swarm-derive", + "lru", + "multistream-select", + "once_cell", + "rand 0.8.5", + "smallvec", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "libp2p-swarm-derive" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206e0aa0ebe004d778d79fb0966aa0de996c19894e2c0605ba2f8524dd4443d8" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "libp2p-tcp" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65346fb4d36035b23fec4e7be4c320436ba53537ce9b6be1d1db1f70c905cad0" +dependencies = [ + "futures", + "futures-timer", + "if-watch", + "libc", + "libp2p-core", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "libp2p-tls" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96ff65a82e35375cbc31ebb99cacbbf28cb6c4fefe26bf13756ddcf708d40080" +dependencies = [ + "futures", + "futures-rustls", + "libp2p-core", + "libp2p-identity", + "rcgen", + "ring", + "rustls", + "rustls-webpki", + "thiserror 2.0.18", + "x509-parser", + "yasna", +] + +[[package]] +name = "libp2p-upnp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d457b9ecceb66e7199f049926fad447f1f17f040e8d29d690c086b4cab8ed14a" +dependencies = [ + "futures", + "futures-timer", + "igd-next 0.15.1", + "libp2p-core", + "libp2p-swarm", + "tokio", + "tracing", ] [[package]] @@ -4369,8 +5282,8 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "logos-blockchain-blend-crypto" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "blake2", "logos-blockchain-groth16", @@ -4383,11 +5296,12 @@ dependencies = [ [[package]] name = "logos-blockchain-blend-message" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "blake2", "derivative", + "hex", "itertools 0.14.0", "logos-blockchain-blend-crypto", "logos-blockchain-blend-proofs", @@ -4405,8 +5319,8 @@ dependencies = [ [[package]] name = "logos-blockchain-blend-proofs" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "ed25519-dalek", "generic-array 1.3.5", @@ -4419,12 +5333,13 @@ dependencies = [ "num-bigint 0.4.6", "serde", "thiserror 1.0.69", + "zeroize", ] [[package]] name = "logos-blockchain-chain-broadcast-service" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "async-trait", "derivative", @@ -4439,11 +5354,12 @@ dependencies = [ [[package]] name = "logos-blockchain-chain-service" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "async-trait", "bytes", + "derivative", "futures", "logos-blockchain-chain-broadcast-service", "logos-blockchain-core", @@ -4455,8 +5371,8 @@ dependencies = [ "logos-blockchain-services-utils", "logos-blockchain-storage-service", "logos-blockchain-time-service", + "logos-blockchain-tracing", "logos-blockchain-utils", - "num-bigint 0.4.6", "overwatch", "serde", "serde_with", @@ -4469,8 +5385,8 @@ dependencies = [ [[package]] name = "logos-blockchain-circuits-prover" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "logos-blockchain-circuits-utils", "tempfile", @@ -4478,16 +5394,16 @@ dependencies = [ [[package]] name = "logos-blockchain-circuits-utils" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "dirs", ] [[package]] name = "logos-blockchain-common-http-client" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "futures", "hex", @@ -4506,8 +5422,8 @@ dependencies = [ [[package]] name = "logos-blockchain-core" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "ark-ff 0.4.2", "bincode", @@ -4520,6 +5436,7 @@ dependencies = [ "logos-blockchain-cryptarchia-engine", "logos-blockchain-groth16", "logos-blockchain-key-management-system-keys", + "logos-blockchain-mmr", "logos-blockchain-poc", "logos-blockchain-pol", "logos-blockchain-poseidon2", @@ -4528,6 +5445,7 @@ dependencies = [ "multiaddr", "nom 8.0.0", "num-bigint 0.4.6", + "rpds", "serde", "strum", "thiserror 1.0.69", @@ -4536,10 +5454,9 @@ dependencies = [ [[package]] name = "logos-blockchain-cryptarchia-engine" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ - "cfg_eval", "logos-blockchain-pol", "logos-blockchain-utils", "serde", @@ -4552,11 +5469,13 @@ dependencies = [ [[package]] name = "logos-blockchain-cryptarchia-sync" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "bytes", "futures", + "libp2p", + "libp2p-stream", "logos-blockchain-core", "logos-blockchain-cryptarchia-engine", "rand 0.8.5", @@ -4569,8 +5488,8 @@ dependencies = [ [[package]] name = "logos-blockchain-groth16" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "ark-bn254 0.4.0", "ark-ec 0.4.2", @@ -4587,12 +5506,13 @@ dependencies = [ [[package]] name = "logos-blockchain-http-api-common" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "axum 0.7.9", "logos-blockchain-core", "logos-blockchain-key-management-system-keys", + "logos-blockchain-tracing", "serde", "serde_json", "serde_with", @@ -4601,8 +5521,8 @@ dependencies = [ [[package]] name = "logos-blockchain-key-management-system-keys" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "async-trait", "bytes", @@ -4627,8 +5547,8 @@ dependencies = [ [[package]] name = "logos-blockchain-key-management-system-macros" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "proc-macro2", "quote", @@ -4637,8 +5557,8 @@ dependencies = [ [[package]] name = "logos-blockchain-key-management-system-operators" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "async-trait", "logos-blockchain-blend-proofs", @@ -4653,13 +5573,14 @@ dependencies = [ [[package]] name = "logos-blockchain-key-management-system-service" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "async-trait", "log", "logos-blockchain-key-management-system-keys", "logos-blockchain-key-management-system-operators", + "logos-blockchain-tracing", "overwatch", "serde", "thiserror 2.0.18", @@ -4669,8 +5590,8 @@ dependencies = [ [[package]] name = "logos-blockchain-ledger" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "derivative", "logos-blockchain-blend-crypto", @@ -4680,6 +5601,7 @@ dependencies = [ "logos-blockchain-cryptarchia-engine", "logos-blockchain-groth16", "logos-blockchain-key-management-system-keys", + "logos-blockchain-mmr", "logos-blockchain-pol", "logos-blockchain-utils", "logos-blockchain-utxotree", @@ -4687,20 +5609,66 @@ dependencies = [ "rand 0.8.5", "rpds", "serde", + "serde_arrays", "thiserror 1.0.69", "tracing", ] +[[package]] +name = "logos-blockchain-libp2p" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" +dependencies = [ + "async-trait", + "backon", + "blake2", + "either", + "futures", + "hex", + "igd-next 0.16.2", + "libp2p", + "logos-blockchain-cryptarchia-sync", + "logos-blockchain-utils", + "multiaddr", + "natpmp", + "netdev", + "num_enum", + "rand 0.8.5", + "serde", + "serde_with", + "thiserror 1.0.69", + "tokio", + "tracing", + "zerocopy", +] + +[[package]] +name = "logos-blockchain-mmr" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" +dependencies = [ + "ark-ff 0.4.2", + "logos-blockchain-groth16", + "logos-blockchain-poseidon2", + "rpds", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "logos-blockchain-network-service" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "async-trait", "futures", "logos-blockchain-core", "logos-blockchain-cryptarchia-sync", + "logos-blockchain-libp2p", + "logos-blockchain-tracing", "overwatch", + "rand 0.8.5", + "rand_chacha 0.3.1", "serde", "tokio", "tokio-stream", @@ -4709,8 +5677,8 @@ dependencies = [ [[package]] name = "logos-blockchain-poc" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "logos-blockchain-circuits-prover", "logos-blockchain-circuits-utils", @@ -4725,8 +5693,8 @@ dependencies = [ [[package]] name = "logos-blockchain-pol" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "astro-float", "logos-blockchain-circuits-prover", @@ -4744,8 +5712,8 @@ dependencies = [ [[package]] name = "logos-blockchain-poq" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "logos-blockchain-circuits-prover", "logos-blockchain-circuits-utils", @@ -4761,8 +5729,8 @@ dependencies = [ [[package]] name = "logos-blockchain-poseidon2" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "ark-bn254 0.4.0", "ark-ff 0.4.2", @@ -4772,8 +5740,8 @@ dependencies = [ [[package]] name = "logos-blockchain-services-utils" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "async-trait", "futures", @@ -4787,14 +5755,15 @@ dependencies = [ [[package]] name = "logos-blockchain-storage-service" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "async-trait", "bytes", "futures", "logos-blockchain-core", "logos-blockchain-cryptarchia-engine", + "logos-blockchain-tracing", "overwatch", "serde", "thiserror 1.0.69", @@ -4804,14 +5773,18 @@ dependencies = [ [[package]] name = "logos-blockchain-time-service" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "async-trait", "futures", "log", "logos-blockchain-cryptarchia-engine", + "logos-blockchain-tracing", + "logos-blockchain-utils", "overwatch", + "serde", + "serde_with", "sntpc", "thiserror 2.0.18", "time", @@ -4820,10 +5793,34 @@ dependencies = [ "tracing", ] +[[package]] +name = "logos-blockchain-tracing" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" +dependencies = [ + "opentelemetry", + "opentelemetry-appender-tracing", + "opentelemetry-http", + "opentelemetry-otlp", + "opentelemetry-semantic-conventions", + "opentelemetry_sdk", + "rand 0.8.5", + "serde", + "tokio", + "tonic", + "tracing", + "tracing-appender", + "tracing-gelf", + "tracing-loki", + "tracing-opentelemetry", + "tracing-subscriber 0.3.23", + "url", +] + [[package]] name = "logos-blockchain-utils" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "async-trait", "blake2", @@ -4839,8 +5836,8 @@ dependencies = [ [[package]] name = "logos-blockchain-utxotree" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "ark-ff 0.4.2", "logos-blockchain-groth16", @@ -4853,16 +5850,16 @@ dependencies = [ [[package]] name = "logos-blockchain-witness-generator" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "tempfile", ] [[package]] name = "logos-blockchain-zksign" -version = "0.2.1" -source = "git+https://github.com/logos-blockchain/logos-blockchain.git#81dbb4517aa466358ed425d92fad7d45a0c419fd" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" dependencies = [ "logos-blockchain-circuits-prover", "logos-blockchain-circuits-utils", @@ -4876,6 +5873,45 @@ dependencies = [ "tracing", ] +[[package]] +name = "logos-blockchain-zone-sdk" +version = "0.1.2" +source = "git+https://github.com/logos-blockchain/logos-blockchain.git?rev=ee281a447d95a951752461ee0a6e88eb4a0f17cf#ee281a447d95a951752461ee0a6e88eb4a0f17cf" +dependencies = [ + "async-trait", + "futures", + "logos-blockchain-common-http-client", + "logos-blockchain-core", + "logos-blockchain-groth16", + "logos-blockchain-key-management-system-service", + "rand 0.8.5", + "reqwest", + "rpds", + "serde", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "loki-api" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdc38a304f59a03e6efa3876766a48c70a766a93f88341c3fff4212834b8e327" +dependencies = [ + "prost 0.13.5", + "prost-types 0.13.5", +] + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -4989,6 +6025,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "match_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.7.3" @@ -5011,17 +6062,6 @@ dependencies = [ "rawpointer", ] -[[package]] -name = "maybe-async" -version = "0.2.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "memchr" version = "2.8.0" @@ -5037,6 +6077,15 @@ dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mempool" version = "0.1.0" @@ -5065,7 +6114,7 @@ dependencies = [ "bitflags 2.11.0", "block", "core-graphics-types", - "foreign-types 0.5.0", + "foreign-types", "log", "objc", "paste", @@ -5114,6 +6163,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "multer" version = "3.1.0" @@ -5123,7 +6189,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http", + "http 1.4.0", "httparse", "memchr", "mime", @@ -5146,7 +6212,8 @@ dependencies = [ "percent-encoding", "serde", "static_assertions", - "unsigned-varint", + "unsigned-varint 0.8.0", + "url", ] [[package]] @@ -5163,29 +6230,39 @@ dependencies = [ [[package]] name = "multihash" -version = "0.19.3" +version = "0.19.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b430e7953c29dd6a09afc29ff0bb69c6e306329ee6794700aee27b76a1aea8d" +checksum = "89ace881e3f514092ce9efbcb8f413d0ad9763860b828981c2de51ddc666936c" dependencies = [ - "core2", - "unsigned-varint", + "no_std_io2", + "serde", + "unsigned-varint 0.8.0", ] [[package]] -name = "native-tls" -version = "0.2.18" +name = "multistream-select" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +checksum = "ea0df8e5eec2298a62b326ee4f0d7fe1a6b90a09dfcf9df37b38f947a8c42f19" dependencies = [ - "libc", + "bytes", + "futures", "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", + "pin-project", + "smallvec", + "unsigned-varint 0.7.2", +] + +[[package]] +name = "natpmp" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77366fa8ce34e2e1322dd97da65f11a62f451bd3daae8be6993c00800f61dd07" +dependencies = [ + "async-trait", + "cc", + "netdev", + "tokio", ] [[package]] @@ -5204,6 +6281,108 @@ dependencies = [ "rayon", ] +[[package]] +name = "netdev" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f901362e84cd407be6f8cd9d3a46bccf09136b095792785401ea7d283c79b91d" +dependencies = [ + "dlopen2", + "ipnet", + "libc", + "netlink-packet-core 0.7.0", + "netlink-packet-route 0.17.1", + "netlink-sys", + "once_cell", + "system-configuration 0.6.1", + "windows-sys 0.52.0", +] + +[[package]] +name = "netlink-packet-core" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72724faf704479d67b388da142b186f916188505e7e0b26719019c525882eda4" +dependencies = [ + "anyhow", + "byteorder", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053998cea5a306971f88580d0829e90f270f940befd7cf928da179d4187a5a66" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "byteorder", + "libc", + "netlink-packet-core 0.7.0", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-route" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445" +dependencies = [ + "bitflags 2.11.0", + "libc", + "log", + "netlink-packet-core 0.8.1", +] + +[[package]] +name = "netlink-packet-utils" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ede8a08c71ad5a95cdd0e4e52facd37190977039a4704eb82a283f713747d34" +dependencies = [ + "anyhow", + "byteorder", + "paste", + "thiserror 1.0.69", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core 0.8.1", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + [[package]] name = "next_tuple" version = "0.1.0" @@ -5228,6 +6407,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a3564ce7035b1e4778d8cb6cacebb5d766b5e8fe5a75b9e441e33fb61a872c6" +dependencies = [ + "memchr", +] + [[package]] name = "no_std_strings" version = "0.1.3" @@ -5258,8 +6458,11 @@ name = "nssa" version = "0.1.0" dependencies = [ "anyhow", + "authenticated_transfer_core", "borsh", + "clock_core", "env_logger", + "faucet_core", "hex", "hex-literal 1.1.0", "k256", @@ -5295,6 +6498,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num" version = "0.4.3" @@ -5473,6 +6685,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -5485,38 +6706,18 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "opaque-debug" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "openssl" -version = "0.10.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "foreign-types 0.3.2", - "libc", - "once_cell", - "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" @@ -5524,15 +6725,95 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] -name = "openssl-sys" -version = "0.9.111" +name = "opentelemetry" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.18", +] + +[[package]] +name = "opentelemetry-appender-tracing" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6a1ac5ca3accf562b8c306fa8483c85f4390f768185ab775f242f7fe8fdcc2" +dependencies = [ + "opentelemetry", + "tracing", + "tracing-core", + "tracing-subscriber 0.3.23", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http 1.4.0", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" +dependencies = [ + "http 1.4.0", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost 0.14.3", + "reqwest", + "thiserror 2.0.18", + "tokio", + "tonic", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost 0.14.3", + "tonic", + "tonic-prost", +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e62e29dfe041afb8ed2a6c9737ab57db4907285d999ef8ad3a59092a36bdc846" + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.3", + "thiserror 2.0.18", + "tokio", + "tokio-stream", ] [[package]] @@ -5585,6 +6866,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "parking" version = "2.2.1" @@ -5651,6 +6942,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -5731,6 +7032,48 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "polyval" version = "0.6.2" @@ -5897,11 +7240,38 @@ dependencies = [ "amm_program", "ata_core", "ata_program", + "authenticated_transfer_core", + "clock_core", + "faucet_core", "nssa_core", "risc0-zkvm", "serde", "token_core", "token_program", + "vault_core", +] + +[[package]] +name = "prometheus-client" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504ee9ff529add891127c4827eb481bd69dc0ebc72e9a682e187db4caa60c3ca" +dependencies = [ + "dtoa", + "itoa", + "parking_lot", + "prometheus-client-derive-encode", +] + +[[package]] +name = "prometheus-client-derive-encode" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -5912,7 +7282,7 @@ checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" dependencies = [ "bitflags 2.11.0", "num-traits", - "rand 0.9.2", + "rand 0.9.3", "rand_chacha 0.9.0", "rand_xorshift", "unarray", @@ -5945,7 +7315,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -5958,12 +7328,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + [[package]] name = "prost-types" version = "0.14.3" @@ -5987,6 +7366,91 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "pyo3" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "quick-protobuf" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d6da84cc204722a989e01ba2f6e1e276e190f22263d0cb6ce8526fcdb0d2e1f" +dependencies = [ + "byteorder", +] + +[[package]] +name = "quick-protobuf-codec" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15a0580ab32b169745d7a39db2ba969226ca16738931be152a3209b409de2474" +dependencies = [ + "asynchronous-codec", + "bytes", + "quick-protobuf", + "thiserror 1.0.69", + "unsigned-varint 0.8.0", +] + [[package]] name = "quinn" version = "0.11.9" @@ -5995,12 +7459,13 @@ checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases", + "futures-io", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -6016,7 +7481,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.3", "ring", "rustc-hash", "rustls", @@ -6037,7 +7502,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.59.0", ] @@ -6104,14 +7569,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -6150,6 +7626,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -6185,6 +7667,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "reactive_graph" version = "0.2.13" @@ -6325,22 +7820,17 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", - "futures-channel", "futures-core", "futures-util", "h2", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -6351,7 +7841,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -6365,6 +7854,12 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "rfc6979" version = "0.4.0" @@ -6408,7 +7903,7 @@ dependencies = [ "elf", "lazy_static", "postcard", - "rand 0.9.2", + "rand 0.9.3", "risc0-zkp", "risc0-zkvm-platform", "ruint", @@ -6504,7 +7999,7 @@ dependencies = [ "hex", "lazy-regex", "metal", - "rand 0.9.2", + "rand 0.9.3", "rayon", "risc0-circuit-recursion-sys", "risc0-core", @@ -6548,7 +8043,7 @@ dependencies = [ "num-traits", "paste", "postcard", - "rand 0.9.2", + "rand 0.9.3", "rayon", "ringbuffer", "risc0-binfmt", @@ -6655,7 +8150,7 @@ dependencies = [ "ndarray", "parking_lot", "paste", - "rand 0.9.2", + "rand 0.9.3", "rand_core 0.9.5", "rayon", "risc0-core", @@ -6676,7 +8171,6 @@ dependencies = [ "addr2line", "anyhow", "bincode", - "bonsai-sdk", "borsh", "bytemuck", "bytes", @@ -6693,7 +8187,7 @@ dependencies = [ "num-traits", "object", "prost 0.13.5", - "rand 0.9.2", + "rand 0.9.3", "rayon", "risc0-binfmt", "risc0-build", @@ -6749,6 +8243,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" +[[package]] +name = "rpassword" +version = "7.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac5b223d9738ef56e0b98305410be40fa0941bf6036c56f1506751e43552d64" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + [[package]] name = "rpds" version = "1.2.0" @@ -6781,7 +8286,7 @@ dependencies = [ "futures", "light-poseidon", "quote", - "rand 0.9.2", + "rand 0.9.3", "syn 1.0.109", "thiserror 2.0.18", "tiny-keccak", @@ -6823,6 +8328,34 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "rtnetlink" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b960d5d873a75b5be9761b1e73b146f52dddcd27bac75263f40fba686d4d7b5" +dependencies = [ + "futures-channel", + "futures-util", + "log", + "netlink-packet-core 0.8.1", + "netlink-packet-route 0.28.0", + "netlink-proto", + "netlink-sys", + "nix", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "ruint" version = "1.17.2" @@ -6832,7 +8365,7 @@ dependencies = [ "borsh", "proptest", "rand 0.8.5", - "rand 0.9.2", + "rand 0.9.3", "ruint-macro", "serde_core", "valuable", @@ -6866,6 +8399,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" @@ -6934,7 +8476,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs 0.26.11", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6945,9 +8487,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -6969,6 +8511,17 @@ dependencies = [ "twox-hash", ] +[[package]] +name = "rw-stream-sink" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c9026ff5d2f23da5e45bbc283f156383001bfb09c4e44256d02c1a685fe9a1" +dependencies = [ + "futures", + "pin-project", + "static_assertions", +] + [[package]] name = "ryu" version = "1.0.23" @@ -7132,17 +8685,17 @@ name = "sequencer_core" version = "0.1.0" dependencies = [ "anyhow", - "bedrock_client", "borsh", "bytesize", "chrono", "common", + "faucet_core", "futures", "humantime-serde", - "jsonrpsee", "log", "logos-blockchain-core", "logos-blockchain-key-management-system-service", + "logos-blockchain-zone-sdk", "mempool", "nssa", "nssa_core", @@ -7151,9 +8704,11 @@ dependencies = [ "serde_json", "storage", "tempfile", + "test_program_methods", "testnet_initial_state", "tokio", "url", + "vault_core", ] [[package]] @@ -7167,7 +8722,6 @@ dependencies = [ "common", "env_logger", "futures", - "indexer_service_rpc", "jsonrpsee", "log", "mempool", @@ -7215,6 +8769,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_arrays" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a16b99c5ea4fe3daccd14853ad260ec00ea043b2708d1fd1da3106dcd8d9df" +dependencies = [ + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -7389,7 +8952,7 @@ dependencies = [ "const_format", "futures", "gloo-net", - "http", + "http 1.4.0", "http-body-util", "hyper", "inventory", @@ -7463,6 +9026,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -7516,6 +9088,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "snap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" + [[package]] name = "sntpc" version = "0.5.2" @@ -7526,6 +9104,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -7545,7 +9133,7 @@ dependencies = [ "base64 0.22.1", "bytes", "futures", - "http", + "http 1.4.0", "httparse", "log", "rand 0.8.5", @@ -7715,6 +9303,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -7768,12 +9367,24 @@ dependencies = [ "web-sys", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tap" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[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.26.0" @@ -7820,6 +9431,34 @@ dependencies = [ "test-case-core", ] +[[package]] +name = "test_fixtures" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytesize", + "common", + "env_logger", + "futures", + "indexer_service", + "jsonrpsee", + "key_protocol", + "log", + "nssa", + "nssa_core", + "sequencer_core", + "sequencer_service", + "sequencer_service_rpc", + "serde", + "serde_json", + "tempfile", + "testcontainers", + "tokio", + "url", + "vault_core", + "wallet", +] + [[package]] name = "test_program_methods" version = "0.1.0" @@ -7831,15 +9470,19 @@ dependencies = [ name = "test_programs" version = "0.1.0" dependencies = [ + "authenticated_transfer_core", + "clock_core", + "faucet_core", "nssa_core", "risc0-zkvm", + "serde", ] [[package]] name = "testcontainers" -version = "0.27.2" +version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bd36b06a2a6c0c3c81a83be1ab05fe86460d054d4d51bf513bc56b3e15bdc22" +checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e" dependencies = [ "astral-tokio-tar", "async-trait", @@ -7851,7 +9494,7 @@ dependencies = [ "etcetera", "ferroid", "futures", - "http", + "http 1.4.0", "itertools 0.14.0", "log", "memchr", @@ -7919,6 +9562,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "throw_error" version = "0.3.1" @@ -7978,6 +9630,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -8022,7 +9684,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -8038,27 +9700,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-retry" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f57eb36ecbe0fc510036adff84824dd3c24bb781e21bfa67b69d556aa85214f" -dependencies = [ - "pin-project", - "rand 0.8.5", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -8219,7 +9860,7 @@ dependencies = [ "base64 0.22.1", "bytes", "h2", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -8227,7 +9868,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "socket2", + "socket2 0.6.3", "sync_wrapper", "tokio", "tokio-stream", @@ -8277,7 +9918,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "http-range-header", @@ -8319,6 +9960,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.18", + "time", + "tracing-subscriber 0.3.23", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -8350,6 +10003,82 @@ dependencies = [ "tracing", ] +[[package]] +name = "tracing-gelf" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c0170f1bf67b749d4377c2da1d99d6e722600051ee53870cfb6f618611e29e" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "hostname", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tracing-core", + "tracing-futures", + "tracing-subscriber 0.3.23", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-loki" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3beec919fbdf99d719de8eda6adae3281f8a5b71ae40431f44dc7423053d34" +dependencies = [ + "loki-api", + "reqwest", + "serde", + "serde_json", + "snap", + "tokio", + "tokio-stream", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", + "tracing-subscriber 0.3.23", + "url", +] + +[[package]] +name = "tracing-opentelemetry" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc" +dependencies = [ + "js-sys", + "opentelemetry", + "smallvec", + "tracing", + "tracing-core", + "tracing-subscriber 0.3.23", + "web-time", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.2.25" @@ -8359,6 +10088,24 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + [[package]] name = "triomphe" version = "0.1.15" @@ -8379,10 +10126,10 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", - "rand 0.9.2", + "rand 0.9.3", "sha1", "thiserror 2.0.18", "utf-8", @@ -8460,6 +10207,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "uint" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "909988d098b2f738727b161a106cfc7cab00c539c2687a8836f8e565976fb53e" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + [[package]] name = "unarray" version = "0.1.4" @@ -8505,6 +10264,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "unit-prefix" version = "0.5.2" @@ -8527,6 +10292,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "unsigned-varint" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6889a77d49f1f013504cec6bf97a2c730394adedaeb1deb5ea08949a50541105" + [[package]] name = "unsigned-varint" version = "0.8.0" @@ -8561,7 +10332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" dependencies = [ "base64 0.22.1", - "http", + "http 1.4.0", "httparse", "log", ] @@ -8626,6 +10397,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vault_core" +version = "0.1.0" +dependencies = [ + "nssa_core", + "risc0-zkvm", + "serde", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -8656,9 +10436,13 @@ dependencies = [ "anyhow", "async-stream", "ata_core", + "authenticated_transfer_core", "base58", + "bincode", + "bip39", "clap", "common", + "derive_more", "env_logger", "futures", "hex", @@ -8667,20 +10451,25 @@ dependencies = [ "indicatif", "itertools 0.14.0", "key_protocol", + "keycard_wallet", "log", "nssa", "nssa_core", "optfield", + "pyo3", "rand 0.8.5", + "rpassword", "sequencer_service_rpc", "serde", "serde_json", "sha2", + "tempfile", "testnet_initial_state", "thiserror 2.0.18", "token_core", "tokio", "url", + "zeroize", ] [[package]] @@ -8688,9 +10477,11 @@ name = "wallet-ffi" version = "0.1.0" dependencies = [ "cbindgen", + "key_protocol", "nssa", "nssa_core", "sequencer_service_rpc", + "serde_json", "tempfile", "tokio", "wallet", @@ -8927,6 +10718,12 @@ dependencies = [ "safe_arch", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -8958,6 +10755,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -8971,6 +10789,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -8999,6 +10828,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -9095,6 +10934,15 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -9309,6 +11157,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -9319,6 +11184,21 @@ dependencies = [ "rustix", ] +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + [[package]] name = "xxhash-rust" version = "0.8.15" @@ -9333,7 +11213,7 @@ checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" dependencies = [ "arraydeque", "encoding_rs", - "hashlink", + "hashlink 0.10.0", ] [[package]] @@ -9342,6 +11222,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index c2853089..f4a981ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,10 +15,14 @@ members = [ "nssa/core", "programs/amm/core", "programs/amm", + "programs/clock/core", "programs/token/core", "programs/token", "programs/associated_token_account/core", "programs/associated_token_account", + "programs/authenticated_transfer/core", + "programs/faucet/core", + "programs/vault/core", "sequencer/core", "sequencer/service", "sequencer/service/protocol", @@ -35,8 +39,13 @@ members = [ "examples/program_deployment", "examples/program_deployment/methods", "examples/program_deployment/methods/guest", - "bedrock_client", "testnet_initial_state", + "indexer/ffi", + "keycard_wallet", + "test_fixtures", + "tools/cycle_bench", + "tools/crypto_primitives_bench", + "tools/integration_bench", ] [workspace.dependencies] @@ -56,15 +65,21 @@ indexer_service_protocol = { path = "indexer/service/protocol" } indexer_service_rpc = { path = "indexer/service/rpc" } wallet = { path = "wallet" } wallet-ffi = { path = "wallet-ffi", default-features = false } +indexer_ffi = { path = "indexer/ffi" } +clock_core = { path = "programs/clock/core" } token_core = { path = "programs/token/core" } token_program = { path = "programs/token" } amm_core = { path = "programs/amm/core" } amm_program = { path = "programs/amm" } ata_core = { path = "programs/associated_token_account/core" } ata_program = { path = "programs/associated_token_account" } +authenticated_transfer_core = { path = "programs/authenticated_transfer/core" } +faucet_core = { path = "programs/faucet/core" } +vault_core = { path = "programs/vault/core" } test_program_methods = { path = "test_program_methods" } -bedrock_client = { path = "bedrock_client" } testnet_initial_state = { path = "testnet_initial_state" } +keycard_wallet = { path = "keycard_wallet" } +test_fixtures = { path = "test_fixtures" } tokio = { version = "1.50", features = [ "net", @@ -73,9 +88,10 @@ tokio = { version = "1.50", features = [ "fs", ] } tokio-util = "0.7.18" -risc0-zkvm = { version = "3.0.5", features = ['std'] } +risc0-zkvm = { version = "3.0.5", default-features = false, features = ['std'] } risc0-build = "3.0.5" anyhow = "1.0.98" +derive_more = "2.1.1" num_cpus = "1.13.1" openssl = { version = "0.10", features = ["vendored"] } openssl-probe = { version = "0.1.2" } @@ -117,12 +133,14 @@ url = { version = "2.5.4", features = ["serde"] } tokio-retry = "0.3.0" schemars = "1.2" async-stream = "0.3.6" +criterion = { version = "0.8", features = ["html_reports"] } -logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git" } -logos-blockchain-key-management-system-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git" } -logos-blockchain-core = { git = "https://github.com/logos-blockchain/logos-blockchain.git" } -logos-blockchain-chain-broadcast-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git" } -logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git" } +logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" } +logos-blockchain-key-management-system-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" } +logos-blockchain-core = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" } +logos-blockchain-chain-broadcast-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" } +logos-blockchain-chain-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" } +logos-blockchain-zone-sdk = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "ee281a447d95a951752461ee0a6e88eb4a0f17cf" } rocksdb = { version = "0.24.0", default-features = false, features = [ "snappy", @@ -142,6 +160,7 @@ actix-web = { version = "4.13.0", default-features = false, features = [ ] } clap = { version = "4.5.42", features = ["derive", "env"] } reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] } +pyo3 = { version = "0.24", features = ["auto-initialize"] } # Profile for leptos WASM release builds [profile.wasm-release] @@ -150,6 +169,14 @@ opt-level = 'z' lto = true codegen-units = 1 +# 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] +debug = "line-tables-only" + +[profile.test] +debug = "line-tables-only" + [workspace.lints.rust] warnings = "deny" diff --git a/Justfile b/Justfile index ac003a15..7af964a6 100644 --- a/Justfile +++ b/Justfile @@ -23,6 +23,12 @@ test: @echo "🧪 Running tests" RISC0_DEV_MODE=1 cargo nextest run --no-fail-fast +# Run criterion benches: fast crypto primitives, then the slow PPE verify (real proving setup). +bench: + @echo "📊 Running criterion benches" + cargo bench -p crypto_primitives_bench --bench primitives + cargo bench -p cycle_bench --features ppe --bench verify + # Run Bedrock node in docker [working-directory: 'bedrock'] run-bedrock: diff --git a/artifacts/program_methods/amm.bin b/artifacts/program_methods/amm.bin index b6867713..2635cab1 100644 Binary files a/artifacts/program_methods/amm.bin and b/artifacts/program_methods/amm.bin differ diff --git a/artifacts/program_methods/associated_token_account.bin b/artifacts/program_methods/associated_token_account.bin index bcd528f1..155f2ba8 100644 Binary files a/artifacts/program_methods/associated_token_account.bin and b/artifacts/program_methods/associated_token_account.bin differ diff --git a/artifacts/program_methods/authenticated_transfer.bin b/artifacts/program_methods/authenticated_transfer.bin index 142397f7..812a5ced 100644 Binary files a/artifacts/program_methods/authenticated_transfer.bin and b/artifacts/program_methods/authenticated_transfer.bin differ diff --git a/artifacts/program_methods/bridge.bin b/artifacts/program_methods/bridge.bin new file mode 100644 index 00000000..58a6cf32 Binary files /dev/null and b/artifacts/program_methods/bridge.bin differ diff --git a/artifacts/program_methods/clock.bin b/artifacts/program_methods/clock.bin index c3f8af15..62e16cb9 100644 Binary files a/artifacts/program_methods/clock.bin and b/artifacts/program_methods/clock.bin differ diff --git a/artifacts/program_methods/faucet.bin b/artifacts/program_methods/faucet.bin new file mode 100644 index 00000000..9b1fbbe4 Binary files /dev/null and b/artifacts/program_methods/faucet.bin differ diff --git a/artifacts/program_methods/genesis_supply_account.bin b/artifacts/program_methods/genesis_supply_account.bin new file mode 100644 index 00000000..c377a1e6 Binary files /dev/null and b/artifacts/program_methods/genesis_supply_account.bin differ diff --git a/artifacts/program_methods/genesis_supply_private_account.bin b/artifacts/program_methods/genesis_supply_private_account.bin new file mode 100644 index 00000000..9d6aa313 Binary files /dev/null and b/artifacts/program_methods/genesis_supply_private_account.bin differ diff --git a/artifacts/program_methods/pinata.bin b/artifacts/program_methods/pinata.bin index 280a834f..6c8849e6 100644 Binary files a/artifacts/program_methods/pinata.bin and b/artifacts/program_methods/pinata.bin differ diff --git a/artifacts/program_methods/pinata_token.bin b/artifacts/program_methods/pinata_token.bin index 4cd06c56..baf619e3 100644 Binary files a/artifacts/program_methods/pinata_token.bin and b/artifacts/program_methods/pinata_token.bin differ diff --git a/artifacts/program_methods/privacy_preserving_circuit.bin b/artifacts/program_methods/privacy_preserving_circuit.bin index 55880e41..16c5a34b 100644 Binary files a/artifacts/program_methods/privacy_preserving_circuit.bin and b/artifacts/program_methods/privacy_preserving_circuit.bin differ diff --git a/artifacts/program_methods/token.bin b/artifacts/program_methods/token.bin index b9c82387..d7e11257 100644 Binary files a/artifacts/program_methods/token.bin and b/artifacts/program_methods/token.bin differ diff --git a/artifacts/program_methods/vault.bin b/artifacts/program_methods/vault.bin new file mode 100644 index 00000000..b2cdea20 Binary files /dev/null and b/artifacts/program_methods/vault.bin differ diff --git a/artifacts/test_program_methods/auth_asserting_noop.bin b/artifacts/test_program_methods/auth_asserting_noop.bin new file mode 100644 index 00000000..7116dbbb Binary files /dev/null and b/artifacts/test_program_methods/auth_asserting_noop.bin differ diff --git a/artifacts/test_program_methods/auth_transfer_proxy.bin b/artifacts/test_program_methods/auth_transfer_proxy.bin new file mode 100644 index 00000000..3dc5a5ec Binary files /dev/null and b/artifacts/test_program_methods/auth_transfer_proxy.bin differ diff --git a/artifacts/test_program_methods/burner.bin b/artifacts/test_program_methods/burner.bin index a740bdb8..5f8209b0 100644 Binary files a/artifacts/test_program_methods/burner.bin and b/artifacts/test_program_methods/burner.bin differ diff --git a/artifacts/test_program_methods/chain_caller.bin b/artifacts/test_program_methods/chain_caller.bin index 112ca113..e2164be6 100644 Binary files a/artifacts/test_program_methods/chain_caller.bin and b/artifacts/test_program_methods/chain_caller.bin differ diff --git a/artifacts/test_program_methods/chain_caller_pda_drop.bin b/artifacts/test_program_methods/chain_caller_pda_drop.bin new file mode 100644 index 00000000..91b42aa2 Binary files /dev/null and b/artifacts/test_program_methods/chain_caller_pda_drop.bin differ diff --git a/artifacts/test_program_methods/changer_claimer.bin b/artifacts/test_program_methods/changer_claimer.bin index a130510b..c99ca2c0 100644 Binary files a/artifacts/test_program_methods/changer_claimer.bin and b/artifacts/test_program_methods/changer_claimer.bin differ diff --git a/artifacts/test_program_methods/claimer.bin b/artifacts/test_program_methods/claimer.bin index 41a5cb3b..dc44c14f 100644 Binary files a/artifacts/test_program_methods/claimer.bin and b/artifacts/test_program_methods/claimer.bin differ diff --git a/artifacts/test_program_methods/clock_chain_caller.bin b/artifacts/test_program_methods/clock_chain_caller.bin new file mode 100644 index 00000000..19a43bbc Binary files /dev/null and b/artifacts/test_program_methods/clock_chain_caller.bin differ diff --git a/artifacts/test_program_methods/data_changer.bin b/artifacts/test_program_methods/data_changer.bin index 3dddebe1..98d3ea85 100644 Binary files a/artifacts/test_program_methods/data_changer.bin and b/artifacts/test_program_methods/data_changer.bin differ diff --git a/artifacts/test_program_methods/extra_output.bin b/artifacts/test_program_methods/extra_output.bin index 1d682ec3..fdd80cbd 100644 Binary files a/artifacts/test_program_methods/extra_output.bin and b/artifacts/test_program_methods/extra_output.bin differ diff --git a/artifacts/test_program_methods/faucet_chain_caller.bin b/artifacts/test_program_methods/faucet_chain_caller.bin new file mode 100644 index 00000000..656e7ab5 Binary files /dev/null and b/artifacts/test_program_methods/faucet_chain_caller.bin differ diff --git a/artifacts/test_program_methods/flash_swap_callback.bin b/artifacts/test_program_methods/flash_swap_callback.bin new file mode 100644 index 00000000..b4e35f49 Binary files /dev/null and b/artifacts/test_program_methods/flash_swap_callback.bin differ diff --git a/artifacts/test_program_methods/flash_swap_initiator.bin b/artifacts/test_program_methods/flash_swap_initiator.bin new file mode 100644 index 00000000..ae9d945f Binary files /dev/null and b/artifacts/test_program_methods/flash_swap_initiator.bin differ diff --git a/artifacts/test_program_methods/group_pda_spender.bin b/artifacts/test_program_methods/group_pda_spender.bin new file mode 100644 index 00000000..16efb8a4 Binary files /dev/null and b/artifacts/test_program_methods/group_pda_spender.bin differ diff --git a/artifacts/test_program_methods/malicious_authorization_changer.bin b/artifacts/test_program_methods/malicious_authorization_changer.bin index c68496ab..828813e3 100644 Binary files a/artifacts/test_program_methods/malicious_authorization_changer.bin and b/artifacts/test_program_methods/malicious_authorization_changer.bin differ diff --git a/artifacts/test_program_methods/malicious_caller_program_id.bin b/artifacts/test_program_methods/malicious_caller_program_id.bin new file mode 100644 index 00000000..bd996ff8 Binary files /dev/null and b/artifacts/test_program_methods/malicious_caller_program_id.bin differ diff --git a/artifacts/test_program_methods/malicious_injector.bin b/artifacts/test_program_methods/malicious_injector.bin new file mode 100644 index 00000000..ae65ed93 Binary files /dev/null and b/artifacts/test_program_methods/malicious_injector.bin differ diff --git a/artifacts/test_program_methods/malicious_launderer.bin b/artifacts/test_program_methods/malicious_launderer.bin new file mode 100644 index 00000000..50a20251 Binary files /dev/null and b/artifacts/test_program_methods/malicious_launderer.bin differ diff --git a/artifacts/test_program_methods/malicious_self_program_id.bin b/artifacts/test_program_methods/malicious_self_program_id.bin new file mode 100644 index 00000000..3cb564f2 Binary files /dev/null and b/artifacts/test_program_methods/malicious_self_program_id.bin differ diff --git a/artifacts/test_program_methods/minter.bin b/artifacts/test_program_methods/minter.bin index ffd29461..d90292f8 100644 Binary files a/artifacts/test_program_methods/minter.bin and b/artifacts/test_program_methods/minter.bin differ diff --git a/artifacts/test_program_methods/missing_output.bin b/artifacts/test_program_methods/missing_output.bin index a2bbecd8..dfca513a 100644 Binary files a/artifacts/test_program_methods/missing_output.bin and b/artifacts/test_program_methods/missing_output.bin differ diff --git a/artifacts/test_program_methods/modified_transfer.bin b/artifacts/test_program_methods/modified_transfer.bin index b44b1233..d4698fdf 100644 Binary files a/artifacts/test_program_methods/modified_transfer.bin and b/artifacts/test_program_methods/modified_transfer.bin differ diff --git a/artifacts/test_program_methods/nonce_changer.bin b/artifacts/test_program_methods/nonce_changer.bin index e006fc75..df524beb 100644 Binary files a/artifacts/test_program_methods/nonce_changer.bin and b/artifacts/test_program_methods/nonce_changer.bin differ diff --git a/artifacts/test_program_methods/noop.bin b/artifacts/test_program_methods/noop.bin index da811f60..be0f301b 100644 Binary files a/artifacts/test_program_methods/noop.bin and b/artifacts/test_program_methods/noop.bin differ diff --git a/artifacts/test_program_methods/pda_claimer.bin b/artifacts/test_program_methods/pda_claimer.bin new file mode 100644 index 00000000..81a5de3e Binary files /dev/null and b/artifacts/test_program_methods/pda_claimer.bin differ diff --git a/artifacts/test_program_methods/pda_fund_spend_proxy.bin b/artifacts/test_program_methods/pda_fund_spend_proxy.bin new file mode 100644 index 00000000..c649bb1f Binary files /dev/null and b/artifacts/test_program_methods/pda_fund_spend_proxy.bin differ diff --git a/artifacts/test_program_methods/pda_spend_proxy.bin b/artifacts/test_program_methods/pda_spend_proxy.bin new file mode 100644 index 00000000..9366e66a Binary files /dev/null and b/artifacts/test_program_methods/pda_spend_proxy.bin differ diff --git a/artifacts/test_program_methods/pinata_cooldown.bin b/artifacts/test_program_methods/pinata_cooldown.bin new file mode 100644 index 00000000..b65ce430 Binary files /dev/null and b/artifacts/test_program_methods/pinata_cooldown.bin differ diff --git a/artifacts/test_program_methods/private_pda_claimer.bin b/artifacts/test_program_methods/private_pda_claimer.bin new file mode 100644 index 00000000..5a64c66d Binary files /dev/null and b/artifacts/test_program_methods/private_pda_claimer.bin differ diff --git a/artifacts/test_program_methods/private_pda_delegator.bin b/artifacts/test_program_methods/private_pda_delegator.bin new file mode 100644 index 00000000..8e1857f8 Binary files /dev/null and b/artifacts/test_program_methods/private_pda_delegator.bin differ diff --git a/artifacts/test_program_methods/private_pda_spender.bin b/artifacts/test_program_methods/private_pda_spender.bin new file mode 100644 index 00000000..b9848210 Binary files /dev/null and b/artifacts/test_program_methods/private_pda_spender.bin differ diff --git a/artifacts/test_program_methods/program_owner_changer.bin b/artifacts/test_program_methods/program_owner_changer.bin index 3963873e..0ea6687a 100644 Binary files a/artifacts/test_program_methods/program_owner_changer.bin and b/artifacts/test_program_methods/program_owner_changer.bin differ diff --git a/artifacts/test_program_methods/simple_balance_transfer.bin b/artifacts/test_program_methods/simple_balance_transfer.bin index 08db47f0..183b8ee2 100644 Binary files a/artifacts/test_program_methods/simple_balance_transfer.bin and b/artifacts/test_program_methods/simple_balance_transfer.bin differ diff --git a/artifacts/test_program_methods/time_locked_transfer.bin b/artifacts/test_program_methods/time_locked_transfer.bin new file mode 100644 index 00000000..02d776f2 Binary files /dev/null and b/artifacts/test_program_methods/time_locked_transfer.bin differ diff --git a/artifacts/test_program_methods/two_pda_claimer.bin b/artifacts/test_program_methods/two_pda_claimer.bin new file mode 100644 index 00000000..5598072f Binary files /dev/null and b/artifacts/test_program_methods/two_pda_claimer.bin differ diff --git a/artifacts/test_program_methods/validity_window.bin b/artifacts/test_program_methods/validity_window.bin index ceb5ae74..0485135f 100644 Binary files a/artifacts/test_program_methods/validity_window.bin and b/artifacts/test_program_methods/validity_window.bin differ diff --git a/artifacts/test_program_methods/validity_window_chain_caller.bin b/artifacts/test_program_methods/validity_window_chain_caller.bin index a7661f03..86d71dfb 100644 Binary files a/artifacts/test_program_methods/validity_window_chain_caller.bin and b/artifacts/test_program_methods/validity_window_chain_caller.bin differ diff --git a/bedrock/cfgsync.yaml b/bedrock/cfgsync.yaml deleted file mode 100644 index e09fe586..00000000 --- a/bedrock/cfgsync.yaml +++ /dev/null @@ -1,12 +0,0 @@ -port: 4400 -n_hosts: 4 -timeout: 10 - -# Tracing -tracing_settings: - logger: Stdout - tracing: None - filter: None - metrics: None - console: None - level: DEBUG diff --git a/bedrock/deployment-settings.yaml b/bedrock/deployment-settings.yaml new file mode 100644 index 00000000..7ef63f03 --- /dev/null +++ b/bedrock/deployment-settings.yaml @@ -0,0 +1,82 @@ +blend: + common: + num_blend_layers: 3 + minimum_network_size: 30 + protocol_name: /blend/integration-tests + data_replication_factor: 0 + core: + scheduler: + cover: + message_frequency_per_round: 1.0 + intervals_for_safety_buffer: 100 + delayer: + maximum_release_delay_in_rounds: 3 + 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 +cryptarchia: + epoch_config: + epoch_stake_distribution_stabilization: 3 + epoch_period_nonce_buffer: 3 + epoch_period_nonce_stabilization: 4 + security_param: 10 + slot_activation_coeff: + numerator: 1 + denominator: 2 + learning_rate: 0.1 + sdp_config: + service_params: + BN: + lock_period: 10 + inactivity_period: 1 + retention_period: 1 + timestamp: 0 + min_stake: + threshold: 1 + timestamp: 0 + gossipsub_protocol: /integration/logos-blockchain/cryptarchia/proto/1.0.0 + genesis_block: + header: + version: Bedrock + parent_block: '0000000000000000000000000000000000000000000000000000000000000000' + slot: 0 + block_root: b5f8787ac23674822414c70eea15d842da38f2e806ede1a73cf7b5cf0277da07 + proof_of_leadership: + proof: '0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' + entropy_contribution: '0000000000000000000000000000000000000000000000000000000000000000' + leader_key: '0000000000000000000000000000000000000000000000000000000000000000' + voucher_cm: '0000000000000000000000000000000000000000000000000000000000000000' + signature: '00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' + transactions: + - mantle_tx: + ops: + - opcode: 0 + payload: + inputs: [] + outputs: + - value: 1 + pk: d204000000000000000000000000000000000000000000000000000000000000 + - value: 100 + pk: '2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26' + - value: 1 + pk: ed266e6e887b9b97059dc1aa1b7b2e19b934291753c6336a163fe4ebaa28e717 + - opcode: 17 + payload: + channel_id: '0000000000000000000000000000000000000000000000000000000000000000' + inscription: '67656e65736973' + parent: '0000000000000000000000000000000000000000000000000000000000000000' + signer: '0000000000000000000000000000000000000000000000000000000000000000' + execution_gas_price: 0 + storage_gas_price: 0 + ops_proofs: + - !Ed25519Sig '00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' + - !Ed25519Sig '00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' +time: + slot_duration: '1.0' + chain_start_time: PLACEHOLDER_CHAIN_START_TIME +mempool: + pubsub_topic: mantle_e2e_tests diff --git a/bedrock/docker-compose.yml b/bedrock/docker-compose.yml index 4f85bf25..e16e505b 100644 --- a/bedrock/docker-compose.yml +++ b/bedrock/docker-compose.yml @@ -1,46 +1,12 @@ services: - cfgsync: - image: ghcr.io/logos-blockchain/logos-blockchain@sha256:000982e751dfd346ca5346b8025c685fc3abc585079c59cde3bde7fd63100657 - volumes: - - ./scripts:/etc/logos-blockchain/scripts - - ./cfgsync.yaml:/etc/logos-blockchain/cfgsync.yaml:z - entrypoint: /etc/logos-blockchain/scripts/run_cfgsync.sh - logos-blockchain-node-0: - image: ghcr.io/logos-blockchain/logos-blockchain@sha256:000982e751dfd346ca5346b8025c685fc3abc585079c59cde3bde7fd63100657 + image: ghcr.io/logos-blockchain/logos-blockchain@sha256:9f1829dea335c56f6ff68ae37ea872ed5313b96b69e8ffe143c02b7217de85fc ports: - "${PORT:-8080}:18080/tcp" volumes: - ./scripts:/etc/logos-blockchain/scripts - ./kzgrs_test_params:/kzgrs_test_params:z - depends_on: - - cfgsync - entrypoint: /etc/logos-blockchain/scripts/run_logos_blockchain_node.sh - - logos-blockchain-node-1: - image: ghcr.io/logos-blockchain/logos-blockchain@sha256:000982e751dfd346ca5346b8025c685fc3abc585079c59cde3bde7fd63100657 - volumes: - - ./scripts:/etc/logos-blockchain/scripts - - ./kzgrs_test_params:/kzgrs_test_params:z - depends_on: - - cfgsync - entrypoint: /etc/logos-blockchain/scripts/run_logos_blockchain_node.sh - - logos-blockchain-node-2: - image: ghcr.io/logos-blockchain/logos-blockchain@sha256:000982e751dfd346ca5346b8025c685fc3abc585079c59cde3bde7fd63100657 - volumes: - - ./scripts:/etc/logos-blockchain/scripts - - ./kzgrs_test_params:/kzgrs_test_params:z - depends_on: - - cfgsync - entrypoint: /etc/logos-blockchain/scripts/run_logos_blockchain_node.sh - - logos-blockchain-node-3: - image: ghcr.io/logos-blockchain/logos-blockchain@sha256:000982e751dfd346ca5346b8025c685fc3abc585079c59cde3bde7fd63100657 - volumes: - - ./scripts:/etc/logos-blockchain/scripts - - ./kzgrs_test_params:/kzgrs_test_params:z - depends_on: - - cfgsync + - ./node-config.yaml:/etc/logos-blockchain/node-config.yaml:z + - ./deployment-settings.yaml:/etc/logos-blockchain/deployment-settings.yaml:z entrypoint: /etc/logos-blockchain/scripts/run_logos_blockchain_node.sh diff --git a/bedrock/node-config.yaml b/bedrock/node-config.yaml new file mode 100644 index 00000000..2c4adc40 --- /dev/null +++ b/bedrock/node-config.yaml @@ -0,0 +1,54 @@ +blend: + non_ephemeral_signing_key_id: 86c8519f00178e9eb1fe5f4247e4bed77d4c9f6da2fb10e8a1fdd7ba6bc79fa0 + core: + zk: + secret_key_kms_id: 64249c75c2cb813578b75d05b215fc95f67cea5862fff047228183f22e63460e +cryptarchia: + service: + bootstrap: + prolonged_bootstrap_period: '1.000000000' + network: + network: + max_connected_peers_to_try_download: 16 + max_discovered_peers_to_try_download: 16 + leader: + wallet: + funding_pk: "2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26" +sdp: + wallet: + funding_pk: ed266e6e887b9b97059dc1aa1b7b2e19b934291753c6336a163fe4ebaa28e717 +kms: + backend: + keys: + 2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26: !Zk 6c645cd4636d9c4c36a37a9aeabcaa3300000000000000000000000000000000 + 64249c75c2cb813578b75d05b215fc95f67cea5862fff047228183f22e63460e: !Zk 83c851cf4436e8d2fdac33d56d2b235f66431be97e2a20bf241d431713dc720f + ed266e6e887b9b97059dc1aa1b7b2e19b934291753c6336a163fe4ebaa28e717: !Zk 7364705cd4636d9c4c36a37a9aeabcaa00000000000000000000000000000000 + 86c8519f00178e9eb1fe5f4247e4bed77d4c9f6da2fb10e8a1fdd7ba6bc79fa0: !Ed25519 5cd4636d9c4c36a37a9aeabcaa332c3ec796226af0af48a0b2e70167205af749 +wallet: + known_keys: + 2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26: "2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26" + ed266e6e887b9b97059dc1aa1b7b2e19b934291753c6336a163fe4ebaa28e717: "ed266e6e887b9b97059dc1aa1b7b2e19b934291753c6336a163fe4ebaa28e717" + voucher_master_key_id: 2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26 + +api: + backend: + listen_address: 0.0.0.0:18080 + cors_origins: [] + timeout: 30 + max_body_size: 10485760 + max_concurrent_requests: 500 + +tracing: + logger: + stdout: true + stderr: false + file: + directory: "./state/logs" + otlp: null + loki: null + gelf: null + tracing: None + filter: None + metrics: None + console: None + level: "INFO" diff --git a/bedrock/scripts/run_logos_blockchain_node.sh b/bedrock/scripts/run_logos_blockchain_node.sh index e318ab4a..ffa02e6d 100755 --- a/bedrock/scripts/run_logos_blockchain_node.sh +++ b/bedrock/scripts/run_logos_blockchain_node.sh @@ -2,12 +2,19 @@ set -e -export CFG_FILE_PATH="/config.yaml" \ - CFG_SERVER_ADDR="http://cfgsync:4400" \ - CFG_HOST_IP=$(hostname -i) \ - CFG_HOST_IDENTIFIER="validator-$(hostname -i)" \ - LOG_LEVEL="INFO" \ - POL_PROOF_DEV_MODE=true +export POL_PROOF_DEV_MODE=true -/usr/bin/logos-blockchain-cfgsync-client && \ - exec /usr/bin/logos-blockchain-node /config.yaml +# 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 diff --git a/bedrock_client/Cargo.toml b/bedrock_client/Cargo.toml deleted file mode 100644 index 2137cb74..00000000 --- a/bedrock_client/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "bedrock_client" -version = "0.1.0" -edition = "2024" -license = { workspace = true } - -[lints] -workspace = true - -[dependencies] -common.workspace = true - -reqwest.workspace = true -anyhow.workspace = true -tokio-retry.workspace = true -futures.workspace = true -log.workspace = true -serde.workspace = true -humantime-serde.workspace = true -logos-blockchain-common-http-client.workspace = true -logos-blockchain-core.workspace = true -logos-blockchain-chain-broadcast-service.workspace = true -logos-blockchain-chain-service.workspace = true diff --git a/bedrock_client/src/lib.rs b/bedrock_client/src/lib.rs deleted file mode 100644 index 4e9bfffd..00000000 --- a/bedrock_client/src/lib.rs +++ /dev/null @@ -1,121 +0,0 @@ -use std::time::Duration; - -use anyhow::{Context as _, Result}; -use common::config::BasicAuth; -use futures::{Stream, TryFutureExt as _}; -#[expect(clippy::single_component_path_imports, reason = "Satisfy machete")] -use humantime_serde; -use log::{info, warn}; -pub use logos_blockchain_chain_broadcast_service::BlockInfo; -use logos_blockchain_chain_service::CryptarchiaInfo; -pub use logos_blockchain_common_http_client::{CommonHttpClient, Error}; -pub use logos_blockchain_core::{block::Block, header::HeaderId, mantle::SignedMantleTx}; -use reqwest::{Client, Url}; -use serde::{Deserialize, Serialize}; -use tokio_retry::Retry; - -/// Fibonacci backoff retry strategy configuration. -#[derive(Debug, Copy, Clone, Serialize, Deserialize)] -pub struct BackoffConfig { - #[serde(with = "humantime_serde")] - pub start_delay: Duration, - pub max_retries: usize, -} - -impl Default for BackoffConfig { - fn default() -> Self { - Self { - start_delay: Duration::from_millis(100), - max_retries: 5, - } - } -} - -/// Simple wrapper -/// maybe extend in the future for our purposes -/// `Clone` is cheap because `CommonHttpClient` is internally reference counted (`Arc`). -#[derive(Clone)] -pub struct BedrockClient { - http_client: CommonHttpClient, - node_url: Url, - backoff: BackoffConfig, -} - -impl BedrockClient { - pub fn new(backoff: BackoffConfig, node_url: Url, auth: Option) -> Result { - info!("Creating Bedrock client with node URL {node_url}"); - let client = Client::builder() - //Add more fields if needed - .timeout(std::time::Duration::from_mins(1)) - .build() - .context("Failed to build HTTP client")?; - - let auth = auth.map(|a| { - logos_blockchain_common_http_client::BasicAuthCredentials::new(a.username, a.password) - }); - - let http_client = CommonHttpClient::new_with_client(client, auth); - Ok(Self { - http_client, - node_url, - backoff, - }) - } - - pub async fn post_transaction(&self, tx: SignedMantleTx) -> Result, Error> { - Retry::spawn(self.backoff_strategy(), || async { - match self - .http_client - .post_transaction(self.node_url.clone(), tx.clone()) - .await - { - Ok(()) => Ok(Ok(())), - Err(err) => match err { - // Retry arm. - // Retrying only reqwest errors: mainly connected to http. - Error::Request(_) => Err(err), - // Returning non-retryable error - Error::Server(_) | Error::Client(_) | Error::Url(_) => Ok(Err(err)), - }, - } - }) - .await - } - - pub async fn get_lib_stream(&self) -> Result, Error> { - self.http_client.get_lib_stream(self.node_url.clone()).await - } - - pub async fn get_block_by_id( - &self, - header_id: HeaderId, - ) -> Result>, Error> { - Retry::spawn(self.backoff_strategy(), || { - self.http_client - .get_block_by_id(self.node_url.clone(), header_id) - .inspect_err(|err| warn!("Block fetching failed with error: {err:#}")) - }) - .await - } - - pub async fn get_consensus_info(&self) -> Result { - Retry::spawn(self.backoff_strategy(), || { - self.http_client - .consensus_info(self.node_url.clone()) - .inspect_err(|err| warn!("Block fetching failed with error: {err:#}")) - }) - .await - } - - fn backoff_strategy(&self) -> impl Iterator { - let start_delay_millis = self - .backoff - .start_delay - .as_millis() - .try_into() - .expect("Start delay must be less than u64::MAX milliseconds"); - - tokio_retry::strategy::FibonacciBackoff::from_millis(start_delay_millis) - .take(self.backoff.max_retries) - } -} diff --git a/common/Cargo.toml b/common/Cargo.toml index 0ae0b220..5d8e278c 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [dependencies] nssa.workspace = true nssa_core.workspace = true +authenticated_transfer_core.workspace = true +clock_core.workspace = true anyhow.workspace = true thiserror.workspace = true diff --git a/common/src/block.rs b/common/src/block.rs index 6decc390..fbc4c9a6 100644 --- a/common/src/block.rs +++ b/common/src/block.rs @@ -1,10 +1,10 @@ use borsh::{BorshDeserialize, BorshSerialize}; -use nssa_core::{BlockId, Timestamp}; +use nssa_core::BlockId; +pub use nssa_core::Timestamp; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256, digest::FixedOutput as _}; use crate::{HashType, transaction::NSSATransaction}; - pub type MantleMsgId = [u8; 32]; pub type BlockHash = HashType; @@ -85,9 +85,20 @@ impl HashableBlockData { signing_key: &nssa::PrivateKey, bedrock_parent_id: MantleMsgId, ) -> Block { + 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 signature = nssa::Signature::new(signing_key, &data_bytes); - let hash = OwnHasher::hash(&data_bytes); + let mut bytes = Vec::with_capacity( + PREFIX + .len() + .checked_add(data_bytes.len()) + .expect("length overflow"), + ); + bytes.extend_from_slice(PREFIX); + bytes.extend_from_slice(&data_bytes); + + let hash = OwnHasher::hash(&bytes); + let signature = nssa::Signature::new(signing_key, &hash.0); Block { header: BlockHeader { block_id: self.block_id, @@ -103,11 +114,6 @@ impl HashableBlockData { bedrock_parent_id, } } - - #[must_use] - pub fn block_hash(&self) -> BlockHash { - OwnHasher::hash(&borsh::to_vec(&self).unwrap()) - } } impl From for HashableBlockData { diff --git a/common/src/test_utils.rs b/common/src/test_utils.rs index 720bd2f9..806048e1 100644 --- a/common/src/test_utils.rs +++ b/common/src/test_utils.rs @@ -3,7 +3,7 @@ use nssa::AccountId; use crate::{ HashType, block::{Block, HashableBlockData}, - transaction::NSSATransaction, + transaction::{NSSATransaction, clock_invocation}, }; // Helpers @@ -15,7 +15,7 @@ pub fn sequencer_sign_key_for_testing() -> nssa::PrivateKey { // Dummy producers -/// Produce dummy block with. +/// Produce dummy block with provided transactions + clock transaction an the end. /// /// `id` - block id, provide zero for genesis. /// @@ -26,8 +26,12 @@ pub fn sequencer_sign_key_for_testing() -> nssa::PrivateKey { pub fn produce_dummy_block( id: u64, prev_hash: Option, - transactions: Vec, + mut transactions: Vec, ) -> Block { + transactions.push(NSSATransaction::Public(clock_invocation( + id.saturating_mul(100), + ))); + let block_data = HashableBlockData { block_id: id, prev_block_hash: prev_hash.unwrap_or_default(), @@ -43,12 +47,11 @@ pub fn produce_dummy_empty_transaction() -> NSSATransaction { let program_id = nssa::program::Program::authenticated_transfer_program().id(); let account_ids = vec![]; let nonces = vec![]; - let instruction_data: u128 = 0; let message = nssa::public_transaction::Message::try_new( program_id, account_ids, nonces, - instruction_data, + authenticated_transfer_core::Instruction::Initialize, ) .unwrap(); let private_key = nssa::PrivateKey::try_new([1; 32]).unwrap(); @@ -74,7 +77,9 @@ pub fn create_transaction_native_token_transfer( program_id, account_ids, nonces, - balance_to_move, + authenticated_transfer_core::Instruction::Transfer { + amount: balance_to_move, + }, ) .unwrap(); let witness_set = nssa::public_transaction::WitnessSet::for_message(&message, &[signing_key]); diff --git a/common/src/transaction.rs b/common/src/transaction.rs index ea0b9819..21cbfd75 100644 --- a/common/src/transaction.rs +++ b/common/src/transaction.rs @@ -1,6 +1,6 @@ use borsh::{BorshDeserialize, BorshSerialize}; use log::warn; -use nssa::{AccountId, V03State}; +use nssa::{AccountId, V03State, ValidatedStateDiff}; use nssa_core::{BlockId, Timestamp}; use serde::{Deserialize, Serialize}; @@ -66,21 +66,67 @@ impl NSSATransaction { } } + /// Validates the transaction against the current state and returns the resulting diff + /// without applying it. Rejects transactions that modify clock or faucet system accounts, + /// whether directly or indirectly via chain calls. + /// + /// This check is required for all user transactions. Only sequencer transactions may bypass + /// this check. + pub fn validate_on_state( + &self, + state: &V03State, + block_id: BlockId, + timestamp: Timestamp, + ) -> Result { + let diff = match self { + Self::Public(tx) => { + ValidatedStateDiff::from_public_transaction(tx, state, block_id, timestamp) + } + Self::PrivacyPreserving(tx) => ValidatedStateDiff::from_privacy_preserving_transaction( + tx, state, block_id, timestamp, + ), + Self::ProgramDeployment(tx) => { + ValidatedStateDiff::from_program_deployment_transaction(tx, state) + } + }?; + + let public_diff = diff.public_diff(); + let touches_clock = nssa::CLOCK_PROGRAM_ACCOUNT_IDS.iter().any(|id| { + public_diff + .get(id) + .is_some_and(|post| *post != state.get_account_by_id(*id)) + }); + if touches_clock { + return Err(nssa::error::NssaError::InvalidInput( + "Transaction modifies system clock accounts".into(), + )); + } + + let faucet_id = nssa::system_faucet_account_id(); + if public_diff + .get(&faucet_id) + .is_some_and(|post| *post != state.get_account_by_id(faucet_id)) + { + return Err(nssa::error::NssaError::InvalidInput( + "Transaction modifies system faucet account".into(), + )); + } + + Ok(diff) + } + + /// Validates the transaction against the current state, rejects modifications to clock + /// system accounts, and applies the resulting diff to the state. pub fn execute_check_on_state( self, state: &mut V03State, block_id: BlockId, timestamp: Timestamp, ) -> Result { - match &self { - Self::Public(tx) => state.transition_from_public_transaction(tx, block_id, timestamp), - Self::PrivacyPreserving(tx) => { - state.transition_from_privacy_preserving_transaction(tx, block_id, timestamp) - } - Self::ProgramDeployment(tx) => state.transition_from_program_deployment_transaction(tx), - } - .inspect_err(|err| warn!("Error at transition {err:#?}"))?; - + let diff = self + .validate_on_state(state, block_id, timestamp) + .inspect_err(|err| warn!("Error at transition {err:#?}"))?; + state.apply_state_diff(diff); Ok(self) } } @@ -121,3 +167,20 @@ pub enum TransactionMalformationError { #[error("Transaction size {size} exceeds maximum allowed size of {max} bytes")] TransactionTooLarge { size: usize, max: usize }, } + +/// Returns the canonical Clock Program invocation transaction for the given block timestamp. +/// Every valid block must end with exactly one occurrence of this transaction. +#[must_use] +pub fn clock_invocation(timestamp: clock_core::Instruction) -> nssa::PublicTransaction { + let message = nssa::public_transaction::Message::try_new( + nssa::program::Program::clock().id(), + clock_core::CLOCK_PROGRAM_ACCOUNT_IDS.to_vec(), + vec![], + timestamp, + ) + .expect("Clock invocation message should always be constructable"); + nssa::PublicTransaction::new( + message, + nssa::public_transaction::WitnessSet::from_raw_parts(vec![]), + ) +} diff --git a/completions/README.md b/completions/README.md index d274774c..b12f1823 100644 --- a/completions/README.md +++ b/completions/README.md @@ -93,6 +93,12 @@ Only `Public/2gJJjtG9UivBGEhA1Jz6waZQx1cwfYupC5yvKEweHaeH` is used for completio exec zsh ``` +> **Note:** After updating the completion script, re-run step 1 to copy the new file, then rebuild the cache: +> ```sh +> cp _wallet ~/.oh-my-zsh/custom/plugins/wallet/ +> rm -rf ~/.zcompdump* && exec zsh +> ``` + ### Requirements The completion script calls `wallet account list` to dynamically fetch account IDs. Ensure the `wallet` command is in your `$PATH`. @@ -197,8 +203,7 @@ wallet account get --account-id 2. Rebuild the completion cache: ```sh - rm -f ~/.zcompdump* - exec zsh + rm -rf ~/.zcompdump* && exec zsh ``` ### Account IDs not completing diff --git a/completions/bash/wallet b/completions/bash/wallet index 57dd3636..a4d390f6 100644 --- a/completions/bash/wallet +++ b/completions/bash/wallet @@ -22,6 +22,20 @@ _wallet_complete_account_id() { fi } +# Helper function to complete account labels +_wallet_complete_account_label() { + local cur="$1" + local labels + + if command -v wallet &>/dev/null; then + labels=$(wallet account list 2>/dev/null | grep -o '\[.*\]' | sed 's/^\[//;s/\]$//') + fi + + if [[ -n "$labels" ]]; then + COMPREPLY=($(compgen -W "$labels" -- "$cur")) + fi +} + _wallet() { local cur prev words cword _init_completion 2>/dev/null || { @@ -32,7 +46,7 @@ _wallet() { cword=$COMP_CWORD } - local commands="auth-transfer chain-info account pinata token amm check-health config restore-keys deploy-program help" + local commands="auth-transfer chain-info account pinata token amm ata check-health config restore-keys deploy-program help" # Find the main command and subcommand by scanning words before the cursor. # Global options that take a value are skipped along with their argument. @@ -91,20 +105,32 @@ _wallet() { --account-id) _wallet_complete_account_id "$cur" ;; + --account-label) + _wallet_complete_account_label "$cur" + ;; *) - COMPREPLY=($(compgen -W "--account-id" -- "$cur")) + COMPREPLY=($(compgen -W "--account-id --account-label" -- "$cur")) ;; esac ;; send) case "$prev" in - --from | --to) + --from) _wallet_complete_account_id "$cur" ;; - --to-npk | --to-vpk | --amount) + --from-label) + _wallet_complete_account_label "$cur" + ;; + --to) + _wallet_complete_account_id "$cur" + ;; + --to-label) + _wallet_complete_account_label "$cur" + ;; + --to-npk | --to-vpk | --to-identifier | --amount) ;; # no specific completion *) - COMPREPLY=($(compgen -W "--from --to --to-npk --to-vpk --amount" -- "$cur")) + COMPREPLY=($(compgen -W "--from --from-label --to --to-label --to-npk --to-vpk --to-identifier --amount" -- "$cur")) ;; esac ;; @@ -147,8 +173,11 @@ _wallet() { -a | --account-id) _wallet_complete_account_id "$cur" ;; + --account-label) + _wallet_complete_account_label "$cur" + ;; *) - COMPREPLY=($(compgen -W "-r --raw -k --keys -a --account-id" -- "$cur")) + COMPREPLY=($(compgen -W "-r --raw -k --keys -a --account-id --account-label" -- "$cur")) ;; esac ;; @@ -158,11 +187,11 @@ _wallet() { sync-private) ;; # no options new) - # `account new` is itself a subcommand: public | private + # `account new` is itself a subcommand: public | private-accounts-key local new_subcmd="" for ((i = subcmd_idx + 1; i < cword; i++)); do case "${words[$i]}" in - public | private) + public | private-accounts-key) new_subcmd="${words[$i]}" break ;; @@ -170,13 +199,26 @@ _wallet() { done if [[ -z "$new_subcmd" ]]; then - COMPREPLY=($(compgen -W "public private" -- "$cur")) + COMPREPLY=($(compgen -W "public private-accounts-key" -- "$cur")) else - case "$prev" in - --cci | -l | --label) - ;; # no specific completion - *) - COMPREPLY=($(compgen -W "--cci -l --label" -- "$cur")) + case "$new_subcmd" in + public) + case "$prev" in + --cci | -l | --label) + ;; # no specific completion + *) + COMPREPLY=($(compgen -W "--cci -l --label" -- "$cur")) + ;; + esac + ;; + private-accounts-key) + case "$prev" in + --cci) + ;; # no specific completion + *) + COMPREPLY=($(compgen -W "--cci" -- "$cur")) + ;; + esac ;; esac fi @@ -186,10 +228,13 @@ _wallet() { -a | --account-id) _wallet_complete_account_id "$cur" ;; + --account-label) + _wallet_complete_account_label "$cur" + ;; -l | --label) ;; # no specific completion for label value *) - COMPREPLY=($(compgen -W "-a --account-id -l --label" -- "$cur")) + COMPREPLY=($(compgen -W "-a --account-id --account-label -l --label" -- "$cur")) ;; esac ;; @@ -206,8 +251,11 @@ _wallet() { --to) _wallet_complete_account_id "$cur" ;; + --to-label) + _wallet_complete_account_label "$cur" + ;; *) - COMPREPLY=($(compgen -W "--to" -- "$cur")) + COMPREPLY=($(compgen -W "--to --to-label" -- "$cur")) ;; esac ;; @@ -221,49 +269,85 @@ _wallet() { ;; new) case "$prev" in - --definition-account-id | --supply-account-id) + --definition-account-id) _wallet_complete_account_id "$cur" ;; + --definition-account-label) + _wallet_complete_account_label "$cur" + ;; + --supply-account-id) + _wallet_complete_account_id "$cur" + ;; + --supply-account-label) + _wallet_complete_account_label "$cur" + ;; -n | --name | -t | --total-supply) ;; # no specific completion *) - COMPREPLY=($(compgen -W "--definition-account-id --supply-account-id -n --name -t --total-supply" -- "$cur")) + COMPREPLY=($(compgen -W "--definition-account-id --definition-account-label --supply-account-id --supply-account-label -n --name -t --total-supply" -- "$cur")) ;; esac ;; send) case "$prev" in - --from | --to) + --from) _wallet_complete_account_id "$cur" ;; - --to-npk | --to-vpk | --amount) + --from-label) + _wallet_complete_account_label "$cur" + ;; + --to) + _wallet_complete_account_id "$cur" + ;; + --to-label) + _wallet_complete_account_label "$cur" + ;; + --to-npk | --to-vpk | --to-identifier | --amount) ;; # no specific completion *) - COMPREPLY=($(compgen -W "--from --to --to-npk --to-vpk --amount" -- "$cur")) + COMPREPLY=($(compgen -W "--from --from-label --to --to-label --to-npk --to-vpk --to-identifier --amount" -- "$cur")) ;; esac ;; burn) case "$prev" in - --definition | --holder) + --definition) _wallet_complete_account_id "$cur" ;; + --definition-label) + _wallet_complete_account_label "$cur" + ;; + --holder) + _wallet_complete_account_id "$cur" + ;; + --holder-label) + _wallet_complete_account_label "$cur" + ;; --amount) ;; # no specific completion *) - COMPREPLY=($(compgen -W "--definition --holder --amount" -- "$cur")) + COMPREPLY=($(compgen -W "--definition --definition-label --holder --holder-label --amount" -- "$cur")) ;; esac ;; mint) case "$prev" in - --definition | --holder) + --definition) _wallet_complete_account_id "$cur" ;; - --holder-npk | --holder-vpk | --amount) + --definition-label) + _wallet_complete_account_label "$cur" + ;; + --holder) + _wallet_complete_account_id "$cur" + ;; + --holder-label) + _wallet_complete_account_label "$cur" + ;; + --holder-npk | --holder-vpk | --holder-identifier | --amount) ;; # no specific completion *) - COMPREPLY=($(compgen -W "--definition --holder --holder-npk --holder-vpk --amount" -- "$cur")) + COMPREPLY=($(compgen -W "--definition --definition-label --holder --holder-label --holder-npk --holder-vpk --holder-identifier --amount" -- "$cur")) ;; esac ;; @@ -273,53 +357,178 @@ _wallet() { amm) case "$subcmd" in "") - COMPREPLY=($(compgen -W "new swap add-liquidity remove-liquidity help" -- "$cur")) + COMPREPLY=($(compgen -W "new swap-exact-input swap-exact-output add-liquidity remove-liquidity help" -- "$cur")) ;; new) case "$prev" in - --user-holding-a | --user-holding-b | --user-holding-lp) + --user-holding-a) _wallet_complete_account_id "$cur" ;; + --user-holding-a-label) + _wallet_complete_account_label "$cur" + ;; + --user-holding-b) + _wallet_complete_account_id "$cur" + ;; + --user-holding-b-label) + _wallet_complete_account_label "$cur" + ;; + --user-holding-lp) + _wallet_complete_account_id "$cur" + ;; + --user-holding-lp-label) + _wallet_complete_account_label "$cur" + ;; --balance-a | --balance-b) ;; # no specific completion *) - COMPREPLY=($(compgen -W "--user-holding-a --user-holding-b --user-holding-lp --balance-a --balance-b" -- "$cur")) + COMPREPLY=($(compgen -W "--user-holding-a --user-holding-a-label --user-holding-b --user-holding-b-label --user-holding-lp --user-holding-lp-label --balance-a --balance-b" -- "$cur")) ;; esac ;; - swap) + swap-exact-input) case "$prev" in - --user-holding-a | --user-holding-b) + --user-holding-a) _wallet_complete_account_id "$cur" ;; + --user-holding-a-label) + _wallet_complete_account_label "$cur" + ;; + --user-holding-b) + _wallet_complete_account_id "$cur" + ;; + --user-holding-b-label) + _wallet_complete_account_label "$cur" + ;; --amount-in | --min-amount-out | --token-definition) ;; # no specific completion *) - COMPREPLY=($(compgen -W "--user-holding-a --user-holding-b --amount-in --min-amount-out --token-definition" -- "$cur")) + COMPREPLY=($(compgen -W "--user-holding-a --user-holding-a-label --user-holding-b --user-holding-b-label --amount-in --min-amount-out --token-definition" -- "$cur")) + ;; + esac + ;; + swap-exact-output) + case "$prev" in + --user-holding-a | --user-holding-b | --exact-amount-out | --max-amount-in | --token-definition) + ;; # no specific completion + *) + COMPREPLY=($(compgen -W "--user-holding-a --user-holding-b --exact-amount-out --max-amount-in --token-definition" -- "$cur")) ;; esac ;; add-liquidity) case "$prev" in - --user-holding-a | --user-holding-b | --user-holding-lp) + --user-holding-a) _wallet_complete_account_id "$cur" ;; + --user-holding-a-label) + _wallet_complete_account_label "$cur" + ;; + --user-holding-b) + _wallet_complete_account_id "$cur" + ;; + --user-holding-b-label) + _wallet_complete_account_label "$cur" + ;; + --user-holding-lp) + _wallet_complete_account_id "$cur" + ;; + --user-holding-lp-label) + _wallet_complete_account_label "$cur" + ;; --max-amount-a | --max-amount-b | --min-amount-lp) ;; # no specific completion *) - COMPREPLY=($(compgen -W "--user-holding-a --user-holding-b --user-holding-lp --max-amount-a --max-amount-b --min-amount-lp" -- "$cur")) + COMPREPLY=($(compgen -W "--user-holding-a --user-holding-a-label --user-holding-b --user-holding-b-label --user-holding-lp --user-holding-lp-label --max-amount-a --max-amount-b --min-amount-lp" -- "$cur")) ;; esac ;; remove-liquidity) case "$prev" in - --user-holding-a | --user-holding-b | --user-holding-lp) + --user-holding-a) _wallet_complete_account_id "$cur" ;; + --user-holding-a-label) + _wallet_complete_account_label "$cur" + ;; + --user-holding-b) + _wallet_complete_account_id "$cur" + ;; + --user-holding-b-label) + _wallet_complete_account_label "$cur" + ;; + --user-holding-lp) + _wallet_complete_account_id "$cur" + ;; + --user-holding-lp-label) + _wallet_complete_account_label "$cur" + ;; --balance-lp | --min-amount-a | --min-amount-b) ;; # no specific completion *) - COMPREPLY=($(compgen -W "--user-holding-a --user-holding-b --user-holding-lp --balance-lp --min-amount-a --min-amount-b" -- "$cur")) + COMPREPLY=($(compgen -W "--user-holding-a --user-holding-a-label --user-holding-b --user-holding-b-label --user-holding-lp --user-holding-lp-label --balance-lp --min-amount-a --min-amount-b" -- "$cur")) + ;; + esac + ;; + esac + ;; + + ata) + case "$subcmd" in + "") + COMPREPLY=($(compgen -W "address create send burn list help" -- "$cur")) + ;; + address) + case "$prev" in + --owner | --token-definition) + ;; # no specific completion + *) + COMPREPLY=($(compgen -W "--owner --token-definition" -- "$cur")) + ;; + esac + ;; + create) + case "$prev" in + --owner) + _wallet_complete_account_id "$cur" + ;; + --token-definition) + ;; # no specific completion + *) + COMPREPLY=($(compgen -W "--owner --token-definition" -- "$cur")) + ;; + esac + ;; + send) + case "$prev" in + --from) + _wallet_complete_account_id "$cur" + ;; + --to | --token-definition | --amount) + ;; # no specific completion + *) + COMPREPLY=($(compgen -W "--from --token-definition --to --amount" -- "$cur")) + ;; + esac + ;; + burn) + case "$prev" in + --holder) + _wallet_complete_account_id "$cur" + ;; + --token-definition | --amount) + ;; # no specific completion + *) + COMPREPLY=($(compgen -W "--holder --token-definition --amount" -- "$cur")) + ;; + esac + ;; + list) + case "$prev" in + --owner | --token-definition) + ;; # no specific completion + *) + COMPREPLY=($(compgen -W "--owner --token-definition" -- "$cur")) ;; esac ;; diff --git a/completions/zsh/_wallet b/completions/zsh/_wallet index 6e60cc53..8f573ab0 100644 --- a/completions/zsh/_wallet +++ b/completions/zsh/_wallet @@ -24,6 +24,7 @@ _wallet() { 'pinata:Pinata program interaction subcommand' 'token:Token program interaction subcommand' 'amm:AMM program interaction subcommand' + 'ata:Associated Token Account program interaction subcommand' 'check-health:Check the wallet can connect to the node and builtin local programs match the remote versions' 'config:Command to setup config, get and set config fields' 'restore-keys:Restoring keys from given password at given depth' @@ -52,6 +53,9 @@ _wallet() { amm) _wallet_amm ;; + ata) + _wallet_ata + ;; config) _wallet_config ;; @@ -72,7 +76,7 @@ _wallet() { # auth-transfer subcommand _wallet_auth_transfer() { local -a subcommands - + _arguments -C \ '1: :->subcommand' \ '*:: :->args' @@ -90,14 +94,18 @@ _wallet_auth_transfer() { case $line[1] in init) _arguments \ - '--account-id[Account ID to initialize]:account_id:_wallet_account_ids' + '--account-id[Account ID to initialize]:account_id:_wallet_account_ids' \ + '--account-label[Account label (alternative to --account-id)]:label:' ;; send) _arguments \ '--from[Source account ID]:from_account:_wallet_account_ids' \ + '--from-label[From account label (alternative to --from)]:label:' \ '--to[Destination account ID (for owned accounts)]:to_account:_wallet_account_ids' \ + '--to-label[To account label (alternative to --to)]:label:' \ '--to-npk[Destination nullifier public key (for foreign private accounts)]:npk:' \ '--to-vpk[Destination viewing public key (for foreign private accounts)]:vpk:' \ + '--to-identifier[Identifier for the recipient private account]:identifier:' \ '--amount[Amount of native tokens to send]:amount:' ;; esac @@ -108,7 +116,7 @@ _wallet_auth_transfer() { # chain-info subcommand _wallet_chain_info() { local -a subcommands - + _arguments -C \ '1: :->subcommand' \ '*:: :->args' @@ -141,7 +149,7 @@ _wallet_chain_info() { # account subcommand _wallet_account() { local -a subcommands - + _arguments -C \ '1: :->subcommand' \ '*:: :->args' @@ -165,7 +173,8 @@ _wallet_account() { _arguments \ '(-r --raw)'{-r,--raw}'[Get raw account data]' \ '(-k --keys)'{-k,--keys}'[Display keys (pk for public accounts, npk/vpk for private accounts)]' \ - '(-a --account-id)'{-a,--account-id}'[Account ID to query]:account_id:_wallet_account_ids' + '(-a --account-id)'{-a,--account-id}'[Account ID to query]:account_id:_wallet_account_ids' \ + '--account-label[Account label (alternative to --account-id)]:label:' ;; list|ls) _arguments \ @@ -177,18 +186,27 @@ _wallet_account() { '*:: :->new_args' case $state in account_type) - compadd public private + compadd public private-accounts-key ;; new_args) - _arguments \ - '--cci[Chain index of a parent node]:chain_index:' \ - '(-l --label)'{-l,--label}'[Label to assign to the new account]:label:' + case $line[1] in + public) + _arguments \ + '--cci[Chain index of a parent node]:chain_index:' \ + '(-l --label)'{-l,--label}'[Label to assign to the new account]:label:' + ;; + private-accounts-key) + _arguments \ + '--cci[Chain index of a parent node]:chain_index:' + ;; + esac ;; esac ;; label) _arguments \ '(-a --account-id)'{-a,--account-id}'[Account ID to label]:account_id:_wallet_account_ids' \ + '--account-label[Account label (alternative to --account-id)]:label:' \ '(-l --label)'{-l,--label}'[The label to assign to the account]:label:' ;; esac @@ -199,7 +217,7 @@ _wallet_account() { # pinata subcommand _wallet_pinata() { local -a subcommands - + _arguments -C \ '1: :->subcommand' \ '*:: :->args' @@ -216,7 +234,8 @@ _wallet_pinata() { case $line[1] in claim) _arguments \ - '--to[Destination account ID to receive claimed tokens]:to_account:_wallet_account_ids' + '--to[Destination account ID to receive claimed tokens]:to_account:_wallet_account_ids' \ + '--to-label[To account label (alternative to --to)]:label:' ;; esac ;; @@ -249,28 +268,38 @@ _wallet_token() { '--name[Token name]:name:' \ '--total-supply[Total supply of tokens to mint]:total_supply:' \ '--definition-account-id[Account ID for token definition]:definition_account:_wallet_account_ids' \ - '--supply-account-id[Account ID to receive initial supply]:supply_account:_wallet_account_ids' + '--definition-account-label[Definition account label (alternative to --definition-account-id)]:label:' \ + '--supply-account-id[Account ID to receive initial supply]:supply_account:_wallet_account_ids' \ + '--supply-account-label[Supply account label (alternative to --supply-account-id)]:label:' ;; send) _arguments \ '--from[Source holding account ID]:from_account:_wallet_account_ids' \ + '--from-label[From account label (alternative to --from)]:label:' \ '--to[Destination holding account ID (for owned accounts)]:to_account:_wallet_account_ids' \ + '--to-label[To account label (alternative to --to)]:label:' \ '--to-npk[Destination nullifier public key (for foreign private accounts)]:npk:' \ '--to-vpk[Destination viewing public key (for foreign private accounts)]:vpk:' \ + '--to-identifier[Identifier for the recipient private account]:identifier:' \ '--amount[Amount of tokens to send]:amount:' ;; burn) _arguments \ '--definition[Definition account ID]:definition_account:_wallet_account_ids' \ + '--definition-label[Definition account label (alternative to --definition)]:label:' \ '--holder[Holder account ID]:holder_account:_wallet_account_ids' \ + '--holder-label[Holder account label (alternative to --holder)]:label:' \ '--amount[Amount of tokens to burn]:amount:' ;; mint) _arguments \ '--definition[Definition account ID]:definition_account:_wallet_account_ids' \ + '--definition-label[Definition account label (alternative to --definition)]:label:' \ '--holder[Holder account ID (for owned accounts)]:holder_account:_wallet_account_ids' \ + '--holder-label[Holder account label (alternative to --holder)]:label:' \ '--holder-npk[Holder nullifier public key (for foreign private accounts)]:npk:' \ '--holder-vpk[Holder viewing public key (for foreign private accounts)]:vpk:' \ + '--holder-identifier[Identifier for the holder private account]:identifier:' \ '--amount[Amount of tokens to mint]:amount:' ;; esac @@ -281,7 +310,7 @@ _wallet_token() { # amm subcommand _wallet_amm() { local -a subcommands - + _arguments -C \ '1: :->subcommand' \ '*:: :->args' @@ -290,7 +319,8 @@ _wallet_amm() { subcommand) subcommands=( 'new:Create a new liquidity pool' - 'swap:Swap tokens using the AMM' + 'swap-exact-input:Swap specifying exact input amount' + 'swap-exact-output:Swap specifying exact output amount' 'add-liquidity:Add liquidity to an existing pool' 'remove-liquidity:Remove liquidity from a pool' 'help:Print this message or the help of the given subcommand(s)' @@ -302,24 +332,40 @@ _wallet_amm() { new) _arguments \ '--user-holding-a[User token A holding account ID]:holding_a:_wallet_account_ids' \ + '--user-holding-a-label[User holding A label (alternative to --user-holding-a)]:label:' \ '--user-holding-b[User token B holding account ID]:holding_b:_wallet_account_ids' \ + '--user-holding-b-label[User holding B label (alternative to --user-holding-b)]:label:' \ '--user-holding-lp[User LP token holding account ID]:holding_lp:_wallet_account_ids' \ + '--user-holding-lp-label[User holding LP label (alternative to --user-holding-lp)]:label:' \ '--balance-a[Amount of token A to deposit]:balance_a:' \ '--balance-b[Amount of token B to deposit]:balance_b:' ;; - swap) + swap-exact-input) _arguments \ '--user-holding-a[User token A holding account ID]:holding_a:_wallet_account_ids' \ + '--user-holding-a-label[User holding A label (alternative to --user-holding-a)]:label:' \ '--user-holding-b[User token B holding account ID]:holding_b:_wallet_account_ids' \ + '--user-holding-b-label[User holding B label (alternative to --user-holding-b)]:label:' \ '--amount-in[Amount of tokens to swap]:amount_in:' \ '--min-amount-out[Minimum tokens expected in return]:min_amount_out:' \ '--token-definition[Definition ID of the token being provided]:token_def:' ;; + swap-exact-output) + _arguments \ + '--user-holding-a[User token A holding account ID]:holding_a:' \ + '--user-holding-b[User token B holding account ID]:holding_b:' \ + '--exact-amount-out[Exact amount of tokens expected out]:exact_amount_out:' \ + '--max-amount-in[Maximum tokens to spend]:max_amount_in:' \ + '--token-definition[Definition ID of the token being provided]:token_def:' + ;; add-liquidity) _arguments \ '--user-holding-a[User token A holding account ID]:holding_a:_wallet_account_ids' \ + '--user-holding-a-label[User holding A label (alternative to --user-holding-a)]:label:' \ '--user-holding-b[User token B holding account ID]:holding_b:_wallet_account_ids' \ + '--user-holding-b-label[User holding B label (alternative to --user-holding-b)]:label:' \ '--user-holding-lp[User LP token holding account ID]:holding_lp:_wallet_account_ids' \ + '--user-holding-lp-label[User holding LP label (alternative to --user-holding-lp)]:label:' \ '--max-amount-a[Maximum amount of token A to deposit]:max_amount_a:' \ '--max-amount-b[Maximum amount of token B to deposit]:max_amount_b:' \ '--min-amount-lp[Minimum LP tokens to receive]:min_amount_lp:' @@ -327,8 +373,11 @@ _wallet_amm() { remove-liquidity) _arguments \ '--user-holding-a[User token A holding account ID]:holding_a:_wallet_account_ids' \ + '--user-holding-a-label[User holding A label (alternative to --user-holding-a)]:label:' \ '--user-holding-b[User token B holding account ID]:holding_b:_wallet_account_ids' \ + '--user-holding-b-label[User holding B label (alternative to --user-holding-b)]:label:' \ '--user-holding-lp[User LP token holding account ID]:holding_lp:_wallet_account_ids' \ + '--user-holding-lp-label[User holding LP label (alternative to --user-holding-lp)]:label:' \ '--balance-lp[Amount of LP tokens to burn]:balance_lp:' \ '--min-amount-a[Minimum token A to receive]:min_amount_a:' \ '--min-amount-b[Minimum token B to receive]:min_amount_b:' @@ -338,6 +387,61 @@ _wallet_amm() { esac } +# ata subcommand +_wallet_ata() { + local -a subcommands + + _arguments -C \ + '1: :->subcommand' \ + '*:: :->args' + + case $state in + subcommand) + subcommands=( + 'address:Derive and print the Associated Token Account address (local only)' + 'create:Create (or idempotently no-op) the Associated Token Account' + 'send:Send tokens from owner ATA to a recipient token holding account' + 'burn:Burn tokens from holder ATA' + 'list:List all ATAs for a given owner across multiple token definitions' + 'help:Print this message or the help of the given subcommand(s)' + ) + _describe -t subcommands 'ata subcommands' subcommands + ;; + args) + case $line[1] in + address) + _arguments \ + '--owner[Owner account (no privacy prefix)]:owner:' \ + '--token-definition[Token definition account (no privacy prefix)]:token_def:' + ;; + create) + _arguments \ + '--owner[Owner account with privacy prefix]:owner:_wallet_account_ids' \ + '--token-definition[Token definition account (no privacy prefix)]:token_def:' + ;; + send) + _arguments \ + '--from[Sender account with privacy prefix]:from:_wallet_account_ids' \ + '--token-definition[Token definition account (no privacy prefix)]:token_def:' \ + '--to[Recipient account (no privacy prefix)]:to:' \ + '--amount[Amount of tokens to send]:amount:' + ;; + burn) + _arguments \ + '--holder[Holder account with privacy prefix]:holder:_wallet_account_ids' \ + '--token-definition[Token definition account (no privacy prefix)]:token_def:' \ + '--amount[Amount of tokens to burn]:amount:' + ;; + list) + _arguments \ + '--owner[Owner account (no privacy prefix)]:owner:' \ + '--token-definition[Token definition accounts (no privacy prefix)]:token_def:' + ;; + esac + ;; + esac +} + # config subcommand _wallet_config() { local -a subcommands @@ -410,6 +514,7 @@ _wallet_help() { 'pinata:Pinata program interaction subcommand' 'token:Token program interaction subcommand' 'amm:AMM program interaction subcommand' + 'ata:Associated Token Account program interaction subcommand' 'check-health:Check the wallet can connect to the node' 'config:Command to setup config, get and set config fields' 'restore-keys:Restoring keys from given password at given depth' @@ -424,7 +529,7 @@ _wallet_help() { _wallet_account_ids() { local -a accounts local line - + # Try to get accounts from wallet account list command # Filter to lines starting with /N (numbered accounts) and extract the account ID if command -v wallet &>/dev/null; then @@ -433,13 +538,13 @@ _wallet_account_ids() { [[ -n "$line" ]] && accounts+=("${line%,}") done < <(wallet account list 2>/dev/null | grep '^/[0-9]' | awk '{print $2}') fi - + # Provide type prefixes as fallback if command fails or returns nothing if (( ${#accounts} == 0 )); then compadd -S '' -- 'Public/' 'Private/' return fi - + _multi_parts / accounts } diff --git a/configs/docker-all-in-one/indexer_config.json b/configs/docker-all-in-one/indexer_config.json index c2b07e3e..f2005ff5 100644 --- a/configs/docker-all-in-one/indexer_config.json +++ b/configs/docker-all-in-one/indexer_config.json @@ -1,160 +1,8 @@ { "home": "./indexer/service", "consensus_info_polling_interval": "1s", - "bedrock_client_config": { - "addr": "http://logos-blockchain-node-0:18080", - "backoff": { - "start_delay": "100ms", - "max_retries": 5 - } + "bedrock_config": { + "addr": "http://logos-blockchain-node-0:18080" }, - "channel_id": "0101010101010101010101010101010101010101010101010101010101010101", - "initial_accounts": [ - { - "account_id": "6iArKUXxhUJqS7kCaPNhwMWt3ro71PDyBj7jwAyE2VQV", - "balance": 10000 - }, - { - "account_id": "7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo", - "balance": 20000 - } - ], - "initial_commitments": [ - { - "npk":[ - 177, - 64, - 1, - 11, - 87, - 38, - 254, - 159, - 231, - 165, - 1, - 94, - 64, - 137, - 243, - 76, - 249, - 101, - 251, - 129, - 33, - 101, - 189, - 30, - 42, - 11, - 191, - 34, - 103, - 186, - 227, - 230 - ] , - "account": { - "program_owner": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "balance": 10000, - "data": [], - "nonce": 0 - } - }, - { - "npk": [ - 32, - 67, - 72, - 164, - 106, - 53, - 66, - 239, - 141, - 15, - 52, - 230, - 136, - 177, - 2, - 236, - 207, - 243, - 134, - 135, - 210, - 143, - 87, - 232, - 215, - 128, - 194, - 120, - 113, - 224, - 4, - 165 - ], - "account": { - "program_owner": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "balance": 20000, - "data": [], - "nonce": 0 - } - } - ], - "signing_key": [ - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37 - ] + "channel_id": "0101010101010101010101010101010101010101010101010101010101010101" } diff --git a/configs/docker-all-in-one/sequencer_config.json b/configs/docker-all-in-one/sequencer_config.json index d7fd3490..207f2e79 100644 --- a/configs/docker-all-in-one/sequencer_config.json +++ b/configs/docker-all-in-one/sequencer_config.json @@ -1,7 +1,5 @@ { "home": "/var/lib/sequencer_service", - "genesis_id": 1, - "is_genesis_random": true, "max_num_tx_in_block": 20, "max_block_size": "1 MiB", "mempool_max_size": 10000, @@ -16,117 +14,29 @@ "node_url": "http://logos-blockchain-node-0:18080" }, "indexer_rpc_url": "ws://indexer_service:8779", - "initial_accounts": [ + "genesis": [ { - "account_id": "6iArKUXxhUJqS7kCaPNhwMWt3ro71PDyBj7jwAyE2VQV", - "balance": 10000 - }, - { - "account_id": "7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo", - "balance": 20000 - } - ], - "initial_commitments": [ - { - "npk":[ - 177, - 64, - 1, - 11, - 87, - 38, - 254, - 159, - 231, - 165, - 1, - 94, - 64, - 137, - 243, - 76, - 249, - 101, - 251, - 129, - 33, - 101, - 189, - 30, - 42, - 11, - 191, - 34, - 103, - 186, - 227, - 230 - ] , - "account": { - "program_owner": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "balance": 10000, - "data": [], - "nonce": 0 + "supply_account": { + "account_id": "6iArKUXxhUJqS7kCaPNhwMWt3ro71PDyBj7jwAyE2VQV", + "balance": 10000 } }, { - "npk": [ - 32, - 67, - 72, - 164, - 106, - 53, - 66, - 239, - 141, - 15, - 52, - 230, - 136, - 177, - 2, - 236, - 207, - 243, - 134, - 135, - 210, - 143, - 87, - 232, - 215, - 128, - 194, - 120, - 113, - 224, - 4, - 165 - ], - "account": { - "program_owner": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "balance": 20000, - "data": [], - "nonce": 0 + "supply_account": { + "account_id": "7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo", + "balance": 20000 + } + }, + { + "supply_account": { + "account_id": "61EsoYN6gvTLkveh1YSTMG3yJkncpHy5EGmxhSK4ew29", + "balance": 10000 + } + }, + { + "supply_account": { + "account_id": "3m6HQmCgmAvsxZtxAHPqqEqoBG4335fCG8TzxigyW7rE", + "balance": 20000 } } ], diff --git a/docs/LEZ testnet v0.1 tutorials/associated-token-accounts.md b/docs/LEZ testnet v0.1 tutorials/associated-token-accounts.md index 330ae909..7ed95e01 100644 --- a/docs/LEZ testnet v0.1 tutorials/associated-token-accounts.md +++ b/docs/LEZ testnet v0.1 tutorials/associated-token-accounts.md @@ -52,7 +52,7 @@ The derivation works as follows: ``` seed = SHA256(owner_id || definition_id) -ata_address = AccountId::from((ata_program_id, seed)) +ata_address = AccountId::for_public_pda(ata_program_id, seed) ``` Because the computation is pure, anyone who knows the owner and definition can reproduce the exact same ATA address — no network call required. diff --git a/docs/LEZ testnet v0.1 tutorials/keycard.md b/docs/LEZ testnet v0.1 tutorials/keycard.md new file mode 100644 index 00000000..38feea4f --- /dev/null +++ b/docs/LEZ testnet v0.1 tutorials/keycard.md @@ -0,0 +1,237 @@ +This tutorial walks you through using Keycard with Wallet CLI. Keycard is optional hardware that can offer enhance security to a LEZ wallet. A LEZ wallet that utilizes Keycard does not store any secret keys for public accounts (eventually, this will extend to private accounts). Instead, Wallet CLI retrieves the appropriate public keys and signatures from Keycard. + + +## Keycard Setup + +### 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 keycard_wallet/keycard_applets + cd 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 (`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 `keycard_wallet/wallet_with_keycard.sh`. + +```bash +# Install appropriate version of `keycard-py`. +git clone --branch lee-schnorr --single-branch https://github.com/bitgamma/keycard-py.git 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 keycard_wallet/python/keycard-py +``` + +**Important**: Keycard wallet commands only work within the virtual environment. +```bash +# In the root of LEE repo: +source venv/bin/activate +``` + +## PIN entry + +Each Keycard command prompts for a PIN interactively. To avoid re-entering it across multiple commands, export it as an environment variable: + +```bash +export KEYCARD_PIN=123456 +``` + +Unset it when done: + +```bash +unset KEYCARD_PIN +``` + +## Keycard Commands + +### 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 load` | Loads a mnemonic phrase onto the Keycard | + +1. Check keycard availability +```bash +wallet keycard available + +# Output: +✅ Keycard is available. +``` + +2. Initialize a blank Keycard +```bash +wallet keycard init + +# Output: +Keycard PIN: +Keycard PUK: 847302916485 +Record this PUK and store it somewhere safe. It cannot be recovered. +✅ Keycard initialized successfully. +``` + +3. Connect (pair and save pairing for subsequent commands) +```bash +wallet keycard connect + +# Output: +Keycard PIN: +✅ Keycard paired and ready. +``` + +4. Load a mnemonic phrase +```bash +# Supply mnemonic via environment variable to avoid interactive prompt +export KEYCARD_MNEMONIC="fashion degree mountain wool question damp current pond grow dolphin chronic then" +wallet keycard load +unset KEYCARD_MNEMONIC + +# Output: +Keycard PIN: +✅ Keycard is now connected to wallet. +✅ Mnemonic phrase loaded successfully. +``` + +5. Disconnect (unpair and clear saved pairing) +```bash +wallet keycard disconnect + +# Output: +Keycard PIN: +✅ Keycard unpaired and pairing cleared. +``` + +### Pinata (testnet) + +| Command | Description | +|-----------------------|--------------------------------------------------------------------------| +| `wallet pinata claim` | Claims a testnet pinata reward to a public or private recipient account | + +Note: The recipient account must be initialized with `wallet auth-transfer init` before claiming. + +`--to` accepts any of: +- A BIP32 key path — uses Keycard (e.g. `m/44'/60'/0'/0/0`) +- An account ID with privacy prefix (e.g. `Public/9bKm...`) +- An account label (e.g. `my-account`) + +1. Claim to a Keycard public account +```bash +wallet pinata claim --to "m/44'/60'/0'/0/0" + +# Output: +Keycard PIN: +Computing solution for pinata... +Found solution 989106 in 33.739525ms +Transaction hash is fd320c01f5469e62d2486afa1d9d5be39afcca0cd01d1575905b7acd95cf6397 +``` + +2. Claim to a local wallet account by label +```bash +wallet pinata claim --to my-account + +# Output: +Transaction hash is 2c8a4f1e903d5b76e80214c5b82e1d46a105e28930ad71bcce48f2d07b49a16f +``` + +### Authenticated-transfer program + +| Command | Description | +|-----------------------------|-------------------------------------------------------------------------------| +| `wallet auth-transfer init` | Registers an account with the auth-transfer program | +| `wallet auth-transfer send` | Sends native tokens between accounts | + +`--account-id` (for `init`) and `--from`/`--to` (for `send`) each accept any of: +- A BIP32 key path — uses Keycard (e.g. `m/44'/60'/0'/0/0`) +- An account ID with privacy prefix (e.g. `Public/9bKm...`) +- An account label (e.g. `my-account`) + +For `send`, foreign recipient accounts (not in the local wallet and not a Keycard path) do not need to sign — pass their account ID directly via `--to`. Shielded sends to foreign private accounts use `--to-npk`/`--to-vpk`. + +1. Initialize a Keycard public account +```bash +wallet auth-transfer init --account-id "m/44'/60'/0'/0/0" + +# Output: +Keycard PIN: +Transaction hash is 49c16940493e1618c393645c1211b5c793d405838221c29ac6562a8a4b11c5a7 +``` + +2. Send native tokens between two Keycard accounts +```bash +wallet auth-transfer send \ + --from "m/44'/60'/0'/0/0" \ + --to "m/44'/60'/0'/0/1" \ + --amount 40 + +# Output: +Keycard PIN: +Transaction hash is 1a9764ab20763dcc1ffb51c6e9badd5a6316a773759032ca48e0eee59caaf488 +``` + +3. Send native tokens from a Keycard account to a foreign account +```bash +wallet auth-transfer send \ + --from "m/44'/60'/0'/0/0" \ + --to "Public/9bKmZ4n7PqVRxEtY3dWsQjA2cHrFT5LpDoGXM8wJuNv6" \ + --amount 20 + +# Output: +Keycard PIN: +Transaction hash is 3e7b2a91cf804d56fe19084b3c8b25d07e8f243829bc50addf6e2c78b4b09d34 +``` + +4. Send native tokens from a Keycard account to a local wallet account by label +```bash +wallet auth-transfer send \ + --from "m/44'/60'/0'/0/0" \ + --to my-account \ + --amount 20 + +# Output: +Keycard PIN: +Transaction hash is 7d4c1b8e2f903a56fd19084b3c8b25d07e8f243829bc50addf6e2c78b4b09e45 +``` + +## Testing + +Tests for Keycard commands are in `keycard_wallet/tests/keycard_tests.sh`. Run from the repo root with a Keycard connected: + +```bash +bash keycard_wallet/tests/keycard_tests.sh +``` + +## SigningGroups + +`SigningGroups` (`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. + +``` +SigningGroups { + 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/docs/LEZ testnet v0.1 tutorials/token-transfer.md b/docs/LEZ testnet v0.1 tutorials/token-transfer.md index 156f0b1f..3a1ef43f 100644 --- a/docs/LEZ testnet v0.1 tutorials/token-transfer.md +++ b/docs/LEZ testnet v0.1 tutorials/token-transfer.md @@ -5,6 +5,7 @@ This tutorial walks through native token transfers between public and private ac 4. Private account creation. 5. Native token transfer from a public account to a private account. 6. Native token transfer from a public account to a private account owned by someone else. +7. Sending to a private accounts key from multiple independent senders. --- @@ -142,7 +143,7 @@ Account owned by authenticated-transfer program > Private accounts are structurally identical to public accounts, but their values are stored off-chain. On-chain, only a 32-byte commitment is recorded. > Transactions include encrypted private values so the owner can recover them, and the decryption keys are never shared. > Private accounts use two keypairs: nullifier keys for privacy-preserving executions and viewing keys for encrypting and decrypting values. -> The private account ID is derived from the nullifier public key. +> The private account ID is derived from the nullifier public key and a numeric identifier: `SHA256(prefix || npk || identifier)`. The same `npk` paired with different identifiers yields different, independent account IDs. > Private accounts can be initialized by anyone, but once initialized they can only be modified by the owner’s keys. > Updates include a new commitment and a nullifier for the old state, which prevents linkage between versions. @@ -158,7 +159,9 @@ With vpk 02ddc96d0eb56e00ce14994cfdaec5ae1f76244180a919545983156e3519940a17 ``` > [!Tip] -> Focus on the account ID for now. The `npk` and `vpk` values are stored locally and used to build privacy-preserving transactions. The private account ID is derived from `npk`. +> Save this account ID. You will use it in later commands. + +### b. Check the account status Just like public accounts, new private accounts start out uninitialized: @@ -218,21 +221,23 @@ Account owned by authenticated-transfer program ## 6. Native token transfer from a public account to a private account owned by someone else > [!Important] -> We’ll simulate transferring to someone else by creating a new private account we own and treating it as if it belonged to another user. +> We’ll simulate transferring to someone else by creating a new private accounts key and treating it as if it belonged to another user. When the recipient is someone else, you only have their `npk` and `vpk` — not an account ID. -### a. Create a new uninitialized private account +### a. Create a new private accounts key to simulate a foreign recipient ```bash -wallet account new private +wallet account new private-accounts-key # Output: -Generated new account with account_id Private/AukXPRBmrYVqoqEW2HTs7N3hvTn3qdNFDcxDHVr5hMm5 +Generated new private accounts key at path /1 With npk 0c95ebc4b3830f53da77bb0b80a276a776cdcf6410932acc718dcdb3f788a00e With vpk 039fd12a3674a880d3e917804129141e4170d419d1f9e28a3dcf979c1f2369cb72 ``` > [!Tip] -> Ignore the private account ID here and use the `npk` and `vpk` values to send to a foreign private account. +> Ignore the account ID here and use the `npk` and `vpk` values to send to a foreign private account. + +### b. Send 3 tokens using the recipient’s npk and vpk ```bash wallet auth-transfer send \ @@ -242,9 +247,74 @@ wallet auth-transfer send \ --amount 3 ``` +> [!Note] +> `--to-identifier` is omitted here. When omitted, the wallet picks a random identifier, which is usually fine. Use the flag explicitly when a specific identifier is required. + > [!Warning] > This command creates a privacy-preserving transaction, which may take a few minutes. The updated values are encrypted and included in the transaction. > Once accepted, the recipient must run `wallet account sync-private` to scan the chain for their encrypted updates and refresh local state. > [!Note] > You have seen transfers between two public accounts and from a public sender to a private recipient. Transfers from a private sender, whether to a public account or to another private account, follow the same pattern. + +## 7. Sending to a private accounts key from multiple independent senders + +> [!Important] +> A private accounts key (`npk` + `vpk`) can be shared with multiple senders. Each sender independently chooses an identifier; the recipient's account ID is derived from `(npk, identifier)`. Two senders using different identifiers produce two separate private accounts under the same key. + +### a. Alice creates a private accounts key + +```bash +wallet account new private-accounts-key + +# Output: +Generated new private accounts key at path /2 +With npk a3f7c21b8e905d4f6a1bc783d0e2f94c1d5a6b7e8f9012345678abcdef012345 +With vpk 03b1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6071819202122232425262728292a2b2c +``` + +Alice shares the `npk` and `vpk` values with Bob and Charlie out of band. + +### b. Bob sends 10 tokens to Alice using identifier 1 + +```bash +wallet auth-transfer send \ + --from Public/BobXqJprP9BmhbFVQyBcbznU8bAXcwrzwRoPTetXdQPA \ + --to-npk a3f7c21b8e905d4f6a1bc783d0e2f94c1d5a6b7e8f9012345678abcdef012345 \ + --to-vpk 03b1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6071819202122232425262728292a2b2c \ + --to-identifier 1 \ + --amount 10 +``` + +### c. Charlie sends 5 tokens to Alice using identifier 2 + +```bash +wallet auth-transfer send \ + --from Public/CharlieYrP9BmhbFVQyBcbznU8bAXcwrzwRoPTetXdQPB \ + --to-npk a3f7c21b8e905d4f6a1bc783d0e2f94c1d5a6b7e8f9012345678abcdef012345 \ + --to-vpk 03b1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6071819202122232425262728292a2b2c \ + --to-identifier 2 \ + --amount 5 +``` + +> [!Note] +> Bob and Charlie each chose a different identifier. They do not need to coordinate — any two distinct values work. + +### d. Alice syncs to discover the new accounts + +```bash +wallet account sync-private +``` + +```bash +wallet account list + +# Output (private account entries under key /2): +/2 Private/AliceBobAcctXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +/2 Private/AliceCharlieAcctXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +Alice now has two separate private accounts, one funded by Bob and one by Charlie, both controlled by the same key at path `/2`. + +> [!Tip] +> Alice can check each account balance with `wallet account get --account-id Private/...`. Neither balance is visible on-chain. diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md new file mode 100644 index 00000000..d745f2f3 --- /dev/null +++ b/docs/benchmarks/README.md @@ -0,0 +1,11 @@ +# Benchmarks + +Bench tools live under `tools/` with READMEs for how to run each one. This directory holds the result write-ups: machine, raw tables, and short findings. + +| Bench | Doc | +|---|---| +| cycle_bench | [cycle_bench.md](cycle_bench.md) | +| crypto_primitives_bench | [crypto_primitives_bench.md](crypto_primitives_bench.md) | +| integration_bench | [integration_bench.md](integration_bench.md) | + +All numbers are from a single M2 Pro dev box unless noted otherwise. diff --git a/docs/benchmarks/crypto_primitives_bench.md b/docs/benchmarks/crypto_primitives_bench.md new file mode 100644 index 00000000..74c534fc --- /dev/null +++ b/docs/benchmarks/crypto_primitives_bench.md @@ -0,0 +1,56 @@ +# crypto_primitives_bench + +Cryptographic primitives used by client/wallet code. Measures the per-call cost of key derivation, sender-side DH for note encryption, and Account note symmetric encrypt/decrypt. Standalone host binary, no live stack required. + +## Machine + +| Field | Value | +|---|---| +| Chip | Apple M2 Pro (8P+4E) | +| RAM | 16 GB | +| OS | macOS 15.5 | +| Rust | 1.94.0 | +| Profile | release | + +## Results + +Criterion sample_size = 50, warm_up_time = 2 s, measurement_time = 10 s. Slope-regression point estimate in the middle column; 95% confidence interval bounds in the outer columns. + +| Operation | low | point | high | outliers (mild + severe) | +|---|---:|---:|---:|---:| +| keychain/new_os_random | 3.11 ms | 3.21 ms | 3.34 ms | 3 + 5 | +| keychain/new_mnemonic | 3.05 ms | 3.11 ms | 3.23 ms | 0 + 2 | +| shared_secret_key/sender_dh | 76.7 µs | 78.4 µs | 80.6 µs | 3 + 4 | +| encryption/encrypt | 1.11 µs | 1.17 µs | 1.25 µs | 1 + 5 | +| encryption/decrypt | 907 ns | 928 ns | 954 ns | 0 + 3 | + +Numbers from a single M2 Pro dev box. For full estimates (slope, mean, median, MAD, std-dev) and the noise model, see `target/criterion///estimates.json` after running locally. + +## Findings + +- Keychain creation is dominated by the 2048-round HMAC-SHA512 PBKDF in the mnemonic-to-SSK path. ≈ 3 ms. +- Per-recipient DH (secp256k1) is ≈ 80 µs. Outbound shielded transfers to N recipients cost ≈ 80·N µs of crypto on top of proving. +- Symmetric encrypt/decrypt over a 49-byte Account note is sub-µs. Bulk encryption is not the bottleneck. + +## Reproduce + +```sh +cargo bench -p crypto_primitives_bench --bench primitives +``` + +JSON estimates: `target/criterion///estimates.json`. HTML report: `target/criterion/report/index.html`. + +## Baseline comparison + +```sh +# On main: +cargo bench -p crypto_primitives_bench --bench primitives -- --save-baseline main +# On your branch: +cargo bench -p crypto_primitives_bench --bench primitives -- --baseline main +``` + +Criterion reports per-bench change as a percentage with a 95% confidence interval; deltas within the CI are reported as "no significant change" rather than red. + +## Caveats + +- Single-thread, no SIMD acceleration. Bench dev box uses the pure-Rust secp256k1 backend. diff --git a/docs/benchmarks/cycle_bench.md b/docs/benchmarks/cycle_bench.md new file mode 100644 index 00000000..0e880070 --- /dev/null +++ b/docs/benchmarks/cycle_bench.md @@ -0,0 +1,101 @@ +# cycle_bench + +Per-program Risc0 cycle counts, prover wall time, PPE composition cost, and verifier wall time for the built-in LEZ programs. Inputs for the fee model's `G_executor`, `G_prove`, `G_verify`, and `S_agg` parameters. + +## Machine + +| Field | Value | +|---|---| +| Chip | Apple M2 Pro (8P+4E) | +| RAM | 16 GB | +| OS | macOS 15.5 | +| Rust | 1.94.0 | +| Risc0 zkVM | 3.0.5 | +| Profile | release | +| GPU acceleration | none | + +## Executor cycles + +`SessionInfo::cycles()` per instruction. Deterministic across runs. Wall time is `best / mean ± stdev` over 5 timed iterations (1 warmup discarded). + +| Program | Instruction | user_cycles | segments | exec_ms (best / mean ± stdev) | +|---|---|---:|---:|---| +| authenticated_transfer | Initialize | 43,642 | 1 | 18.86 / 19.41 ± 0.48 | +| authenticated_transfer | Transfer | 77,095 | 1 | 19.67 / 20.84 ± 1.16 | +| token | Burn | 116,546 | 1 | 24.86 / 25.46 ± 0.63 | +| token | Mint | 116,862 | 1 | 24.47 / 25.08 ± 0.42 | +| token | Transfer | 127,726 | 1 | 25.00 / 25.40 ± 0.29 | +| clock | Tick (no rollups) | 137,022 | 1 | 21.18 / 21.57 ± 0.41 | +| ata | Create | 175,056 | 1 | 23.64 / 24.94 ± 1.09 | +| amm | SwapExactInput | 508,634 | 1 | 34.21 / 34.77 ± 0.55 | +| amm | AddLiquidity | 642,774 | 1 | 37.59 / 37.87 ± 0.28 | + +## Real proving (`--prove`) + +`prover.prove(env, elf)` wall time per program on CPU. `total_cycles` is `user_cycles` rounded up to the next power of two (Risc0 padding). + +| Program | Instruction | total_cycles | prove_ms | prove_s | +|---|---|---:|---:|---:| +| authenticated_transfer | Initialize | 131,072 | 11,881 | 11.9 | +| authenticated_transfer | Transfer | 131,072 | 13,705 | 13.7 | +| token | Burn | 262,144 | 22,893 | 22.9 | +| token | Mint | 262,144 | 23,927 | 23.9 | +| token | Transfer | 262,144 | 27,178 | 27.2 | +| clock | Tick | 262,144 | 23,486 | 23.5 | +| ata | Create | 262,144 | 21,093 | 21.1 | +| amm | AddLiquidity | 1,048,576 | 111,654 | 111.7 | +| amm | SwapExactInput | 1,048,576 | 126,400 | 126.4 | + +Linear fit across po2 buckets: ≈ 100 µs per total cycle (≈ 10k cycles/s throughput on this CPU). + +## PPE composition + chain-call sweep (`--ppe`) + +Same `auth_transfer Transfer` instruction, standalone vs wrapped in the privacy circuit; plus the `chain_caller` test program with N chained `authenticated_transfer` calls. `proof_bytes` is the borsh-serialized. InnerReceipt (S_agg in the fee model). + +| Case | prove_ms | prove_s | proof_bytes | +|---|---:|---:|---:| +| auth_transfer Transfer standalone | 13,705 | 13.7 | n/a | +| auth_transfer Transfer in PPE | 61,486 | 61.5 | 223,551 | +| chain_caller depth=1 | 122,590 | 122.6 | 223,551 | +| chain_caller depth=3 | 231,974 | 232.0 | 223,551 | +| chain_caller depth=5 | 372,123 | 372.1 | 223,551 | +| chain_caller depth=9 | 544,280 | 544.3 | 223,551 | + +Linear fit depth=1..9: ≈ 53 s per additional chained call, intercept ≈ 73 s. Composition tax (single program PPE − standalone): ≈ 48 s. `proof_bytes` is constant: the outer succinct proof has fixed size; the journal carried alongside it scales with public state and is reported separately by `--verify`. + +## Verifier (criterion bench) + +One PPE receipt generated once (auth_transfer Transfer in PPE), then `Receipt::verify(PRIVACY_PRESERVING_CIRCUIT_ID)` measured under criterion's statistical sampler. Bench file: `tools/cycle_bench/benches/verify.rs`. Setup (one full PPE prove) is outside the timed `iter` loop. + +Numbers from the most recent local run on the machine listed above. Criterion sample_size = 100, measurement_time = 15 s, warm_up_time = 2 s. Slope-regression point estimate in the middle column; 95% CI bounds on either side. Run `cargo bench -p cycle_bench --features ppe --bench verify` to refresh. + +| Bench | low | point | high | outliers (mild + severe) | +|---|---:|---:|---:|---:| +| ppe/verify_auth_transfer | 12.016 ms | 12.215 ms | 12.469 ms | 1 + 10 | + +The corresponding `proof_bytes` (S_agg) for the bench receipt is captured by `--ppe` above; the verify bench itself only times the verify call. + +## Findings + +- Proving cost scales with po2-bucketed `total_cycles`, not raw `user_cycles`. Trimming user_cycles only helps if it crosses a 2^N boundary. +- Single-program PPE composition tax on M2 Pro CPU: ≈ 48 s (61.5 − 13.7). +- Chained-call cost is linear at ≈ 53 s per call. A max-depth chain (10) would take ≈ 600 s standalone on this CPU. +- `G_verify` is ≈ 12 ms (criterion CI: 12.0–12.5 ms over 100 samples) and roughly constant per outer receipt. The succinct outer proof is fixed at 223,551 bytes (S_agg); verify is not on the latency critical path. + +## Reproduce + +```sh +cargo run --release -p cycle_bench +cargo run --release -p cycle_bench --features prove -- --prove +cargo run --release -p cycle_bench --features ppe -- --prove --ppe + +# Verifier microbench via criterion: +cargo bench -p cycle_bench --features ppe --bench verify +``` + +JSON output: `target/cycle_bench.json` (bin), `target/criterion/ppe/verify_auth_transfer/` (verify bench). + +## Caveats + +- CPU-only proving on a dev laptop. Production prover hardware (GPU, specialised CPU pipelines) will produce much smaller numbers; relative ordering should be preserved. +- Single-segment cases only; multi-segment programs would pay continuation overhead not measured here. diff --git a/docs/benchmarks/integration_bench.md b/docs/benchmarks/integration_bench.md new file mode 100644 index 00000000..dd7bfe6e --- /dev/null +++ b/docs/benchmarks/integration_bench.md @@ -0,0 +1,120 @@ +# integration_bench + +End-to-end LEZ scenarios driven through the wallet against a docker-compose Bedrock node + in-process sequencer + indexer (via `test_fixtures::TestContext`). Times each step and records borsh sizes per block, split by tx variant. + +Numbers below are from a single-host docker-compose run on an Apple M2 Pro (CPU only, no GPU acceleration). Absolute wall time and block sizes depend heavily on the bedrock config (block cadence and confirmation depth) and on dev-mode vs real proving; re-run the bench locally to characterise your own setup. + +## Scenarios + +| Scenario | Description | +|---|---| +| token | Sequential public token Send + one shielded recipient setup. | +| amm | Pool create, add liquidity, swap, remove liquidity. All public. | +| fanout | One sender → N recipients, sequential. All public. | +| private | Shielded, deshielded, private→private chained private flow. | +| parallel | N senders submit concurrently into one block. All public. | + +## Dev-mode vs real-proving + +`RISC0_DEV_MODE=1` makes the prover emit stub receipts instead of running the recursive STARK pipeline. The table compares each quantity in dev mode vs real proving for the two classes of scenarios: + +| Quantity | Public-only scenarios (dev → real) | PPE-bearing scenarios (dev → real) | +|---|---|---| +| Wall time per step | same in both modes | real adds ~100 s per PPE step | +| `public_tx_bytes` | same in both modes | same in both modes | +| `ppe_tx_bytes` | n/a | dev ≈ 2 KB stub → real ≈ 225 KB (matches `S_agg` from cycle_bench) | +| `block_bytes` | same in both modes | real adds ~225 KB per PPE tx in the block | +| `bedrock_finality_s` | same in both modes | same in both modes (L1 cadence, not LEZ prover) | +| Blocks captured | similar in both modes | real captures more empty clock-only ticks that fill prove wall-time | + +Tables below report dev-mode for all five scenarios. Real-proving numbers are included for `amm_swap_flow` (representative all-public) and `private_chained_flow` (representative chained-private flow); public-only scenarios converge between modes within run-to-run jitter, so a full real-proving sweep is not run here. + +## Methodology + +Per scenario, every produced block is fetched via `getBlock(BlockId)` and serialized with `borsh::to_vec(&Block)`. Each transaction is serialized individually and counted by variant. Empty clock-only ticks give the per-block fixed-cost baseline. Wall time is captured per step (submit + inclusion + wallet sync) and aggregated to the per-scenario `total_s`. The one-time stack-setup cost (`shared_setup_s` at the run level) and the closing bedrock finality wait (`bedrock_finality_s` per scenario) are reported separately, not folded into `total_s`. + +## Step latencies — dev mode (`RISC0_DEV_MODE=1`) + +Per-scenario wall time and Bedrock L1-finality latency for the closing tip. + +| Scenario | total_s | bedrock_finality_s | +|---|---:|---:| +| token_onboarding | 61.36 | 5.88 | +| amm_swap_flow | 156.50 | 27.99 | +| multi_recipient_fanout | 214.40 | 31.71 | +| private_chained_flow | 109.31 | 8.73 | +| parallel_fanout | 234.42 | 20.29 | + +Shared TestContext setup: 139.80 s (paid once per run). Total dev-mode wall time across all five scenarios: 1010.4 s. + +## Step latencies — real proving (selected scenarios) + +| Scenario | total_s | bedrock_finality_s | Δ vs dev | +|---|---:|---:|---:| +| amm_swap_flow | 156.20 | 26.95 | ~0 (all-public) | +| private_chained_flow | 391.74 | 9.40 | +282.4 s (≈ 94 s per PPE step × 3) | + +Per-step breakdown for `private_chained_flow` in real proving: + +| Step | submit_s | inclusion_s | total_s | +|---|---:|---:|---:| +| token_new_fungible (public) | 0.003 | 10.857 | 11.006 | +| shielded_transfer (PPE) | 125.416 | 0.001 | 125.469 | +| deshielded_transfer (PPE) | 126.261 | 0.001 | 126.311 | +| private_to_private (PPE) | 128.875 | 0.001 | 128.934 | + +PPE steps move the cost from `inclusion_s` (waiting for the next sealed block) to `submit_s` (the wallet itself proving the PPE circuit before sending). Each PPE prove is ≈ 127 s on this CPU. + +## Block + tx sizes (borsh) — dev mode + +Per scenario, every produced block is fetched via `getBlock(BlockId)` and serialized with `borsh::to_vec(&Block)`. Each transaction is serialized individually and counted by variant. The empty clock-only ticks at `min` give the per-block fixed-cost baseline (≈ 334 bytes across all scenarios). + +| Scenario | blocks | block_bytes (mean) | block_bytes (min..max) | public_tx (mean / n) | ppe_tx (mean / n) | +|---|---:|---:|---|---:|---:| +| token_onboarding | 6 | 881 | 334..2,890 | 206 / 8 | 2,556 / 1 | +| amm_swap_flow | 16 | 553 | 334..1,011 | 248 / 24 | n/a | +| multi_recipient_fanout | 22 | 513 | 334..707 | 221 / 33 | n/a | +| private_chained_flow | 10 | 1,186 | 334..3,565 | 173 / 11 | 2,715 / 3 | +| parallel_fanout | 24 | 646 | 334..3,904 | 248 / 45 | n/a | + +## Block + tx sizes (borsh) — real proving + +| Scenario | blocks | block_bytes (mean) | block_bytes (min..max) | public_tx (mean / n) | ppe_tx (mean / n) | +|---|---:|---:|---|---:|---:| +| amm_swap_flow | 16 | 553 | 334..1,011 | 248 / 24 | n/a | +| private_chained_flow | 39 | 17,707 | 334..226,578 | 158 / 40 | 225,728 / 3 | + +`amm_swap_flow` is byte-identical between dev and real (no proof payload). `private_chained_flow`'s `ppe_tx_bytes` matches the cycle_bench `S_agg` measurement (≈ 225 KB borsh InnerReceipt). The `block_bytes` max (226,578) is the block containing the largest PPE transaction. + +## Findings + +- Public-only scenarios converge between dev mode and real proving in both latency and byte counts. Either mode is suitable to characterize them. +- PPE transactions are ≈ 225 KB on the wire in real proving, dominated by the outer succinct proof. Dev mode emits a ≈ 2.7 KB stub that does not represent the L1 payload; fee-model storage gas inputs must come from a real-proving run. +- Per-PPE-step prove cost on this CPU is ≈ 127 s, paid on the wallet side at submit time, not on the sequencer. For a single-program chained flow the cost stacks linearly. +- Empty clock-only ticks set the per-block fixed-cost baseline at ≈ 334 bytes across all scenarios and both modes. +- Bedrock L1 finality varies in the 6 to 32 s range across scenarios, driven by L1 cadence and which tick the closing wait happens to land on, not by the LEZ prover. + +## Reproduce + +Prerequisite: a running local Docker daemon (the `bedrock/docker-compose.yml` is brought up by the bench). + +```sh +# Dev-mode sweep (fast) +RISC0_DEV_MODE=1 cargo run --release -p integration_bench -- --scenario all + +# Real-proving for representative private flow +cargo run --release -p integration_bench -- --scenario private + +# Real-proving for representative public flow +cargo run --release -p integration_bench -- --scenario amm +``` + +JSON output: `target/integration_bench_dev.json` / `target/integration_bench_prove.json` (suffix toggled by `RISC0_DEV_MODE`). + +## Caveats + +- Dev-mode `ppe_tx_bytes` and PPE-step latencies are not representative of production; use real-proving numbers for any fee-model input that touches the storage or prover-cost components. +- Single-host run, no GPU acceleration. Real-proving on production prover hardware will move per-step latencies by orders of magnitude; byte counts will not change. +- Bedrock running locally via docker-compose; no real network latency between sequencer and Bedrock. +- Bedrock L1 finality (`bedrock_finality_s`) is set by the bedrock config in `bedrock/docker-compose.yml` (block cadence × confirmation depth). Different configs will shift `bedrock_finality_s` materially. +- All scenarios share a single TestContext for the run (one bedrock + sequencer + indexer + wallet for the whole run, chain state accumulating across scenarios), which matches how the node runs in production. diff --git a/examples/program_deployment/methods/guest/src/bin/hello_world.rs b/examples/program_deployment/methods/guest/src/bin/hello_world.rs index 810e83f3..3e91db0e 100644 --- a/examples/program_deployment/methods/guest/src/bin/hello_world.rs +++ b/examples/program_deployment/methods/guest/src/bin/hello_world.rs @@ -19,6 +19,8 @@ fn main() { // Read inputs let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction: greeting, }, @@ -50,5 +52,12 @@ fn main() { // with the NSSA program rules. // WARNING: constructing a `ProgramOutput` has no effect on its own. `.write()` must be // called to commit the output. - ProgramOutput::new(instruction_data, vec![pre_state], vec![post_state]).write(); + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_data, + vec![pre_state], + vec![post_state], + ) + .write(); } diff --git a/examples/program_deployment/methods/guest/src/bin/hello_world_with_authorization.rs b/examples/program_deployment/methods/guest/src/bin/hello_world_with_authorization.rs index 62908870..70dfa2ae 100644 --- a/examples/program_deployment/methods/guest/src/bin/hello_world_with_authorization.rs +++ b/examples/program_deployment/methods/guest/src/bin/hello_world_with_authorization.rs @@ -19,6 +19,8 @@ fn main() { // Read inputs let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction: greeting, }, @@ -57,5 +59,12 @@ fn main() { // with the NSSA program rules. // WARNING: constructing a `ProgramOutput` has no effect on its own. `.write()` must be // called to commit the output. - ProgramOutput::new(instruction_data, vec![pre_state], vec![post_state]).write(); + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_data, + vec![pre_state], + vec![post_state], + ) + .write(); } diff --git a/examples/program_deployment/methods/guest/src/bin/hello_world_with_move_function.rs b/examples/program_deployment/methods/guest/src/bin/hello_world_with_move_function.rs index 7e29b5de..4289349b 100644 --- a/examples/program_deployment/methods/guest/src/bin/hello_world_with_move_function.rs +++ b/examples/program_deployment/methods/guest/src/bin/hello_world_with_move_function.rs @@ -66,6 +66,8 @@ fn main() { // Read input accounts. let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction: (function_id, data), }, @@ -85,5 +87,12 @@ fn main() { // WARNING: constructing a `ProgramOutput` has no effect on its own. `.write()` must be // called to commit the output. - ProgramOutput::new(instruction_words, pre_states, post_states).write(); + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + post_states, + ) + .write(); } diff --git a/examples/program_deployment/methods/guest/src/bin/simple_tail_call.rs b/examples/program_deployment/methods/guest/src/bin/simple_tail_call.rs index d2c04083..716e5c29 100644 --- a/examples/program_deployment/methods/guest/src/bin/simple_tail_call.rs +++ b/examples/program_deployment/methods/guest/src/bin/simple_tail_call.rs @@ -27,6 +27,8 @@ fn main() { // Read inputs let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction: (), }, @@ -55,7 +57,13 @@ fn main() { // Write the outputs. // WARNING: constructing a `ProgramOutput` has no effect on its own. `.write()` must be // called to commit the output. - ProgramOutput::new(instruction_data, vec![pre_state], vec![post_state]) - .with_chained_calls(vec![chained_call]) - .write(); + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_data, + vec![pre_state], + vec![post_state], + ) + .with_chained_calls(vec![chained_call]) + .write(); } diff --git a/examples/program_deployment/methods/guest/src/bin/tail_call_with_pda.rs b/examples/program_deployment/methods/guest/src/bin/tail_call_with_pda.rs index 564efc2b..5ec9aaab 100644 --- a/examples/program_deployment/methods/guest/src/bin/tail_call_with_pda.rs +++ b/examples/program_deployment/methods/guest/src/bin/tail_call_with_pda.rs @@ -33,6 +33,8 @@ fn main() { // Read inputs let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction: (), }, @@ -68,7 +70,13 @@ fn main() { // Write the outputs. // WARNING: constructing a `ProgramOutput` has no effect on its own. `.write()` must be // called to commit the output. - ProgramOutput::new(instruction_data, vec![pre_state], vec![post_state]) - .with_chained_calls(vec![chained_call]) - .write(); + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_data, + vec![pre_state], + vec![post_state], + ) + .with_chained_calls(vec![chained_call]) + .write(); } 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 a9750bce..18b4ba80 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 @@ -50,8 +50,8 @@ async fn main() { // Load signing keys to provide authorization let signing_key = wallet_core .storage() - .user_data - .get_pub_account_signing_key(account_id) + .key_chain() + .pub_account_signing_key(account_id) .expect("Input account should be a self owned public account"); // Define the desired greeting in ASCII 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 e6a8ca99..86c95ebf 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 @@ -46,7 +46,7 @@ async fn main() { let program = Program::new(bytecode).unwrap(); // Compute the PDA to pass it as input account to the public execution - let pda = AccountId::from((&program.id(), &PDA_SEED)); + let pda = AccountId::for_public_pda(&program.id(), &PDA_SEED); let account_ids = vec![pda]; let instruction_data = (); let nonces = vec![]; diff --git a/explorer_service/src/api.rs b/explorer_service/src/api.rs index 8c2a0e36..5984a636 100644 --- a/explorer_service/src/api.rs +++ b/explorer_service/src/api.rs @@ -86,7 +86,7 @@ pub async fn get_block_by_id(block_id: BlockId) -> Result /// Get latest block ID #[server] -pub async fn get_latest_block_id() -> Result { +pub async fn get_latest_block_id() -> Result, ServerFnError> { use indexer_service_rpc::RpcClient as _; let client = expect_context::(); client diff --git a/flake.lock b/flake.lock index d0df80e3..1d9f4502 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1769737823, - "narHash": "sha256-DrBaNpZ+sJ4stXm+0nBX7zqZT9t9P22zbk6m5YhQxS4=", + "lastModified": 1776396856, + "narHash": "sha256-aRJpIJUlZLaf06ekPvqjuU46zvO9K90IxJGpbqodkPs=", "owner": "ipetkov", "repo": "crane", - "rev": "b2f45c3830aa96b7456a4c4bc327d04d7a43e1ba", + "rev": "28462d6d55c33206ffa5a56c7907ca3125ed788f", "type": "github" }, "original": { @@ -20,11 +20,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1770979891, - "narHash": "sha256-cvkVnE7btuFLzv70ORAZve9K1Huiplq0iECgXSXb0ZY=", + "lastModified": 1775835011, + "narHash": "sha256-SQDLyyRUa5J9QHjNiHbeZw4rQOZnTEo61TcaUpjtLBs=", "owner": "logos-blockchain", "repo": "logos-blockchain-circuits", - "rev": "ec7d298e5a3a0507bb8570df86cdf78dc452d024", + "rev": "d6cf41f66500d4afc157b4f43de0f0d5bfa01443", "type": "github" }, "original": { @@ -51,11 +51,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1770019141, - "narHash": "sha256-VKS4ZLNx4PNrABoB0L8KUpc1fE7CLpQXQs985tGfaCU=", + "lastModified": 1776169885, + "narHash": "sha256-l/iNYDZ4bGOAFQY2q8y5OAfBBtrDAaPuRQqWaFHVRXM=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "cb369ef2efd432b3cdf8622b0ffc0a97a02f3137", + "rev": "4bd9165a9165d7b5e33ae57f3eecbcb28fb231c9", "type": "github" }, "original": { @@ -80,11 +80,11 @@ ] }, "locked": { - "lastModified": 1770088046, - "narHash": "sha256-4hfYDnUTvL1qSSZEA4CEThxfz+KlwSFQ30Z9jgDguO0=", + "lastModified": 1776395632, + "narHash": "sha256-Mi1uF5f2FsdBIvy+v7MtsqxD3Xjhd0ARJdwoqqqPtJo=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "71f9daa4e05e49c434d08627e755495ae222bc34", + "rev": "8087ff1f47fff983a1fba70fa88b759f2fd8ae97", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index be20b56b..b4adfe71 100644 --- a/flake.nix +++ b/flake.nix @@ -2,7 +2,9 @@ description = "Logos Execution Zone"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + logos-liblogos.url = "github:logos-co/logos-liblogos"; + + nixpkgs.follows = "logos-liblogos/nixpkgs"; rust-overlay = { url = "github:oxalica/rust-overlay"; @@ -130,9 +132,26 @@ ''; } ); + + indexerFfiPackage = craneLib.buildPackage ( + commonArgs + // { + pname = "logos-execution-zone-indexer-ffi"; + version = "0.1.0"; + cargoExtraArgs = "-p indexer_ffi"; + postInstall = '' + mkdir -p $out/include + cp indexer/ffi/indexer_ffi.h $out/include/ + '' + + pkgs.lib.optionalString pkgs.stdenv.isDarwin '' + install_name_tool -id @rpath/libindexer_ffi.dylib $out/lib/libindexer_ffi.dylib + ''; + } + ); in { wallet = walletFfiPackage; + indexer = indexerFfiPackage; default = walletFfiPackage; } ); @@ -144,9 +163,14 @@ walletFfiShell = pkgs.mkShell { inputsFrom = [ walletFfiPackage ]; }; + indexerFfiPackage = self.packages.${system}.indexer; + indexerFfiShell = pkgs.mkShell { + inputsFrom = [ indexerFfiPackage ]; + }; in { wallet = walletFfiShell; + indexer = indexerFfiShell; default = walletFfiShell; } ); diff --git a/indexer/core/Cargo.toml b/indexer/core/Cargo.toml index 33fe2d9d..6c7ad01f 100644 --- a/indexer/core/Cargo.toml +++ b/indexer/core/Cargo.toml @@ -9,7 +9,7 @@ workspace = true [dependencies] common.workspace = true -bedrock_client.workspace = true +logos-blockchain-zone-sdk.workspace = true nssa.workspace = true nssa_core.workspace = true storage.workspace = true @@ -19,13 +19,14 @@ anyhow.workspace = true log.workspace = true serde.workspace = true humantime-serde.workspace = true -tokio.workspace = true borsh.workspace = true futures.workspace = true url.workspace = true logos-blockchain-core.workspace = true serde_json.workspace = true async-stream.workspace = true +tokio.workspace = true [dev-dependencies] tempfile.workspace = true +authenticated_transfer_core.workspace = true diff --git a/indexer/core/src/block_store.rs b/indexer/core/src/block_store.rs index 7faf5376..b66b778f 100644 --- a/indexer/core/src/block_store.rs +++ b/indexer/core/src/block_store.rs @@ -1,11 +1,13 @@ use std::{path::Path, sync::Arc}; -use anyhow::Result; -use bedrock_client::HeaderId; +use anyhow::{Context as _, Result}; use common::{ block::{BedrockStatus, Block}, - transaction::NSSATransaction, + transaction::{NSSATransaction, clock_invocation}, }; +use log::info; +use logos_blockchain_core::{header::HeaderId, mantle::ops::channel::MsgId}; +use logos_blockchain_zone_sdk::Slot; use nssa::{Account, AccountId, V03State}; use nssa_core::BlockId; use storage::indexer::RocksDBIO; @@ -20,14 +22,10 @@ pub struct IndexerStore { impl IndexerStore { /// Starting database at the start of new chain. /// Creates files if necessary. - /// - /// ATTENTION: Will overwrite genesis block. - pub fn open_db_with_genesis( - location: &Path, - genesis_block: &Block, - initial_state: &V03State, - ) -> Result { - let dbio = RocksDBIO::open_or_create(location, genesis_block, initial_state)?; + pub fn open_db(location: &Path) -> Result { + let initial_state = testnet_initial_state::initial_state(); + let dbio = RocksDBIO::open_or_create(location, &initial_state)?; + let current_state = dbio.final_state()?; Ok(Self { @@ -43,8 +41,8 @@ impl IndexerStore { .map(HeaderId::from)) } - pub fn get_last_block_id(&self) -> Result { - Ok(self.dbio.get_meta_last_block_in_db()?) + pub fn get_last_block_id(&self) -> Result> { + self.dbio.get_meta_last_block_id_in_db().map_err(Into::into) } pub fn get_block_at_id(&self, id: u64) -> Result> { @@ -85,24 +83,36 @@ impl IndexerStore { Ok(self.dbio.get_acc_transactions(acc_id, offset, limit)?) } - #[must_use] - pub fn genesis_id(&self) -> u64 { + pub fn genesis_id(&self) -> Result> { self.dbio - .get_meta_first_block_in_db() - .expect("Must be set at the DB startup") + .get_meta_first_block_id_in_db() + .map_err(Into::into) } - #[must_use] - pub fn last_block(&self) -> u64 { - self.dbio - .get_meta_last_block_in_db() - .expect("Must be set at the DB startup") + pub fn last_block(&self) -> Result> { + self.dbio.get_meta_last_block_id_in_db().map_err(Into::into) } pub fn get_state_at_block(&self, block_id: u64) -> Result { Ok(self.dbio.calculate_state_for_id(block_id)?) } + pub fn get_zone_cursor(&self) -> Result> { + let Some(bytes) = self.dbio.get_zone_sdk_indexer_cursor_bytes()? else { + return Ok(None); + }; + let cursor: (MsgId, Slot) = serde_json::from_slice(&bytes) + .context("Failed to deserialize stored zone-sdk indexer cursor")?; + Ok(Some(cursor)) + } + + pub fn set_zone_cursor(&self, cursor: &(MsgId, Slot)) -> Result<()> { + let bytes = + serde_json::to_vec(cursor).context("Failed to serialize zone-sdk indexer cursor")?; + self.dbio.put_zone_sdk_indexer_cursor_bytes(&bytes)?; + Ok(()) + } + /// Recalculation of final state directly from DB. /// /// Used for indexer healthcheck. @@ -118,20 +128,66 @@ impl IndexerStore { .get_account_by_id(*account_id)) } + pub fn account_state_at_block(&self, account_id: &AccountId, block_id: u64) -> Result { + Ok(self + .get_state_at_block(block_id)? + .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); { let mut state_guard = self.current_state.write().await; - for transaction in &block.body.transactions { - transaction - .clone() - .transaction_stateless_check()? - .execute_check_on_state( - &mut state_guard, - block.header.block_id, - block.header.timestamp, - )?; + let (clock_tx, user_txs) = block + .body + .transactions + .split_last() + .ok_or_else(|| anyhow::anyhow!("Block has no transactions"))?; + + anyhow::ensure!( + *clock_tx == NSSATransaction::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 { + NSSATransaction::Public(public_tx) => public_tx, + NSSATransaction::PrivacyPreserving(_) + | NSSATransaction::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()? + .execute_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 NSSATransaction::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, + )?; } // ToDo: Currently we are fetching only finalized blocks @@ -139,98 +195,131 @@ impl IndexerStore { // to represent correct block finality block.bedrock_status = BedrockStatus::Finalized; + info!("Putting block {} into DB", block.header.block_id); Ok(self.dbio.put_block(&block, l1_header.into())?) } } #[cfg(test)] mod tests { - use nssa::{AccountId, PublicKey}; + use common::{HashType, block::HashableBlockData}; use tempfile::tempdir; + use testnet_initial_state::initial_pub_accounts_private_keys; use super::*; - fn genesis_block() -> Block { - common::test_utils::produce_dummy_block(1, None, vec![]) - } - - fn acc1_sign_key() -> nssa::PrivateKey { - nssa::PrivateKey::try_new([1; 32]).unwrap() - } - - fn acc2_sign_key() -> nssa::PrivateKey { - nssa::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())) - } - #[test] fn correct_startup() { let home = tempdir().unwrap(); - let storage = IndexerStore::open_db_with_genesis( - home.as_ref(), - &genesis_block(), - &nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[]), - ) - .unwrap(); + let storage = IndexerStore::open_db(home.as_ref()).unwrap(); - let block = storage.get_block_at_id(1).unwrap().unwrap(); let final_id = storage.get_last_block_id().unwrap(); - assert_eq!(block.header.hash, genesis_block().header.hash); - assert_eq!(final_id, 1); + assert_eq!(final_id, None); } #[tokio::test] async fn state_transition() { let home = tempdir().unwrap(); - let storage = IndexerStore::open_db_with_genesis( - home.as_ref(), - &genesis_block(), - &nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[]), - ) - .unwrap(); + let storage = IndexerStore::open_db(home.as_ref()).unwrap(); - let mut prev_hash = genesis_block().header.hash; + 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(); - let from = acc1(); - let to = acc2(); - let sign_key = acc1_sign_key(); + // Submit genesis block + let clock_tx = NSSATransaction::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(), + [0; 32], + ); + let mut prev_hash = Some(genesis_block.header.hash); + storage + .put_block(genesis_block, HeaderId::from([0_u8; 32])) + .await + .unwrap(); - for i in 2..10 { + for i in 0..10 { let tx = common::test_utils::create_transaction_native_token_transfer( - from, - i - 2, - to, - 10, - &sign_key, + from, i, to, 10, &sign_key, ); + let block_id = u64::try_from(i + 1).unwrap(); - let next_block = common::test_utils::produce_dummy_block( - u64::try_from(i).unwrap(), - Some(prev_hash), - vec![tx], - ); - prev_hash = next_block.header.hash; + 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).unwrap(); 32])) + .put_block( + next_block, + HeaderId::from([u8::try_from(i + 1).unwrap(); 32]), + ) .await .unwrap(); } - let acc1_val = storage.account_current_state(&acc1()).await.unwrap(); - let acc2_val = storage.account_current_state(&acc2()).await.unwrap(); + 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, 9920); - assert_eq!(acc2_val.balance, 20080); + assert_eq!(acc1_val.balance, 9900); + assert_eq!(acc2_val.balance, 20100); + } + + #[tokio::test] + async fn account_state_at_block() { + let home = tempdir().unwrap(); + + let storage = IndexerStore::open_db(home.as_ref()).unwrap(); + + let mut prev_hash = None; + + 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 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(); + } + + // 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); } } diff --git a/indexer/core/src/config.rs b/indexer/core/src/config.rs index 291e54f5..6a019828 100644 --- a/indexer/core/src/config.rs +++ b/indexer/core/src/config.rs @@ -6,18 +6,14 @@ use std::{ }; use anyhow::{Context as _, Result}; -pub use bedrock_client::BackoffConfig; use common::config::BasicAuth; use humantime_serde; pub use logos_blockchain_core::mantle::ops::channel::ChannelId; use serde::{Deserialize, Serialize}; -use testnet_initial_state::{PrivateAccountPublicInitialData, PublicAccountPublicInitialData}; use url::Url; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClientConfig { - /// For individual RPC requests we use Fibonacci backoff retry strategy. - pub backoff: BackoffConfig, pub addr: Url, #[serde(default, skip_serializing_if = "Option::is_none")] pub auth: Option, @@ -25,18 +21,12 @@ pub struct ClientConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IndexerConfig { - /// Home dir of sequencer storage. + /// Home dir of indexer storage. pub home: PathBuf, - /// Sequencers signing key. - pub signing_key: [u8; 32], #[serde(with = "humantime_serde")] pub consensus_info_polling_interval: Duration, - pub bedrock_client_config: ClientConfig, + pub bedrock_config: ClientConfig, pub channel_id: ChannelId, - #[serde(skip_serializing_if = "Option::is_none")] - pub initial_public_accounts: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub initial_private_accounts: Option>, } impl IndexerConfig { diff --git a/indexer/core/src/lib.rs b/indexer/core/src/lib.rs index bcd99ad7..400d0a9d 100644 --- a/indexer/core/src/lib.rs +++ b/indexer/core/src/lib.rs @@ -1,18 +1,14 @@ -use std::collections::VecDeque; +use std::sync::Arc; use anyhow::Result; -use bedrock_client::{BedrockClient, HeaderId}; -use common::{ - HashType, PINATA_BASE58, - block::{Block, HashableBlockData}, +use common::block::Block; +// 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, }; -use log::{debug, error, info}; -use logos_blockchain_core::mantle::{ - Op, SignedMantleTx, - ops::channel::{ChannelId, inscribe::InscriptionOp}, -}; -use nssa::V03State; -use testnet_initial_state::initial_state_testnet; use crate::{block_store::IndexerStore, config::IndexerConfig}; @@ -21,343 +17,97 @@ pub mod config; #[derive(Clone)] pub struct IndexerCore { - pub bedrock_client: BedrockClient, + pub zone_indexer: Arc>, pub config: IndexerConfig, pub store: IndexerStore, } -#[derive(Clone)] -/// This struct represents one L1 block data fetched from backfilling. -pub struct BackfillBlockData { - l2_blocks: Vec, - l1_header: HeaderId, -} - -#[derive(Clone)] -/// This struct represents data fetched fom backfilling in one iteration. -pub struct BackfillData { - block_data: VecDeque, - curr_fin_l1_lib_header: HeaderId, -} - impl IndexerCore { pub fn new(config: IndexerConfig) -> Result { - let hashable_data = HashableBlockData { - block_id: 1, - transactions: vec![], - prev_block_hash: HashType([0; 32]), - timestamp: 0, - }; - - // Genesis creation is fine as it is, - // because it will be overwritten by sequencer. - // Therefore: - // ToDo: remove key from indexer config, use some default. - let signing_key = nssa::PrivateKey::try_new(config.signing_key).unwrap(); - let channel_genesis_msg_id = [0; 32]; - let genesis_block = hashable_data.into_pending_block(&signing_key, channel_genesis_msg_id); - - let initial_commitments: Option> = config - .initial_private_accounts - .as_ref() - .map(|initial_commitments| { - initial_commitments - .iter() - .map(|init_comm_data| { - let npk = &init_comm_data.npk; - - let mut acc = init_comm_data.account.clone(); - - acc.program_owner = - nssa::program::Program::authenticated_transfer_program().id(); - - nssa_core::Commitment::new(npk, &acc) - }) - .collect() - }); - - let init_accs: Option> = config - .initial_public_accounts - .as_ref() - .map(|initial_accounts| { - initial_accounts - .iter() - .map(|acc_data| (acc_data.account_id, acc_data.balance)) - .collect() - }); - - // If initial commitments or accounts are present in config, need to construct state from - // them - let state = if initial_commitments.is_some() || init_accs.is_some() { - let mut state = V03State::new_with_genesis_accounts( - &init_accs.unwrap_or_default(), - &initial_commitments.unwrap_or_default(), - ); - - // ToDo: Remove after testnet - state.add_pinata_program(PINATA_BASE58.parse().unwrap()); - - state - } else { - initial_state_testnet() - }; - let home = config.home.join("rocksdb"); + let basic_auth = config.bedrock_config.auth.clone().map(Into::into); + let node = NodeHttpClient::new( + CommonHttpClient::new(basic_auth), + config.bedrock_config.addr.clone(), + ); + let zone_indexer = ZoneIndexer::new(config.channel_id, node); + Ok(Self { - bedrock_client: BedrockClient::new( - config.bedrock_client_config.backoff, - config.bedrock_client_config.addr.clone(), - config.bedrock_client_config.auth.clone(), - )?, + zone_indexer: Arc::new(zone_indexer), config, - store: IndexerStore::open_db_with_genesis(&home, &genesis_block, &state)?, + store: IndexerStore::open_db(&home)?, }) } - pub fn subscribe_parse_block_stream(&self) -> impl futures::Stream> { + pub fn subscribe_parse_block_stream(&self) -> impl futures::Stream> + '_ { + let poll_interval = self.config.consensus_info_polling_interval; + let initial_cursor = self + .store + .get_zone_cursor() + .expect("Failed to load zone-sdk indexer cursor"); + async_stream::stream! { - info!("Searching for initial header"); + let mut cursor = initial_cursor; - let last_stored_l1_lib_header = self.store.last_observed_l1_lib_header()?; - - let mut prev_last_l1_lib_header = if let Some(last_l1_lib_header) = last_stored_l1_lib_header { - info!("Last l1 lib header found: {last_l1_lib_header}"); - last_l1_lib_header + if cursor.is_some() { + info!("Resuming indexer from cursor {cursor:?}"); } else { - info!("Last l1 lib header not found in DB"); - info!("Searching for the start of a channel"); - - let BackfillData { - block_data: start_buff, - curr_fin_l1_lib_header: last_l1_lib_header, - } = self.search_for_channel_start().await?; - - for BackfillBlockData { - l2_blocks: l2_block_vec, - l1_header, - } in start_buff { - let mut l2_blocks_parsed_ids: Vec<_> = l2_block_vec.iter().map(|block| block.header.block_id).collect(); - l2_blocks_parsed_ids.sort_unstable(); - info!("Parsed {} L2 blocks with ids {:?}", l2_block_vec.len(), l2_blocks_parsed_ids); - - for l2_block in l2_block_vec { - self.store.put_block(l2_block.clone(), l1_header).await?; - - yield Ok(l2_block); - } - } - - last_l1_lib_header - }; - - info!("Searching for initial header finished"); - - info!("Starting backfilling from {prev_last_l1_lib_header}"); + info!("Starting indexer from beginning of channel"); + } loop { - let BackfillData { - block_data: buff, - curr_fin_l1_lib_header, - } = self - .backfill_to_last_l1_lib_header_id(prev_last_l1_lib_header, &self.config.channel_id) - .await - .inspect_err(|err| error!("Failed to backfill to last l1 lib header id with err {err:#?}"))?; - - prev_last_l1_lib_header = curr_fin_l1_lib_header; - - for BackfillBlockData { - l2_blocks: l2_block_vec, - l1_header: header, - } in buff { - let mut l2_blocks_parsed_ids: Vec<_> = l2_block_vec.iter().map(|block| block.header.block_id).collect(); - l2_blocks_parsed_ids.sort_unstable(); - info!("Parsed {} L2 blocks with ids {:?}", l2_block_vec.len(), l2_blocks_parsed_ids); - - for l2_block in l2_block_vec { - self.store.put_block(l2_block.clone(), header).await?; - - yield Ok(l2_block); + let stream = match self.zone_indexer.next_messages(cursor).await { + Ok(s) => s, + Err(err) => { + error!("Failed to start zone-sdk next_messages stream: {err}"); + tokio::time::sleep(poll_interval).await; + continue; } - } - } - } - } - - async fn get_lib(&self) -> Result { - Ok(self.bedrock_client.get_consensus_info().await?.lib) - } - - async fn get_next_lib(&self, prev_lib: HeaderId) -> Result { - loop { - let next_lib = self.get_lib().await?; - if next_lib == prev_lib { - info!( - "Wait {:?} to not spam the node", - self.config.consensus_info_polling_interval - ); - tokio::time::sleep(self.config.consensus_info_polling_interval).await; - } else { - break Ok(next_lib); - } - } - } - - /// WARNING: depending on channel state, - /// may take indefinite amount of time. - pub async fn search_for_channel_start(&self) -> Result { - let mut curr_last_l1_lib_header = self.get_lib().await?; - let mut backfill_start = curr_last_l1_lib_header; - // ToDo: How to get root? - let mut backfill_limit = HeaderId::from([0; 32]); - // ToDo: Not scalable, initial buffer should be stored in DB to not run out of memory - // Don't want to complicate DB even more right now. - let mut block_buffer = VecDeque::new(); - - 'outer: loop { - let mut cycle_header = curr_last_l1_lib_header; - - loop { - let Some(cycle_block) = self.bedrock_client.get_block_by_id(cycle_header).await? - else { - // First run can reach root easily - // so here we are optimistic about L1 - // failing to get parent. - break; }; + let mut stream = std::pin::pin!(stream); - // It would be better to have id, but block does not have it, so slot will do. - info!( - "INITIAL SEARCH: Observed L1 block at slot {}", - cycle_block.header().slot().into_inner() - ); - debug!( - "INITIAL SEARCH: This block header is {}", - cycle_block.header().id() - ); - debug!( - "INITIAL SEARCH: This block parent is {}", - cycle_block.header().parent() - ); + while let Some((msg, slot)) = stream.next().await { + 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. + ZoneMessage::Deposit(_) | ZoneMessage::Withdraw(_) => continue, + }; - let (l2_block_vec, l1_header) = - parse_block_owned(&cycle_block, &self.config.channel_id); + 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((zone_block.id, slot)); + if let Err(err) = self.store.set_zone_cursor(&(zone_block.id, slot)) { + warn!("Failed to persist indexer cursor: {err:#}"); + } + continue; + } + }; - info!("Parsed {} L2 blocks", l2_block_vec.len()); + info!("Indexed L2 block {}", block.header.block_id); - if !l2_block_vec.is_empty() { - block_buffer.push_front(BackfillBlockData { - l2_blocks: l2_block_vec.clone(), - l1_header, - }); - } - - if let Some(first_l2_block) = l2_block_vec.first() - && first_l2_block.header.block_id == 1 - { - info!("INITIAL_SEARCH: Found channel start"); - break 'outer; - } - - // Step back to parent - let parent = cycle_block.header().parent(); - - if parent == backfill_limit { - break; - } - - cycle_header = parent; - } - - info!("INITIAL_SEARCH: Reached backfill limit, refetching last l1 lib header"); - - block_buffer.clear(); - backfill_limit = backfill_start; - curr_last_l1_lib_header = self.get_next_lib(curr_last_l1_lib_header).await?; - backfill_start = curr_last_l1_lib_header; - } - - Ok(BackfillData { - block_data: block_buffer, - curr_fin_l1_lib_header: curr_last_l1_lib_header, - }) - } - - pub async fn backfill_to_last_l1_lib_header_id( - &self, - last_fin_l1_lib_header: HeaderId, - channel_id: &ChannelId, - ) -> Result { - let curr_fin_l1_lib_header = self.get_next_lib(last_fin_l1_lib_header).await?; - // ToDo: Not scalable, buffer should be stored in DB to not run out of memory - // Don't want to complicate DB even more right now. - let mut block_buffer = VecDeque::new(); - - let mut cycle_header = curr_fin_l1_lib_header; - loop { - let Some(cycle_block) = self.bedrock_client.get_block_by_id(cycle_header).await? else { - return Err(anyhow::anyhow!("Parent not found")); - }; - - if cycle_block.header().id() == last_fin_l1_lib_header { - break; - } - // Step back to parent - cycle_header = cycle_block.header().parent(); - - // It would be better to have id, but block does not have it, so slot will do. - info!( - "Observed L1 block at slot {}", - cycle_block.header().slot().into_inner() - ); - - let (l2_block_vec, l1_header) = parse_block_owned(&cycle_block, channel_id); - - info!("Parsed {} L2 blocks", l2_block_vec.len()); - - if !l2_block_vec.is_empty() { - block_buffer.push_front(BackfillBlockData { - l2_blocks: l2_block_vec, - l1_header, - }); - } - } - - Ok(BackfillData { - block_data: block_buffer, - curr_fin_l1_lib_header, - }) - } -} - -fn parse_block_owned( - l1_block: &bedrock_client::Block, - decoded_channel_id: &ChannelId, -) -> (Vec, HeaderId) { - ( - #[expect( - clippy::wildcard_enum_match_arm, - reason = "We are only interested in channel inscription ops, so it's fine to ignore the rest" - )] - l1_block - .transactions() - .flat_map(|tx| { - tx.mantle_tx.ops.iter().filter_map(|op| match op { - Op::ChannelInscribe(InscriptionOp { - channel_id, - inscription, - .. - }) if channel_id == decoded_channel_id => { - borsh::from_slice::(inscription) - .inspect_err(|err| { - error!("Failed to deserialize our inscription with err: {err:#?}"); - }) - .ok() + // 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); } - _ => None, - }) - }) - .collect(), - l1_block.header().id(), - ) + + cursor = Some((zone_block.id, slot)); + if let Err(err) = self.store.set_zone_cursor(&(zone_block.id, slot)) { + warn!("Failed to persist indexer cursor: {err:#}"); + } + yield Ok(block); + } + + // Stream ended (caught up to LIB). Sleep then poll again. + tokio::time::sleep(poll_interval).await; + } + } + } } diff --git a/indexer/ffi/Cargo.toml b/indexer/ffi/Cargo.toml new file mode 100644 index 00000000..1e6b1468 --- /dev/null +++ b/indexer/ffi/Cargo.toml @@ -0,0 +1,32 @@ +[package] +edition = "2024" +license = { workspace = true } +name = "indexer_ffi" +version = "0.1.0" + +[dependencies] +nssa.workspace = true +indexer_service.workspace = true +indexer_service_rpc = { workspace = true, features = ["client"] } +indexer_service_protocol.workspace = true + +url.workspace = true +log = { workspace = true } +tokio = { features = ["rt-multi-thread"], workspace = true } +jsonrpsee.workspace = true +anyhow.workspace = true + +[build-dependencies] +cbindgen = "0.29" + +[lib] +crate-type = ["rlib", "cdylib", "staticlib"] +name = "indexer_ffi" + +[lints] +workspace = true + +[package.metadata.cargo-machete] +ignored = [ + "cbindgen", +] # machete does not recognize this for build dep and complains. diff --git a/indexer/ffi/build.rs b/indexer/ffi/build.rs new file mode 100644 index 00000000..92c95407 --- /dev/null +++ b/indexer/ffi/build.rs @@ -0,0 +1,12 @@ +use std::env; + +fn main() { + let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + println!("cargo:rerun-if-changed=src/"); + cbindgen::Builder::new() + .with_crate(crate_dir) + .with_language(cbindgen::Language::C) + .generate() + .expect("Unable to generate bindings") + .write_to_file("indexer_ffi.h"); +} diff --git a/indexer/ffi/cbindgen.toml b/indexer/ffi/cbindgen.toml new file mode 100644 index 00000000..79f622b7 --- /dev/null +++ b/indexer/ffi/cbindgen.toml @@ -0,0 +1,2 @@ +language = "C" # For increased compatibility +no_includes = true diff --git a/indexer/ffi/indexer_ffi.h b/indexer/ffi/indexer_ffi.h new file mode 100644 index 00000000..b2ba41bf --- /dev/null +++ b/indexer/ffi/indexer_ffi.h @@ -0,0 +1,752 @@ +#include +#include +#include +#include + +typedef enum OperationStatus { + Ok = 0, + NullPointer = 1, + InitializationError = 2, + ClientError = 3, +} OperationStatus; + +typedef enum FfiTransactionKind { + Public = 0, + Private, + ProgramDeploy, +} FfiTransactionKind; + +typedef enum FfiBedrockStatus { + Pending = 0, + Safe, + Finalized, +} FfiBedrockStatus; + +typedef struct Option_u64 Option_u64; + +typedef struct IndexerServiceFFI { + void *indexer_handle; + void *indexer_client; +} IndexerServiceFFI; + +/** + * Simple wrapper around a pointer to a value or an error. + * + * Pointer is not guaranteed. You should check the error field before + * dereferencing the pointer. + */ +typedef struct PointerResult_IndexerServiceFFI__OperationStatus { + struct IndexerServiceFFI *value; + enum OperationStatus error; +} PointerResult_IndexerServiceFFI__OperationStatus; + +typedef struct PointerResult_IndexerServiceFFI__OperationStatus InitializedIndexerServiceFFIResult; + +typedef enum PointerKind_Tag { + Owned, + Borrowed, + Null, +} PointerKind_Tag; + +typedef struct PointerKind { + PointerKind_Tag tag; + union { + struct { + void *owned; + }; + struct { + const void *borrowed; + }; + }; +} PointerKind; + +typedef struct Pointer_Runtime { + struct PointerKind kind; +} Pointer_Runtime; + +/** + * Wrapper around [`tokio::runtime::Runtime`] that can be safely passed across the FFI boundary. + */ +typedef struct Runtime { + struct Pointer_Runtime inner; +} Runtime; + +/** + * Simple wrapper around a pointer to a value or an error. + * + * Pointer is not guaranteed. You should check the error field before + * dereferencing the pointer. + */ +typedef struct PointerResult_Runtime__OperationStatus { + struct Runtime *value; + enum OperationStatus error; +} PointerResult_Runtime__OperationStatus; + +/** + * Simple wrapper around a pointer to a value or an error. + * + * Pointer is not guaranteed. You should check the error field before + * dereferencing the pointer. + */ +typedef struct PointerResult_Option_u64_____OperationStatus { + struct Option_u64 *value; + enum OperationStatus error; +} PointerResult_Option_u64_____OperationStatus; + +typedef uint64_t FfiBlockId; + +/** + * 32-byte array type for `AccountId`, keys, hashes, etc. + */ +typedef struct FfiBytes32 { + uint8_t data[32]; +} FfiBytes32; + +typedef struct FfiBytes32 FfiHashType; + +typedef uint64_t FfiTimestamp; + +/** + * 64-byte array type for signatures, etc. + */ +typedef struct FfiBytes64 { + uint8_t data[64]; +} FfiBytes64; + +typedef struct FfiBytes64 FfiSignature; + +typedef struct FfiBlockHeader { + FfiBlockId block_id; + FfiHashType prev_block_hash; + FfiHashType hash; + FfiTimestamp timestamp; + FfiSignature signature; +} FfiBlockHeader; + +/** + * Program ID - 8 u32 values (32 bytes total). + */ +typedef struct FfiProgramId { + uint32_t data[8]; +} FfiProgramId; + +typedef struct FfiBytes32 FfiAccountId; + +typedef struct FfiVec_FfiAccountId { + FfiAccountId *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiAccountId; + +typedef struct FfiVec_FfiAccountId FfiAccountIdList; + +/** + * U128 - 16 bytes little endian. + */ +typedef struct FfiU128 { + uint8_t data[16]; +} FfiU128; + +typedef struct FfiU128 FfiNonce; + +typedef struct FfiVec_FfiNonce { + FfiNonce *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiNonce; + +typedef struct FfiVec_FfiNonce FfiNonceList; + +typedef struct FfiVec_u32 { + uint32_t *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_u32; + +typedef struct FfiVec_u32 FfiInstructionDataList; + +typedef struct FfiPublicMessage { + struct FfiProgramId program_id; + FfiAccountIdList account_ids; + FfiNonceList nonces; + FfiInstructionDataList instruction_data; +} FfiPublicMessage; + +typedef struct FfiBytes32 FfiPublicKey; + +typedef struct FfiSignaturePubKeyEntry { + FfiSignature signature; + FfiPublicKey public_key; +} FfiSignaturePubKeyEntry; + +typedef struct FfiVec_FfiSignaturePubKeyEntry { + struct FfiSignaturePubKeyEntry *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiSignaturePubKeyEntry; + +typedef struct FfiVec_FfiSignaturePubKeyEntry FfiSignaturePubKeyList; + +typedef struct FfiPublicTransactionBody { + FfiHashType hash; + struct FfiPublicMessage message; + FfiSignaturePubKeyList witness_set; +} FfiPublicTransactionBody; + +/** + * Account data structure - C-compatible version of nssa Account. + * + * Note: `balance` and `nonce` are u128 values represented as little-endian + * byte arrays since C doesn't have native u128 support. + */ +typedef struct FfiAccount { + struct FfiProgramId program_owner; + /** + * Balance as little-endian [u8; 16]. + */ + struct FfiU128 balance; + /** + * Pointer to account data bytes. + */ + uint8_t *data; + /** + * Length of account data. + */ + uintptr_t data_len; + /** + * Capacity of account data. + */ + uintptr_t data_cap; + /** + * Nonce as little-endian [u8; 16]. + */ + struct FfiU128 nonce; +} FfiAccount; + +typedef struct FfiVec_FfiAccount { + struct FfiAccount *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiAccount; + +typedef struct FfiVec_FfiAccount FfiAccountList; + +typedef struct FfiVec_u8 { + uint8_t *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_u8; + +typedef struct FfiVec_u8 FfiVecU8; + +typedef struct FfiEncryptedAccountData { + FfiVecU8 ciphertext; + FfiVecU8 epk; + uint8_t view_tag; +} FfiEncryptedAccountData; + +typedef struct FfiVec_FfiEncryptedAccountData { + struct FfiEncryptedAccountData *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiEncryptedAccountData; + +typedef struct FfiVec_FfiEncryptedAccountData FfiEncryptedAccountDataList; + +typedef struct FfiVec_FfiBytes32 { + struct FfiBytes32 *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiBytes32; + +typedef struct FfiVec_FfiBytes32 FfiVecBytes32; + +typedef struct FfiNullifierCommitmentSet { + struct FfiBytes32 nullifier; + struct FfiBytes32 commitment_set_digest; +} FfiNullifierCommitmentSet; + +typedef struct FfiVec_FfiNullifierCommitmentSet { + struct FfiNullifierCommitmentSet *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiNullifierCommitmentSet; + +typedef struct FfiVec_FfiNullifierCommitmentSet FfiNullifierCommitmentSetList; + +typedef struct FfiPrivacyPreservingMessage { + FfiAccountIdList public_account_ids; + FfiNonceList nonces; + FfiAccountList public_post_states; + FfiEncryptedAccountDataList encrypted_private_post_states; + FfiVecBytes32 new_commitments; + FfiNullifierCommitmentSetList new_nullifiers; + uint64_t block_validity_window[2]; + uint64_t timestamp_validity_window[2]; +} FfiPrivacyPreservingMessage; + +typedef FfiVecU8 FfiProof; + +typedef struct FfiPrivateTransactionBody { + FfiHashType hash; + struct FfiPrivacyPreservingMessage message; + FfiSignaturePubKeyList witness_set; + FfiProof proof; +} FfiPrivateTransactionBody; + +typedef FfiVecU8 FfiProgramDeploymentMessage; + +typedef struct FfiProgramDeploymentTransactionBody { + FfiHashType hash; + FfiProgramDeploymentMessage message; +} FfiProgramDeploymentTransactionBody; + +typedef struct FfiTransactionBody { + struct FfiPublicTransactionBody *public_body; + struct FfiPrivateTransactionBody *private_body; + struct FfiProgramDeploymentTransactionBody *program_deployment_body; +} FfiTransactionBody; + +typedef struct FfiTransaction { + struct FfiTransactionBody body; + enum FfiTransactionKind kind; +} FfiTransaction; + +typedef struct FfiVec_FfiTransaction { + struct FfiTransaction *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiTransaction; + +typedef struct FfiVec_FfiTransaction FfiBlockBody; + +typedef struct FfiBytes32 FfiMsgId; + +typedef struct FfiBlock { + struct FfiBlockHeader header; + FfiBlockBody body; + enum FfiBedrockStatus bedrock_status; + FfiMsgId bedrock_parent_id; +} FfiBlock; + +typedef struct FfiOption_FfiBlock { + struct FfiBlock *value; + bool is_some; +} FfiOption_FfiBlock; + +typedef struct FfiOption_FfiBlock FfiBlockOpt; + +/** + * Simple wrapper around a pointer to a value or an error. + * + * Pointer is not guaranteed. You should check the error field before + * dereferencing the pointer. + */ +typedef struct PointerResult_FfiBlockOpt__OperationStatus { + FfiBlockOpt *value; + enum OperationStatus error; +} PointerResult_FfiBlockOpt__OperationStatus; + +/** + * Simple wrapper around a pointer to a value or an error. + * + * Pointer is not guaranteed. You should check the error field before + * dereferencing the pointer. + */ +typedef struct PointerResult_FfiAccount__OperationStatus { + struct FfiAccount *value; + enum OperationStatus error; +} PointerResult_FfiAccount__OperationStatus; + +typedef struct FfiOption_FfiTransaction { + struct FfiTransaction *value; + bool is_some; +} FfiOption_FfiTransaction; + +/** + * Simple wrapper around a pointer to a value or an error. + * + * Pointer is not guaranteed. You should check the error field before + * dereferencing the pointer. + */ +typedef struct PointerResult_FfiOption_FfiTransaction_____OperationStatus { + struct FfiOption_FfiTransaction *value; + enum OperationStatus error; +} PointerResult_FfiOption_FfiTransaction_____OperationStatus; + +typedef struct FfiVec_FfiBlock { + struct FfiBlock *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiBlock; + +/** + * Simple wrapper around a pointer to a value or an error. + * + * Pointer is not guaranteed. You should check the error field before + * dereferencing the pointer. + */ +typedef struct PointerResult_FfiVec_FfiBlock_____OperationStatus { + struct FfiVec_FfiBlock *value; + enum OperationStatus error; +} PointerResult_FfiVec_FfiBlock_____OperationStatus; + +typedef struct FfiOption_u64 { + uint64_t *value; + bool is_some; +} FfiOption_u64; + +/** + * Simple wrapper around a pointer to a value or an error. + * + * Pointer is not guaranteed. You should check the error field before + * dereferencing the pointer. + */ +typedef struct PointerResult_FfiVec_FfiTransaction_____OperationStatus { + struct FfiVec_FfiTransaction *value; + enum OperationStatus error; +} PointerResult_FfiVec_FfiTransaction_____OperationStatus; + +/** + * Creates and starts an indexer based on the provided + * configuration file path. + * + * # Arguments + * + * - `config_path`: A pointer to a string representing the path to the configuration file. + * - `port`: Number representing a port, on which indexers RPC will start. + * + * # Returns + * + * An `InitializedIndexerServiceFFIResult` containing either a pointer to the + * initialized `IndexerServiceFFI` or an error code. + * + * # Safety + * The caller must ensure that: + * - `runtime` is a valid pointer to a `tokio::runtime::Runtime` instance. + * - `config_path` is a valid pointer to a null-terminated C string. + */ +InitializedIndexerServiceFFIResult start_indexer(const struct Runtime *runtime, + const char *config_path, + uint16_t port); + +/** + * Creates a new [`tokio::runtime::Runtime`]. + */ +struct PointerResult_Runtime__OperationStatus new_runtime(void); + +/** + * Stops and frees the resources associated with the given indexer service. + * + * # Arguments + * + * - `indexer`: A pointer to the `IndexerServiceFFI` instance to be stopped. + * + * # Returns + * + * An `OperationStatus` indicating success or failure. + * + * # Safety + * + * The caller must ensure that: + * - `indexer` is a valid pointer to a `IndexerServiceFFI` instance + * - The `IndexerServiceFFI` instance was created by this library + * - The pointer will not be used after this function returns + */ +enum OperationStatus stop_indexer(struct IndexerServiceFFI *indexer); + +/** + * # Safety + * It's up to the caller to pass a proper pointer, if somehow from c/c++ side + * this is called with a type which doesn't come from a returned `CString` it + * will cause a segfault. + */ +void free_cstring(char *block); + +/** + * Query the last block id from indexer. + * + * # Arguments + * + * - `indexer`: A pointer to the [`IndexerServiceFFI`] instance to be queried. + * + * # Returns + * + * A `PointerResult, OperationStatus>` indicating success or failure. + * + * # Safety + * + * The caller must ensure that: + * - `runtime` is a valid pointer to a [`Runtime`] instance. + * - `indexer` is a valid pointer to a [`IndexerServiceFFI`] instance. + */ +struct PointerResult_Option_u64_____OperationStatus query_last_block(const struct Runtime *runtime, + const struct IndexerServiceFFI *indexer); + +/** + * Query the block by id from indexer. + * + * # Arguments + * + * - `indexer`: A pointer to the [`IndexerServiceFFI`] instance to be queried. + * - `block_id`: `u64` number of block id + * + * # Returns + * + * A `PointerResult` indicating success or failure. + * + * # Safety + * + * The caller must ensure that: + * - `runtime` is a valid pointer to a [`Runtime`] instance. + * - `indexer` is a valid pointer to a [`IndexerServiceFFI`] instance. + */ +struct PointerResult_FfiBlockOpt__OperationStatus query_block(const struct Runtime *runtime, + const struct IndexerServiceFFI *indexer, + FfiBlockId block_id); + +/** + * Query the block by id from indexer. + * + * # Arguments + * + * - `indexer`: A pointer to the [`IndexerServiceFFI`] instance to be queried. + * - `hash`: `FfiHashType` - hash of block + * + * # Returns + * + * A `PointerResult` indicating success or failure. + * + * # Safety + * + * The caller must ensure that: + * - `runtime` is a valid pointer to a [`Runtime`] instance. + * - `indexer` is a valid pointer to a [`IndexerServiceFFI`] instance. + */ +struct PointerResult_FfiBlockOpt__OperationStatus query_block_by_hash(const struct Runtime *runtime, + const struct IndexerServiceFFI *indexer, + FfiHashType hash); + +/** + * Query the account by id from indexer. + * + * # Arguments + * + * - `indexer`: A pointer to the [`IndexerServiceFFI`] instance to be queried. + * - `account_id`: `FfiAccountId` - id of queried account + * + * # Returns + * + * A `PointerResult` indicating success or failure. + * + * # Safety + * + * The caller must ensure that: + * - `runtime` is a valid pointer to a [`Runtime`] instance. + * - `indexer` is a valid pointer to a [`IndexerServiceFFI`] instance. + */ +struct PointerResult_FfiAccount__OperationStatus query_account(const struct Runtime *runtime, + const struct IndexerServiceFFI *indexer, + FfiAccountId account_id); + +/** + * Query the trasnaction by hash from indexer. + * + * # Arguments + * + * - `indexer`: A pointer to the [`IndexerServiceFFI`] instance to be queried. + * - `hash`: `FfiHashType` - hash of transaction + * + * # Returns + * + * A `PointerResult, OperationStatus>` indicating success or failure. + * + * # Safety + * + * The caller must ensure that: + * - `indexer` is a valid pointer to a [`IndexerServiceFFI`] instance. + * - `runtime` is a valid pointer to a [`Runtime`] instance. + */ +struct PointerResult_FfiOption_FfiTransaction_____OperationStatus query_transaction(const struct Runtime *runtime, + const struct IndexerServiceFFI *indexer, + FfiHashType hash); + +/** + * Query the blocks by block range from indexer. + * + * # Arguments + * + * - `indexer`: A pointer to the [`IndexerServiceFFI`] instance to be queried. + * - `before`: `FfiOption` - end block of query + * - `limit`: `u64` - number of blocks to query before `before` + * + * # Returns + * + * A `PointerResult, OperationStatus>` indicating success or failure. + * + * # Safety + * + * The caller must ensure that: + * - `indexer` is a valid pointer to a [`IndexerServiceFFI`] instance. + * - `runtime` is a valid pointer to a [`Runtime`] instance. + */ +struct PointerResult_FfiVec_FfiBlock_____OperationStatus query_block_vec(const struct Runtime *runtime, + const struct IndexerServiceFFI *indexer, + struct FfiOption_u64 before, + uint64_t limit); + +/** + * Query the transactions range by account id from indexer. + * + * # Arguments + * + * - `indexer`: A pointer to the [`IndexerServiceFFI`] instance to be queried. + * - `account_id`: `FfiAccountId` - id of queried account + * - `offset`: `u64` - first tx id of query + * - `limit`: `u64` - number of tx ids to query after `offset` + * + * # Returns + * + * A `PointerResult, OperationStatus>` indicating success or failure. + * + * # Safety + * + * The caller must ensure that: + * - `indexer` is a valid pointer to a [`IndexerServiceFFI`] instance. + * - `runtime` is a valid pointer to a [`Runtime`] instance. + */ +struct PointerResult_FfiVec_FfiTransaction_____OperationStatus query_transactions_by_account(const struct Runtime *runtime, + const struct IndexerServiceFFI *indexer, + FfiAccountId account_id, + uint64_t offset, + uint64_t limit); + +/** + * Frees the resources associated with the given ffi account. + * + * # Arguments + * + * - `val`: An instance of `FfiAccount`. + * + * # Returns + * + * void. + * + * # Safety + * + * The caller must ensure that: + * - `val` is a valid instance of `FfiAccount`. + */ +void free_ffi_account(struct FfiAccount val); + +/** + * Frees the resources associated with the given ffi block. + * + * # Arguments + * + * - `val`: An instance of `FfiBlock`. + * + * # Returns + * + * void. + * + * # Safety + * + * The caller must ensure that: + * - `val` is a valid instance of `FfiBlock`. + */ +void free_ffi_block(struct FfiBlock val); + +/** + * Frees the resources associated with the given ffi block option. + * + * # Arguments + * + * - `val`: An instance of `FfiBlockOpt`. + * + * # Returns + * + * void. + * + * # Safety + * + * The caller must ensure that: + * - `val` is a valid instance of `FfiBlockOpt`. + */ +void free_ffi_block_opt(FfiBlockOpt val); + +/** + * Frees the resources associated with the given ffi block vector. + * + * # Arguments + * + * - `val`: An instance of `FfiVec`. + * + * # Returns + * + * void. + * + * # Safety + * + * The caller must ensure that: + * - `val` is a valid instance of `FfiVec`. + */ +void free_ffi_block_vec(struct FfiVec_FfiBlock val); + +/** + * Frees the resources associated with the given ffi transaction. + * + * # Arguments + * + * - `val`: An instance of `FfiTransaction`. + * + * # Returns + * + * void. + * + * # Safety + * + * The caller must ensure that: + * - `val` is a valid instance of `FfiTransaction`. + */ +void free_ffi_transaction(struct FfiTransaction val); + +/** + * Frees the resources associated with the given ffi transaction option. + * + * # Arguments + * + * - `val`: An instance of `FfiOption`. + * + * # Returns + * + * void. + * + * # Safety + * + * The caller must ensure that: + * - `val` is a valid instance of `FfiOption`. + */ +void free_ffi_transaction_opt(struct FfiOption_FfiTransaction val); + +/** + * Frees the resources associated with the given vector of ffi transactions. + * + * # Arguments + * + * - `val`: An instance of `FfiVec`. + * + * # Returns + * + * void. + * + * # Safety + * + * The caller must ensure that: + * - `val` is a valid instance of `FfiVec`. + */ +void free_ffi_transaction_vec(struct FfiVec_FfiTransaction val); + +bool is_ok(const enum OperationStatus *self); + +bool is_error(const enum OperationStatus *self); diff --git a/indexer/ffi/src/api/client.rs b/indexer/ffi/src/api/client.rs new file mode 100644 index 00000000..825a57de --- /dev/null +++ b/indexer/ffi/src/api/client.rs @@ -0,0 +1,36 @@ +use std::net::SocketAddr; + +use url::Url; + +use crate::OperationStatus; + +#[derive(Debug, Clone, Copy)] +pub enum UrlProtocol { + Http, + Ws, +} + +impl std::fmt::Display for UrlProtocol { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Http => write!(f, "http"), + Self::Ws => write!(f, "ws"), + } + } +} + +pub(crate) fn addr_to_url(protocol: UrlProtocol, addr: SocketAddr) -> Result { + // Convert 0.0.0.0 to 127.0.0.1 for client connections + // When binding to port 0, the server binds to 0.0.0.0: + // but clients need to connect to 127.0.0.1: to work reliably + let url_string = if addr.ip().is_unspecified() { + format!("{protocol}://127.0.0.1:{}", addr.port()) + } else { + format!("{protocol}://{addr}") + }; + + url_string.parse().map_err(|e| { + log::error!("Could not parse indexer url: {e}"); + OperationStatus::InitializationError + }) +} diff --git a/indexer/ffi/src/api/lifecycle.rs b/indexer/ffi/src/api/lifecycle.rs new file mode 100644 index 00000000..d124901f --- /dev/null +++ b/indexer/ffi/src/api/lifecycle.rs @@ -0,0 +1,138 @@ +use std::{ffi::c_char, path::PathBuf}; + +use crate::{ + IndexerServiceFFI, Runtime, + api::{ + PointerResult, + client::{UrlProtocol, addr_to_url}, + }, + client::{IndexerClient, IndexerClientTrait as _}, + errors::OperationStatus, +}; + +pub type InitializedIndexerServiceFFIResult = PointerResult; + +/// Creates and starts an indexer based on the provided +/// configuration file path. +/// +/// # Arguments +/// +/// - `config_path`: A pointer to a string representing the path to the configuration file. +/// - `port`: Number representing a port, on which indexers RPC will start. +/// +/// # Returns +/// +/// An `InitializedIndexerServiceFFIResult` containing either a pointer to the +/// initialized `IndexerServiceFFI` or an error code. +/// +/// # Safety +/// The caller must ensure that: +/// - `runtime` is a valid pointer to a `tokio::runtime::Runtime` instance. +/// - `config_path` is a valid pointer to a null-terminated C string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn start_indexer( + runtime: *const Runtime, + config_path: *const c_char, + port: u16, +) -> InitializedIndexerServiceFFIResult { + // SAFETY: The caller must ensure the validness of the `runtime` and `config_path` pointers. + unsafe { setup_indexer(runtime, config_path, port) }.map_or_else( + InitializedIndexerServiceFFIResult::from_error, + InitializedIndexerServiceFFIResult::from_value, + ) +} + +/// Creates a new [`tokio::runtime::Runtime`]. +#[unsafe(no_mangle)] +pub extern "C" fn new_runtime() -> PointerResult { + Runtime::new().map_or_else( + |_e| PointerResult::from_error(OperationStatus::InitializationError), + PointerResult::from_value, + ) +} + +/// Initializes and starts an indexer based on the provided +/// configuration file path. +/// +/// # Arguments +/// +/// - `config_path`: A pointer to a string representing the path to the configuration file. +/// - `port`: Number representing a port, on which indexers RPC will start. +/// +/// # Returns +/// +/// A `Result` containing either the initialized `IndexerServiceFFI` or an +/// error code. +/// +/// # Safety +/// The caller must ensure that: +/// - `runtime` is a valid pointer to a `tokio::runtime::Runtime` instance. +/// - `config_path` is a valid pointer to a null-terminated C string. +unsafe fn setup_indexer( + runtime: *const Runtime, + config_path: *const c_char, + port: u16, +) -> Result { + let user_config_path = PathBuf::from( + unsafe { std::ffi::CStr::from_ptr(config_path) } + .to_str() + .map_err(|e| { + log::error!("Could not convert the config path to string: {e}"); + OperationStatus::InitializationError + })?, + ); + let config = indexer_service::IndexerConfig::from_path(&user_config_path).map_err(|e| { + log::error!("Failed to read config: {e}"); + OperationStatus::InitializationError + })?; + + // SAFETY: The caller must ensure that `runtime` is a valid pointer to a + // `tokio::runtime::Runtime` instance. + let runtime = unsafe { &*runtime }; + + let indexer_handle = runtime + .block_on(indexer_service::run_server(config, port)) + .map_err(|e| { + log::error!("Could not start indexer service: {e}"); + OperationStatus::InitializationError + })?; + + let indexer_url = addr_to_url(UrlProtocol::Ws, indexer_handle.addr())?; + let indexer_client = runtime + .block_on(IndexerClient::new(&indexer_url)) + .map_err(|e| { + log::error!("Could not start indexer client: {e}"); + OperationStatus::InitializationError + })?; + + Ok(IndexerServiceFFI::new(indexer_handle, indexer_client)) +} + +/// Stops and frees the resources associated with the given indexer service. +/// +/// # Arguments +/// +/// - `indexer`: A pointer to the `IndexerServiceFFI` instance to be stopped. +/// +/// # Returns +/// +/// An `OperationStatus` indicating success or failure. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `indexer` is a valid pointer to a `IndexerServiceFFI` instance +/// - The `IndexerServiceFFI` instance was created by this library +/// - The pointer will not be used after this function returns +#[unsafe(no_mangle)] +pub unsafe extern "C" fn stop_indexer(indexer: *mut IndexerServiceFFI) -> OperationStatus { + if indexer.is_null() { + log::error!("Attempted to stop a null indexer pointer. This is a bug. Aborting."); + return OperationStatus::NullPointer; + } + + let indexer = unsafe { Box::from_raw(indexer) }; + drop(indexer); + + OperationStatus::Ok +} diff --git a/indexer/ffi/src/api/memory.rs b/indexer/ffi/src/api/memory.rs new file mode 100644 index 00000000..f266d309 --- /dev/null +++ b/indexer/ffi/src/api/memory.rs @@ -0,0 +1,14 @@ +use std::ffi::{CString, c_char}; + +/// # Safety +/// It's up to the caller to pass a proper pointer, if somehow from c/c++ side +/// this is called with a type which doesn't come from a returned `CString` it +/// will cause a segfault. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn free_cstring(block: *mut c_char) { + if block.is_null() { + log::error!("Trying to free a null pointer. Exiting"); + return; + } + drop(unsafe { CString::from_raw(block) }); +} diff --git a/indexer/ffi/src/api/mod.rs b/indexer/ffi/src/api/mod.rs new file mode 100644 index 00000000..ea2b91d7 --- /dev/null +++ b/indexer/ffi/src/api/mod.rs @@ -0,0 +1,8 @@ +pub use result::PointerResult; + +pub mod client; +pub mod lifecycle; +pub mod memory; +pub mod query; +pub mod result; +pub mod types; diff --git a/indexer/ffi/src/api/query.rs b/indexer/ffi/src/api/query.rs new file mode 100644 index 00000000..44951014 --- /dev/null +++ b/indexer/ffi/src/api/query.rs @@ -0,0 +1,348 @@ +use indexer_service_protocol::{AccountId, HashType}; +use indexer_service_rpc::RpcClient as _; + +use crate::{ + IndexerServiceFFI, Runtime, + api::{ + PointerResult, + types::{ + FfiAccountId, FfiBlockId, FfiHashType, FfiOption, FfiVec, + account::FfiAccount, + block::{FfiBlock, FfiBlockOpt}, + transaction::FfiTransaction, + }, + }, + errors::OperationStatus, +}; + +/// Query the last block id from indexer. +/// +/// # Arguments +/// +/// - `indexer`: A pointer to the [`IndexerServiceFFI`] instance to be queried. +/// +/// # Returns +/// +/// A `PointerResult, OperationStatus>` indicating success or failure. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `runtime` is a valid pointer to a [`Runtime`] instance. +/// - `indexer` is a valid pointer to a [`IndexerServiceFFI`] instance. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn query_last_block( + runtime: *const Runtime, + indexer: *const IndexerServiceFFI, +) -> PointerResult, OperationStatus> { + if indexer.is_null() { + log::error!("Attempted to query a null indexer pointer. This is a bug. Aborting."); + return PointerResult::from_error(OperationStatus::NullPointer); + } + + let indexer = unsafe { &*indexer }; + + let client = indexer.client(); + let runtime = unsafe { &*runtime }; + + runtime + .block_on(client.get_last_finalized_block_id()) + .map_or_else( + |_| PointerResult::from_error(OperationStatus::ClientError), + PointerResult::from_value, + ) +} + +/// Query the block by id from indexer. +/// +/// # Arguments +/// +/// - `indexer`: A pointer to the [`IndexerServiceFFI`] instance to be queried. +/// - `block_id`: `u64` number of block id +/// +/// # Returns +/// +/// A `PointerResult` indicating success or failure. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `runtime` is a valid pointer to a [`Runtime`] instance. +/// - `indexer` is a valid pointer to a [`IndexerServiceFFI`] instance. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn query_block( + runtime: *const Runtime, + indexer: *const IndexerServiceFFI, + block_id: FfiBlockId, +) -> PointerResult { + if indexer.is_null() { + log::error!("Attempted to query a null indexer pointer. This is a bug. Aborting."); + return PointerResult::from_error(OperationStatus::NullPointer); + } + + let indexer = unsafe { &*indexer }; + + let client = indexer.client(); + let runtime = unsafe { &*runtime }; + + runtime + .block_on(client.get_block_by_id(block_id)) + .map_or_else( + |_| PointerResult::from_error(OperationStatus::ClientError), + |block_opt| { + let block_ffi = block_opt.map_or_else(FfiBlockOpt::from_none, |block| { + FfiBlockOpt::from_value(block.into()) + }); + + PointerResult::from_value(block_ffi) + }, + ) +} + +/// Query the block by id from indexer. +/// +/// # Arguments +/// +/// - `indexer`: A pointer to the [`IndexerServiceFFI`] instance to be queried. +/// - `hash`: `FfiHashType` - hash of block +/// +/// # Returns +/// +/// A `PointerResult` indicating success or failure. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `runtime` is a valid pointer to a [`Runtime`] instance. +/// - `indexer` is a valid pointer to a [`IndexerServiceFFI`] instance. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn query_block_by_hash( + runtime: *const Runtime, + indexer: *const IndexerServiceFFI, + hash: FfiHashType, +) -> PointerResult { + if indexer.is_null() { + log::error!("Attempted to query a null indexer pointer. This is a bug. Aborting."); + return PointerResult::from_error(OperationStatus::NullPointer); + } + + let indexer = unsafe { &*indexer }; + + let client = indexer.client(); + let runtime = unsafe { &*runtime }; + + runtime + .block_on(client.get_block_by_hash(HashType(hash.data))) + .map_or_else( + |_| PointerResult::from_error(OperationStatus::ClientError), + |block_opt| { + let block_ffi = block_opt.map_or_else(FfiBlockOpt::from_none, |block| { + FfiBlockOpt::from_value(block.into()) + }); + + PointerResult::from_value(block_ffi) + }, + ) +} + +/// Query the account by id from indexer. +/// +/// # Arguments +/// +/// - `indexer`: A pointer to the [`IndexerServiceFFI`] instance to be queried. +/// - `account_id`: `FfiAccountId` - id of queried account +/// +/// # Returns +/// +/// A `PointerResult` indicating success or failure. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `runtime` is a valid pointer to a [`Runtime`] instance. +/// - `indexer` is a valid pointer to a [`IndexerServiceFFI`] instance. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn query_account( + runtime: *const Runtime, + indexer: *const IndexerServiceFFI, + account_id: FfiAccountId, +) -> PointerResult { + if indexer.is_null() { + log::error!("Attempted to query a null indexer pointer. This is a bug. Aborting."); + return PointerResult::from_error(OperationStatus::NullPointer); + } + + let indexer = unsafe { &*indexer }; + + let client = indexer.client(); + let runtime = unsafe { &*runtime }; + + runtime + .block_on(client.get_account(AccountId { + value: account_id.data, + })) + .map_or_else( + |_| PointerResult::from_error(OperationStatus::ClientError), + |acc| { + let acc_nssa: nssa::Account = + acc.try_into().expect("Source is in blocks, must fit"); + PointerResult::from_value(acc_nssa.into()) + }, + ) +} + +/// Query the trasnaction by hash from indexer. +/// +/// # Arguments +/// +/// - `indexer`: A pointer to the [`IndexerServiceFFI`] instance to be queried. +/// - `hash`: `FfiHashType` - hash of transaction +/// +/// # Returns +/// +/// A `PointerResult, OperationStatus>` indicating success or failure. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `indexer` is a valid pointer to a [`IndexerServiceFFI`] instance. +/// - `runtime` is a valid pointer to a [`Runtime`] instance. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn query_transaction( + runtime: *const Runtime, + indexer: *const IndexerServiceFFI, + hash: FfiHashType, +) -> PointerResult, OperationStatus> { + if indexer.is_null() { + log::error!("Attempted to query a null indexer pointer. This is a bug. Aborting."); + return PointerResult::from_error(OperationStatus::NullPointer); + } + + let indexer = unsafe { &*indexer }; + + let client = indexer.client(); + let runtime = unsafe { &*runtime }; + + runtime + .block_on(client.get_transaction(HashType(hash.data))) + .map_or_else( + |_| PointerResult::from_error(OperationStatus::ClientError), + |tx_opt| { + let tx_ffi = tx_opt.map_or_else(FfiOption::::from_none, |tx| { + FfiOption::::from_value(tx.into()) + }); + + PointerResult::from_value(tx_ffi) + }, + ) +} + +/// Query the blocks by block range from indexer. +/// +/// # Arguments +/// +/// - `indexer`: A pointer to the [`IndexerServiceFFI`] instance to be queried. +/// - `before`: `FfiOption` - end block of query +/// - `limit`: `u64` - number of blocks to query before `before` +/// +/// # Returns +/// +/// A `PointerResult, OperationStatus>` indicating success or failure. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `indexer` is a valid pointer to a [`IndexerServiceFFI`] instance. +/// - `runtime` is a valid pointer to a [`Runtime`] instance. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn query_block_vec( + runtime: *const Runtime, + indexer: *const IndexerServiceFFI, + before: FfiOption, + limit: u64, +) -> PointerResult, OperationStatus> { + if indexer.is_null() { + log::error!("Attempted to query a null indexer pointer. This is a bug. Aborting."); + return PointerResult::from_error(OperationStatus::NullPointer); + } + + let indexer = unsafe { &*indexer }; + + let client = indexer.client(); + let runtime = unsafe { &*runtime }; + + let before_std = before.is_some.then(|| unsafe { *before.value }); + + runtime + .block_on(client.get_blocks(before_std, limit)) + .map_or_else( + |_| PointerResult::from_error(OperationStatus::ClientError), + |block_vec| { + PointerResult::from_value( + block_vec + .into_iter() + .map(Into::into) + .collect::>() + .into(), + ) + }, + ) +} + +/// Query the transactions range by account id from indexer. +/// +/// # Arguments +/// +/// - `indexer`: A pointer to the [`IndexerServiceFFI`] instance to be queried. +/// - `account_id`: `FfiAccountId` - id of queried account +/// - `offset`: `u64` - first tx id of query +/// - `limit`: `u64` - number of tx ids to query after `offset` +/// +/// # Returns +/// +/// A `PointerResult, OperationStatus>` indicating success or failure. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `indexer` is a valid pointer to a [`IndexerServiceFFI`] instance. +/// - `runtime` is a valid pointer to a [`Runtime`] instance. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn query_transactions_by_account( + runtime: *const Runtime, + indexer: *const IndexerServiceFFI, + account_id: FfiAccountId, + offset: u64, + limit: u64, +) -> PointerResult, OperationStatus> { + if indexer.is_null() { + log::error!("Attempted to query a null indexer pointer. This is a bug. Aborting."); + return PointerResult::from_error(OperationStatus::NullPointer); + } + + let indexer = unsafe { &*indexer }; + + let client = indexer.client(); + let runtime = unsafe { &*runtime }; + + runtime + .block_on(client.get_transactions_by_account( + AccountId { + value: account_id.data, + }, + offset, + limit, + )) + .map_or_else( + |_| PointerResult::from_error(OperationStatus::ClientError), + |tx_vec| { + PointerResult::from_value( + tx_vec + .into_iter() + .map(Into::into) + .collect::>() + .into(), + ) + }, + ) +} diff --git a/indexer/ffi/src/api/result.rs b/indexer/ffi/src/api/result.rs new file mode 100644 index 00000000..96cbcdd8 --- /dev/null +++ b/indexer/ffi/src/api/result.rs @@ -0,0 +1,29 @@ +/// Simple wrapper around a pointer to a value or an error. +/// +/// Pointer is not guaranteed. You should check the error field before +/// dereferencing the pointer. +#[repr(C)] +pub struct PointerResult { + pub value: *mut Type, + pub error: Error, +} + +impl PointerResult { + pub fn from_pointer(pointer: *mut Type) -> Self { + Self { + value: pointer, + error: Error::default(), + } + } + + pub fn from_value(value: Type) -> Self { + Self::from_pointer(Box::into_raw(Box::new(value))) + } + + pub const fn from_error(error: Error) -> Self { + Self { + value: std::ptr::null_mut(), + error, + } + } +} diff --git a/indexer/ffi/src/api/types/account.rs b/indexer/ffi/src/api/types/account.rs new file mode 100644 index 00000000..6c35347f --- /dev/null +++ b/indexer/ffi/src/api/types/account.rs @@ -0,0 +1,119 @@ +use indexer_service_protocol::ProgramId; + +use crate::api::types::{FfiBytes32, FfiProgramId, FfiU128}; + +/// Account data structure - C-compatible version of nssa Account. +/// +/// Note: `balance` and `nonce` are u128 values represented as little-endian +/// byte arrays since C doesn't have native u128 support. +#[repr(C)] +pub struct FfiAccount { + pub program_owner: FfiProgramId, + /// Balance as little-endian [u8; 16]. + pub balance: FfiU128, + /// Pointer to account data bytes. + pub data: *mut u8, + /// Length of account data. + pub data_len: usize, + /// Capacity of account data. + pub data_cap: usize, + /// Nonce as little-endian [u8; 16]. + pub nonce: FfiU128, +} + +// Helper functions to convert between Rust and FFI types + +impl From<&nssa::AccountId> for FfiBytes32 { + fn from(id: &nssa::AccountId) -> Self { + Self::from_account_id(id) + } +} + +impl From for FfiAccount { + fn from(value: nssa::Account) -> Self { + let nssa::Account { + program_owner, + balance, + data, + nonce, + } = value; + + let (data, data_len, data_cap) = data.into_inner().into_raw_parts(); + + let program_owner = FfiProgramId { + data: program_owner, + }; + Self { + program_owner, + balance: balance.into(), + data, + data_len, + data_cap, + nonce: nonce.0.into(), + } + } +} + +impl From for indexer_service_protocol::Account { + fn from(value: FfiAccount) -> Self { + let FfiAccount { + program_owner, + balance, + data, + data_cap, + data_len, + nonce, + } = value; + + Self { + program_owner: ProgramId(program_owner.data), + balance: balance.into(), + data: indexer_service_protocol::Data(unsafe { + Vec::from_raw_parts(data, data_len, data_cap) + }), + nonce: nonce.into(), + } + } +} + +impl From<&FfiAccount> for indexer_service_protocol::Account { + fn from(value: &FfiAccount) -> Self { + let &FfiAccount { + program_owner, + balance, + data, + data_cap, + data_len, + nonce, + } = value; + + Self { + program_owner: ProgramId(program_owner.data), + balance: balance.into(), + data: indexer_service_protocol::Data(unsafe { + Vec::from_raw_parts(data, data_len, data_cap) + }), + nonce: nonce.into(), + } + } +} + +/// Frees the resources associated with the given ffi account. +/// +/// # Arguments +/// +/// - `val`: An instance of `FfiAccount`. +/// +/// # Returns +/// +/// void. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `val` is a valid instance of `FfiAccount`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn free_ffi_account(val: FfiAccount) { + let orig_val: indexer_service_protocol::Account = val.into(); + drop(orig_val); +} diff --git a/indexer/ffi/src/api/types/block.rs b/indexer/ffi/src/api/types/block.rs new file mode 100644 index 00000000..bca2fdb5 --- /dev/null +++ b/indexer/ffi/src/api/types/block.rs @@ -0,0 +1,199 @@ +use indexer_service_protocol::{ + BedrockStatus, Block, BlockHeader, HashType, MantleMsgId, Signature, +}; + +use crate::api::types::{ + FfiBlockId, FfiHashType, FfiMsgId, FfiOption, FfiSignature, FfiTimestamp, FfiVec, + transaction::free_ffi_transaction_vec, vectors::FfiBlockBody, +}; + +#[repr(C)] +pub struct FfiBlock { + pub header: FfiBlockHeader, + pub body: FfiBlockBody, + pub bedrock_status: FfiBedrockStatus, + pub bedrock_parent_id: FfiMsgId, +} + +impl From for FfiBlock { + fn from(value: Block) -> Self { + let Block { + header, + body, + bedrock_status, + bedrock_parent_id, + } = value; + + Self { + header: header.into(), + body: body + .transactions + .into_iter() + .map(Into::into) + .collect::>() + .into(), + bedrock_status: bedrock_status.into(), + bedrock_parent_id: bedrock_parent_id.into(), + } + } +} + +pub type FfiBlockOpt = FfiOption; + +#[repr(C)] +pub struct FfiBlockHeader { + pub block_id: FfiBlockId, + pub prev_block_hash: FfiHashType, + pub hash: FfiHashType, + pub timestamp: FfiTimestamp, + pub signature: FfiSignature, +} + +impl From for FfiBlockHeader { + fn from(value: BlockHeader) -> Self { + let BlockHeader { + block_id, + prev_block_hash, + hash, + timestamp, + signature, + } = value; + + Self { + block_id, + prev_block_hash: prev_block_hash.into(), + hash: hash.into(), + timestamp, + signature: signature.into(), + } + } +} + +#[repr(C)] +pub enum FfiBedrockStatus { + Pending = 0x0, + Safe, + Finalized, +} + +impl From for FfiBedrockStatus { + fn from(value: BedrockStatus) -> Self { + match value { + BedrockStatus::Finalized => Self::Finalized, + BedrockStatus::Pending => Self::Pending, + BedrockStatus::Safe => Self::Safe, + } + } +} + +impl From for BedrockStatus { + fn from(value: FfiBedrockStatus) -> Self { + match value { + FfiBedrockStatus::Finalized => Self::Finalized, + FfiBedrockStatus::Pending => Self::Pending, + FfiBedrockStatus::Safe => Self::Safe, + } + } +} + +/// Frees the resources associated with the given ffi block. +/// +/// # Arguments +/// +/// - `val`: An instance of `FfiBlock`. +/// +/// # Returns +/// +/// void. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `val` is a valid instance of `FfiBlock`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn free_ffi_block(val: FfiBlock) { + // We don't really need all the casts, but just in case + // All except `ffi_tx_ffi_vec` is Copy types, so no need for Drop + let _ = BlockHeader { + block_id: val.header.block_id, + prev_block_hash: HashType(val.header.prev_block_hash.data), + hash: HashType(val.header.hash.data), + timestamp: val.header.timestamp, + signature: Signature(val.header.signature.data), + }; + let ffi_tx_ffi_vec = val.body; + + #[expect(clippy::let_underscore_must_use, reason = "No use for this Copy type")] + let _: BedrockStatus = val.bedrock_status.into(); + + let _ = MantleMsgId(val.bedrock_parent_id.data); + + unsafe { + free_ffi_transaction_vec(ffi_tx_ffi_vec); + }; +} + +/// Frees the resources associated with the given ffi block option. +/// +/// # Arguments +/// +/// - `val`: An instance of `FfiBlockOpt`. +/// +/// # Returns +/// +/// void. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `val` is a valid instance of `FfiBlockOpt`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn free_ffi_block_opt(val: FfiBlockOpt) { + if val.is_some { + let value = unsafe { Box::from_raw(val.value) }; + + // We don't really need all the casts, but just in case + // All except `ffi_tx_ffi_vec` is Copy types, so no need for Drop + let _ = BlockHeader { + block_id: value.header.block_id, + prev_block_hash: HashType(value.header.prev_block_hash.data), + hash: HashType(value.header.hash.data), + timestamp: value.header.timestamp, + signature: Signature(value.header.signature.data), + }; + let ffi_tx_ffi_vec = value.body; + + #[expect(clippy::let_underscore_must_use, reason = "No use for this Copy type")] + let _: BedrockStatus = value.bedrock_status.into(); + + let _ = MantleMsgId(value.bedrock_parent_id.data); + + unsafe { + free_ffi_transaction_vec(ffi_tx_ffi_vec); + }; + } +} + +/// Frees the resources associated with the given ffi block vector. +/// +/// # Arguments +/// +/// - `val`: An instance of `FfiVec`. +/// +/// # Returns +/// +/// void. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `val` is a valid instance of `FfiVec`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn free_ffi_block_vec(val: FfiVec) { + let ffi_block_std_vec: Vec<_> = val.into(); + for block in ffi_block_std_vec { + unsafe { + free_ffi_block(block); + } + } +} diff --git a/indexer/ffi/src/api/types/mod.rs b/indexer/ffi/src/api/types/mod.rs new file mode 100644 index 00000000..2e7a77ad --- /dev/null +++ b/indexer/ffi/src/api/types/mod.rs @@ -0,0 +1,165 @@ +use indexer_service_protocol::{AccountId, HashType, MantleMsgId, ProgramId, PublicKey, Signature}; + +pub mod account; +pub mod block; +pub mod transaction; +pub mod vectors; + +/// 32-byte array type for `AccountId`, keys, hashes, etc. +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct FfiBytes32 { + pub data: [u8; 32], +} + +/// 64-byte array type for signatures, etc. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct FfiBytes64 { + pub data: [u8; 64], +} + +/// Program ID - 8 u32 values (32 bytes total). +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct FfiProgramId { + pub data: [u32; 8], +} + +impl From for FfiProgramId { + fn from(value: ProgramId) -> Self { + Self { data: value.0 } + } +} + +/// U128 - 16 bytes little endian. +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct FfiU128 { + pub data: [u8; 16], +} + +impl FfiBytes32 { + /// Create from a 32-byte array. + #[must_use] + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self { data: bytes } + } + + /// Create from an `AccountId`. + #[must_use] + pub const fn from_account_id(id: &nssa::AccountId) -> Self { + Self { data: *id.value() } + } +} + +impl From for FfiU128 { + fn from(value: u128) -> Self { + Self { + data: value.to_le_bytes(), + } + } +} + +impl From for u128 { + fn from(value: FfiU128) -> Self { + Self::from_le_bytes(value.data) + } +} + +pub type FfiHashType = FfiBytes32; +pub type FfiMsgId = FfiBytes32; +pub type FfiBlockId = u64; +pub type FfiTimestamp = u64; +pub type FfiSignature = FfiBytes64; +pub type FfiAccountId = FfiBytes32; +pub type FfiNonce = FfiU128; +pub type FfiPublicKey = FfiBytes32; + +impl From for FfiHashType { + fn from(value: HashType) -> Self { + Self { data: value.0 } + } +} + +impl From for FfiMsgId { + fn from(value: MantleMsgId) -> Self { + Self { data: value.0 } + } +} + +impl From for FfiSignature { + fn from(value: Signature) -> Self { + Self { data: value.0 } + } +} + +impl From for FfiAccountId { + fn from(value: AccountId) -> Self { + Self { data: value.value } + } +} + +impl From for FfiPublicKey { + fn from(value: PublicKey) -> Self { + Self { data: value.0 } + } +} + +#[repr(C)] +pub struct FfiVec { + pub entries: *mut T, + pub len: usize, + pub capacity: usize, +} + +impl From> for FfiVec { + fn from(value: Vec) -> Self { + let (entries, len, capacity) = value.into_raw_parts(); + Self { + entries, + len, + capacity, + } + } +} + +impl From> for Vec { + fn from(value: FfiVec) -> Self { + unsafe { Self::from_raw_parts(value.entries, value.len, value.capacity) } + } +} + +impl FfiVec { + /// # Safety + /// + /// `index` must be lesser than `self.len`. + #[must_use] + pub unsafe fn get(&self, index: usize) -> &T { + let ptr = unsafe { self.entries.add(index) }; + unsafe { &*ptr } + } +} + +#[repr(C)] +pub struct FfiOption { + pub value: *mut T, + pub is_some: bool, +} + +impl FfiOption { + pub fn from_value(val: T) -> Self { + Self { + value: Box::into_raw(Box::new(val)), + is_some: true, + } + } + + #[must_use] + pub const fn from_none() -> Self { + Self { + value: std::ptr::null_mut(), + is_some: false, + } + } +} diff --git a/indexer/ffi/src/api/types/transaction.rs b/indexer/ffi/src/api/types/transaction.rs new file mode 100644 index 00000000..ee3bd01b --- /dev/null +++ b/indexer/ffi/src/api/types/transaction.rs @@ -0,0 +1,548 @@ +use indexer_service_protocol::{ + AccountId, Ciphertext, Commitment, CommitmentSetDigest, EncryptedAccountData, + EphemeralPublicKey, HashType, Nullifier, PrivacyPreservingMessage, + PrivacyPreservingTransaction, ProgramDeploymentMessage, ProgramDeploymentTransaction, + ProgramId, Proof, PublicKey, PublicMessage, PublicTransaction, Signature, Transaction, + ValidityWindow, WitnessSet, +}; + +use crate::api::types::{ + FfiBytes32, FfiHashType, FfiOption, FfiProgramId, FfiPublicKey, FfiSignature, FfiVec, + vectors::{ + FfiAccountIdList, FfiAccountList, FfiEncryptedAccountDataList, FfiInstructionDataList, + FfiNonceList, FfiNullifierCommitmentSetList, FfiProgramDeploymentMessage, FfiProof, + FfiSignaturePubKeyList, FfiVecBytes32, FfiVecU8, + }, +}; + +#[repr(C)] +pub struct FfiPublicTransactionBody { + pub hash: FfiHashType, + pub message: FfiPublicMessage, + pub witness_set: FfiSignaturePubKeyList, +} + +impl From for FfiPublicTransactionBody { + fn from(value: PublicTransaction) -> Self { + let PublicTransaction { + hash, + message, + witness_set, + } = value; + + Self { + hash: hash.into(), + message: message.into(), + witness_set: witness_set + .signatures_and_public_keys + .into_iter() + .map(Into::into) + .collect::>() + .into(), + } + } +} + +impl From> for PublicTransaction { + fn from(value: Box) -> Self { + Self { + hash: HashType(value.hash.data), + message: PublicMessage { + program_id: ProgramId(value.message.program_id.data), + account_ids: { + let std_vec: Vec<_> = value.message.account_ids.into(); + std_vec + .into_iter() + .map(|ffi_val| AccountId { + value: ffi_val.data, + }) + .collect() + }, + nonces: { + let std_vec: Vec<_> = value.message.nonces.into(); + std_vec.into_iter().map(Into::into).collect() + }, + instruction_data: value.message.instruction_data.into(), + }, + witness_set: WitnessSet { + signatures_and_public_keys: { + let std_vec: Vec<_> = value.witness_set.into(); + std_vec + .into_iter() + .map(|ffi_val| { + ( + Signature(ffi_val.signature.data), + PublicKey(ffi_val.public_key.data), + ) + }) + .collect() + }, + proof: None, + }, + } + } +} + +#[repr(C)] +pub struct FfiPublicMessage { + pub program_id: FfiProgramId, + pub account_ids: FfiAccountIdList, + pub nonces: FfiNonceList, + pub instruction_data: FfiInstructionDataList, +} + +impl From for FfiPublicMessage { + fn from(value: PublicMessage) -> Self { + let PublicMessage { + program_id, + account_ids, + nonces, + instruction_data, + } = value; + + Self { + program_id: program_id.into(), + account_ids: account_ids + .into_iter() + .map(Into::into) + .collect::>() + .into(), + nonces: nonces + .into_iter() + .map(Into::into) + .collect::>() + .into(), + instruction_data: instruction_data.into(), + } + } +} + +#[repr(C)] +pub struct FfiPrivateTransactionBody { + pub hash: FfiHashType, + pub message: FfiPrivacyPreservingMessage, + pub witness_set: FfiSignaturePubKeyList, + pub proof: FfiProof, +} + +impl From for FfiPrivateTransactionBody { + fn from(value: PrivacyPreservingTransaction) -> Self { + let PrivacyPreservingTransaction { + hash, + message, + witness_set, + } = value; + + Self { + hash: hash.into(), + message: message.into(), + witness_set: witness_set + .signatures_and_public_keys + .into_iter() + .map(Into::into) + .collect::>() + .into(), + proof: witness_set + .proof + .expect("Private execution: proof must be present") + .0 + .into(), + } + } +} + +impl From> for PrivacyPreservingTransaction { + fn from(value: Box) -> Self { + Self { + hash: HashType(value.hash.data), + message: PrivacyPreservingMessage { + public_account_ids: { + let std_vec: Vec<_> = value.message.public_account_ids.into(); + std_vec + .into_iter() + .map(|ffi_val| AccountId { + value: ffi_val.data, + }) + .collect() + }, + nonces: { + let std_vec: Vec<_> = value.message.nonces.into(); + std_vec.into_iter().map(Into::into).collect() + }, + public_post_states: { + let std_vec: Vec<_> = value.message.public_post_states.into(); + std_vec.into_iter().map(Into::into).collect() + }, + encrypted_private_post_states: { + let std_vec: Vec<_> = value.message.encrypted_private_post_states.into(); + std_vec + .into_iter() + .map(|ffi_val| EncryptedAccountData { + ciphertext: Ciphertext(ffi_val.ciphertext.into()), + epk: EphemeralPublicKey(ffi_val.epk.into()), + view_tag: ffi_val.view_tag, + }) + .collect() + }, + new_commitments: { + let std_vec: Vec<_> = value.message.new_commitments.into(); + std_vec + .into_iter() + .map(|ffi_val| Commitment(ffi_val.data)) + .collect() + }, + new_nullifiers: { + let std_vec: Vec<_> = value.message.new_nullifiers.into(); + std_vec + .into_iter() + .map(|ffi_val| { + ( + Nullifier(ffi_val.nullifier.data), + CommitmentSetDigest(ffi_val.commitment_set_digest.data), + ) + }) + .collect() + }, + block_validity_window: cast_ffi_validity_window( + value.message.block_validity_window, + ), + timestamp_validity_window: cast_ffi_validity_window( + value.message.timestamp_validity_window, + ), + }, + witness_set: WitnessSet { + signatures_and_public_keys: { + let std_vec: Vec<_> = value.witness_set.into(); + std_vec + .into_iter() + .map(|ffi_val| { + ( + Signature(ffi_val.signature.data), + PublicKey(ffi_val.public_key.data), + ) + }) + .collect() + }, + proof: Some(Proof(value.proof.into())), + }, + } + } +} + +#[repr(C)] +pub struct FfiPrivacyPreservingMessage { + pub public_account_ids: FfiAccountIdList, + pub nonces: FfiNonceList, + pub public_post_states: FfiAccountList, + pub encrypted_private_post_states: FfiEncryptedAccountDataList, + pub new_commitments: FfiVecBytes32, + pub new_nullifiers: FfiNullifierCommitmentSetList, + pub block_validity_window: [u64; 2], + pub timestamp_validity_window: [u64; 2], +} + +impl From for FfiPrivacyPreservingMessage { + fn from(value: PrivacyPreservingMessage) -> Self { + let PrivacyPreservingMessage { + public_account_ids, + nonces, + public_post_states, + encrypted_private_post_states, + new_commitments, + new_nullifiers, + block_validity_window, + timestamp_validity_window, + } = value; + + Self { + public_account_ids: public_account_ids + .into_iter() + .map(Into::into) + .collect::>() + .into(), + nonces: nonces + .into_iter() + .map(Into::into) + .collect::>() + .into(), + public_post_states: public_post_states + .into_iter() + .map(|acc_ind| -> nssa::Account { + acc_ind.try_into().expect("Source is in blocks, must fit") + }) + .map(Into::into) + .collect::>() + .into(), + encrypted_private_post_states: encrypted_private_post_states + .into_iter() + .map(Into::into) + .collect::>() + .into(), + new_commitments: new_commitments + .into_iter() + .map(|comm| FfiBytes32 { data: comm.0 }) + .collect::>() + .into(), + new_nullifiers: new_nullifiers + .into_iter() + .map(Into::into) + .collect::>() + .into(), + block_validity_window: cast_validity_window(block_validity_window), + timestamp_validity_window: cast_validity_window(timestamp_validity_window), + } + } +} + +#[repr(C)] +pub struct FfiNullifierCommitmentSet { + pub nullifier: FfiBytes32, + pub commitment_set_digest: FfiBytes32, +} + +impl From<(Nullifier, CommitmentSetDigest)> for FfiNullifierCommitmentSet { + fn from(value: (Nullifier, CommitmentSetDigest)) -> Self { + Self { + nullifier: FfiBytes32 { data: value.0.0 }, + commitment_set_digest: FfiBytes32 { data: value.1.0 }, + } + } +} + +#[repr(C)] +pub struct FfiEncryptedAccountData { + pub ciphertext: FfiVecU8, + pub epk: FfiVecU8, + pub view_tag: u8, +} + +impl From for FfiEncryptedAccountData { + fn from(value: EncryptedAccountData) -> Self { + let EncryptedAccountData { + ciphertext, + epk, + view_tag, + } = value; + + Self { + ciphertext: ciphertext.0.into(), + epk: epk.0.into(), + view_tag, + } + } +} + +#[repr(C)] +pub struct FfiSignaturePubKeyEntry { + pub signature: FfiSignature, + pub public_key: FfiPublicKey, +} + +impl From<(Signature, PublicKey)> for FfiSignaturePubKeyEntry { + fn from(value: (Signature, PublicKey)) -> Self { + Self { + signature: value.0.into(), + public_key: value.1.into(), + } + } +} + +#[repr(C)] +pub struct FfiProgramDeploymentTransactionBody { + pub hash: FfiHashType, + pub message: FfiProgramDeploymentMessage, +} + +impl From> for ProgramDeploymentTransaction { + fn from(value: Box) -> Self { + Self { + hash: HashType(value.hash.data), + message: ProgramDeploymentMessage { + bytecode: value.message.into(), + }, + } + } +} + +impl From for FfiProgramDeploymentTransactionBody { + fn from(value: ProgramDeploymentTransaction) -> Self { + let ProgramDeploymentTransaction { hash, message } = value; + + Self { + hash: hash.into(), + message: message.bytecode.into(), + } + } +} + +#[repr(C)] +pub struct FfiTransactionBody { + pub public_body: *mut FfiPublicTransactionBody, + pub private_body: *mut FfiPrivateTransactionBody, + pub program_deployment_body: *mut FfiProgramDeploymentTransactionBody, +} + +#[repr(C)] +pub struct FfiTransaction { + pub body: FfiTransactionBody, + pub kind: FfiTransactionKind, +} + +impl From for FfiTransaction { + fn from(value: Transaction) -> Self { + match value { + Transaction::Public(pub_tx) => Self { + body: FfiTransactionBody { + public_body: Box::into_raw(Box::new(pub_tx.into())), + private_body: std::ptr::null_mut(), + program_deployment_body: std::ptr::null_mut(), + }, + kind: FfiTransactionKind::Public, + }, + Transaction::PrivacyPreserving(priv_tx) => Self { + body: FfiTransactionBody { + public_body: std::ptr::null_mut(), + private_body: Box::into_raw(Box::new(priv_tx.into())), + program_deployment_body: std::ptr::null_mut(), + }, + kind: FfiTransactionKind::Private, + }, + Transaction::ProgramDeployment(pr_dep_tx) => Self { + body: FfiTransactionBody { + public_body: std::ptr::null_mut(), + private_body: std::ptr::null_mut(), + program_deployment_body: Box::into_raw(Box::new(pr_dep_tx.into())), + }, + kind: FfiTransactionKind::ProgramDeploy, + }, + } + } +} + +#[repr(C)] +pub enum FfiTransactionKind { + Public = 0x0, + Private, + ProgramDeploy, +} + +/// Frees the resources associated with the given ffi transaction. +/// +/// # Arguments +/// +/// - `val`: An instance of `FfiTransaction`. +/// +/// # Returns +/// +/// void. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `val` is a valid instance of `FfiTransaction`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn free_ffi_transaction(val: FfiTransaction) { + match val.kind { + FfiTransactionKind::Public => { + let body = unsafe { Box::from_raw(val.body.public_body) }; + let std_body: PublicTransaction = body.into(); + drop(std_body); + } + FfiTransactionKind::Private => { + let body = unsafe { Box::from_raw(val.body.private_body) }; + let std_body: PrivacyPreservingTransaction = body.into(); + drop(std_body); + } + FfiTransactionKind::ProgramDeploy => { + let body = unsafe { Box::from_raw(val.body.program_deployment_body) }; + let std_body: ProgramDeploymentTransaction = body.into(); + drop(std_body); + } + } +} + +/// Frees the resources associated with the given ffi transaction option. +/// +/// # Arguments +/// +/// - `val`: An instance of `FfiOption`. +/// +/// # Returns +/// +/// void. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `val` is a valid instance of `FfiOption`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn free_ffi_transaction_opt(val: FfiOption) { + if val.is_some { + let value = unsafe { Box::from_raw(val.value) }; + + match value.kind { + FfiTransactionKind::Public => { + let body = unsafe { Box::from_raw(value.body.public_body) }; + let std_body: PublicTransaction = body.into(); + drop(std_body); + } + FfiTransactionKind::Private => { + let body = unsafe { Box::from_raw(value.body.private_body) }; + let std_body: PrivacyPreservingTransaction = body.into(); + drop(std_body); + } + FfiTransactionKind::ProgramDeploy => { + let body = unsafe { Box::from_raw(value.body.program_deployment_body) }; + let std_body: ProgramDeploymentTransaction = body.into(); + drop(std_body); + } + } + } +} + +/// Frees the resources associated with the given vector of ffi transactions. +/// +/// # Arguments +/// +/// - `val`: An instance of `FfiVec`. +/// +/// # Returns +/// +/// void. +/// +/// # Safety +/// +/// The caller must ensure that: +/// - `val` is a valid instance of `FfiVec`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn free_ffi_transaction_vec(val: FfiVec) { + let ffi_tx_std_vec: Vec<_> = val.into(); + for tx in ffi_tx_std_vec { + unsafe { + free_ffi_transaction(tx); + } + } +} + +fn cast_validity_window(window: ValidityWindow) -> [u64; 2] { + [ + window.0.0.unwrap_or_default(), + window.0.1.unwrap_or(u64::MAX), + ] +} + +const fn cast_ffi_validity_window(ffi_window: [u64; 2]) -> ValidityWindow { + let left = if ffi_window[0] == 0 { + None + } else { + Some(ffi_window[0]) + }; + + let right = if ffi_window[1] == u64::MAX { + None + } else { + Some(ffi_window[1]) + }; + + ValidityWindow((left, right)) +} diff --git a/indexer/ffi/src/api/types/vectors.rs b/indexer/ffi/src/api/types/vectors.rs new file mode 100644 index 00000000..46f08737 --- /dev/null +++ b/indexer/ffi/src/api/types/vectors.rs @@ -0,0 +1,31 @@ +use crate::api::types::{ + FfiAccountId, FfiBytes32, FfiNonce, FfiVec, + account::FfiAccount, + transaction::{ + FfiEncryptedAccountData, FfiNullifierCommitmentSet, FfiSignaturePubKeyEntry, FfiTransaction, + }, +}; + +pub type FfiVecU8 = FfiVec; + +pub type FfiAccountList = FfiVec; + +pub type FfiAccountIdList = FfiVec; + +pub type FfiVecBytes32 = FfiVec; + +pub type FfiBlockBody = FfiVec; + +pub type FfiNonceList = FfiVec; + +pub type FfiInstructionDataList = FfiVec; + +pub type FfiSignaturePubKeyList = FfiVec; + +pub type FfiProof = FfiVecU8; + +pub type FfiProgramDeploymentMessage = FfiVecU8; + +pub type FfiEncryptedAccountDataList = FfiVec; + +pub type FfiNullifierCommitmentSetList = FfiVec; diff --git a/sequencer/core/src/indexer_client.rs b/indexer/ffi/src/client.rs similarity index 91% rename from sequencer/core/src/indexer_client.rs rename to indexer/ffi/src/client.rs index 960b77a4..f05b350e 100644 --- a/sequencer/core/src/indexer_client.rs +++ b/indexer/ffi/src/client.rs @@ -4,7 +4,6 @@ use anyhow::{Context as _, Result}; use log::info; pub use url::Url; -#[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")] pub trait IndexerClientTrait: Clone { async fn new(indexer_url: &Url) -> Result; } diff --git a/indexer/ffi/src/errors.rs b/indexer/ffi/src/errors.rs new file mode 100644 index 00000000..4572474c --- /dev/null +++ b/indexer/ffi/src/errors.rs @@ -0,0 +1,23 @@ +#[derive(Debug, Default, PartialEq, Eq)] +#[repr(C)] +pub enum OperationStatus { + #[default] + Ok = 0x0, + NullPointer = 0x1, + InitializationError = 0x2, + ClientError = 0x3, +} + +impl OperationStatus { + #[must_use] + #[unsafe(no_mangle)] + pub extern "C" fn is_ok(&self) -> bool { + *self == Self::Ok + } + + #[must_use] + #[unsafe(no_mangle)] + pub extern "C" fn is_error(&self) -> bool { + !self.is_ok() + } +} diff --git a/indexer/ffi/src/indexer.rs b/indexer/ffi/src/indexer.rs new file mode 100644 index 00000000..e8707697 --- /dev/null +++ b/indexer/ffi/src/indexer.rs @@ -0,0 +1,95 @@ +use std::{ffi::c_void, net::SocketAddr}; + +use indexer_service::IndexerHandle; + +use crate::client::IndexerClient; + +#[repr(C)] +pub struct IndexerServiceFFI { + indexer_handle: *mut c_void, + indexer_client: *mut c_void, +} + +impl IndexerServiceFFI { + #[must_use] + pub fn new( + indexer_handle: indexer_service::IndexerHandle, + indexer_client: IndexerClient, + ) -> Self { + Self { + // Box the complex types and convert to opaque pointers + indexer_handle: Box::into_raw(Box::new(indexer_handle)).cast::(), + indexer_client: Box::into_raw(Box::new(indexer_client)).cast::(), + } + } + + /// Helper to take ownership back. + #[must_use] + pub fn into_parts(mut self) -> (Box, Box) { + let Self { + indexer_handle, + indexer_client, + } = &mut self; + + let indexer_handle_boxed = unsafe { Box::from_raw(indexer_handle.cast::()) }; + let indexer_client_boxed = unsafe { Box::from_raw(indexer_client.cast::()) }; + + // Assigning nulls to prevent double free on drop, since ownership is transferred to caller + *indexer_handle = std::ptr::null_mut(); + *indexer_client = std::ptr::null_mut(); + + (indexer_handle_boxed, indexer_client_boxed) + } + + /// Helper to get indexer handle addr. + #[must_use] + pub const fn addr(&self) -> SocketAddr { + let indexer_handle = unsafe { + self.indexer_handle + .cast::() + .as_ref() + .expect("Indexer Handle must be non-null pointer") + }; + + indexer_handle.addr() + } + + /// Helper to get indexer handle ref. + #[must_use] + pub const fn handle(&self) -> &IndexerHandle { + unsafe { + self.indexer_handle + .cast::() + .as_ref() + .expect("Indexer Handle must be non-null pointer") + } + } + + /// Helper to get indexer client ref. + #[must_use] + pub const fn client(&self) -> &IndexerClient { + unsafe { + self.indexer_client + .cast::() + .as_ref() + .expect("Indexer Client must be non-null pointer") + } + } +} + +// Implement Drop to prevent memory leaks +impl Drop for IndexerServiceFFI { + fn drop(&mut self) { + let Self { + indexer_handle, + indexer_client, + } = self; + + if !indexer_handle.is_null() { + drop(unsafe { Box::from_raw(indexer_handle.cast::()) }); + } + if !indexer_client.is_null() { + drop(unsafe { Box::from_raw(indexer_client.cast::()) }); + } + } +} diff --git a/indexer/ffi/src/lib.rs b/indexer/ffi/src/lib.rs new file mode 100644 index 00000000..9e34b111 --- /dev/null +++ b/indexer/ffi/src/lib.rs @@ -0,0 +1,11 @@ +#![allow(clippy::undocumented_unsafe_blocks, reason = "It is an FFI")] + +pub use errors::OperationStatus; +pub use indexer::IndexerServiceFFI; +pub use runtime::Runtime; + +pub mod api; +mod client; +mod errors; +mod indexer; +mod runtime; diff --git a/indexer/ffi/src/runtime.rs b/indexer/ffi/src/runtime.rs new file mode 100644 index 00000000..ba361fd8 --- /dev/null +++ b/indexer/ffi/src/runtime.rs @@ -0,0 +1,129 @@ +use std::ffi::c_void; + +/// Wrapper around [`tokio::runtime::Runtime`] that can be safely passed across the FFI boundary. +#[repr(C)] +pub struct Runtime { + inner: Pointer, +} + +impl Runtime { + /// Creates a new owned [`Runtime`] instance. + pub fn new() -> Result> { + let inner = tokio::runtime::Runtime::new()?; + Ok(Self { + inner: Pointer::owned(inner), + }) + } + + /// Creates a new owned [`Runtime`] instance from an existing [`tokio::runtime::Runtime`]. + pub fn from_owned(inner: tokio::runtime::Runtime) -> Self { + Self { + inner: Pointer::owned(inner), + } + } + + /// Creates a new borrowed [`Runtime`] instance from a reference to an existing + /// `tokio::runtime::Runtime`. + /// + /// # Safety + /// The caller must ensure that the provided reference remains valid for the lifetime of the + /// returned [`Runtime`]. + pub const unsafe fn from_borrowed(inner: &tokio::runtime::Runtime) -> Self { + Self { + // SAFETY: The caller must ensure the validness of the `inner` reference. + inner: unsafe { Pointer::borrowed(inner) }, + } + } +} + +impl AsRef for Runtime { + fn as_ref(&self) -> &tokio::runtime::Runtime { + self.inner + .as_ref() + .expect("Runtime pointer should not be null") + } +} + +impl std::ops::Deref for Runtime { + type Target = tokio::runtime::Runtime; + + fn deref(&self) -> &Self::Target { + self.as_ref() + } +} + +#[repr(C)] +struct Pointer { + kind: PointerKind, + _marker: std::marker::PhantomData, +} + +#[repr(C)] +enum PointerKind { + Owned(*mut c_void), + Borrowed(*const c_void), + Null, +} + +impl Pointer { + /// Creates a new owned pointer from a value. + pub fn owned(value: T) -> Self { + let boxed = Box::new(value); + let kind = PointerKind::Owned(Box::into_raw(boxed).cast::()); + Self { + kind, + _marker: std::marker::PhantomData, + } + } + + /// Creates a new borrowed pointer from a reference to an existing value. + /// + /// # Safety + /// The caller must ensure that the provided reference remains valid for the lifetime of the + /// returned pointer. + pub const unsafe fn borrowed(value: &T) -> Self { + let kind = PointerKind::Borrowed(std::ptr::from_ref(value).cast::()); + Self { + kind, + _marker: std::marker::PhantomData, + } + } + + /// Returns a reference to the value if the pointer is owned or borrowed, or [`None`] if it is + /// null. + pub const fn as_ref(&self) -> Option<&T> { + match self.kind { + PointerKind::Owned(ptr) => unsafe { (ptr.cast::()).as_ref() }, + PointerKind::Borrowed(ptr) => unsafe { (ptr.cast::()).as_ref() }, + PointerKind::Null => None, + } + } + + /// Takes ownership of the pointer if it is owned, returning the raw pointer and leaving a null + /// pointer in its place. + /// If the pointer is borrowed or null, returns [`None`]. + #[expect(dead_code, reason = "May be useful in future")] + pub fn take(&mut self) -> Option { + match std::mem::replace(&mut self.kind, PointerKind::Null) { + PointerKind::Owned(ptr) => { + // SAFETY: We ensure that the pointer is valid and was allocated by us. + let boxed = unsafe { Box::from_raw(ptr.cast::()) }; + Some(*boxed) + } + PointerKind::Borrowed(_) | PointerKind::Null => None, + } + } +} + +impl Drop for Pointer { + fn drop(&mut self) { + let Self { kind, _marker } = self; + + if let PointerKind::Owned(ptr) = *kind { + // SAFETY: We ensure that the pointer is valid and was allocated by us. + unsafe { + drop(Box::from_raw(ptr.cast::())); + } + } + } +} diff --git a/indexer/service/configs/indexer_config.json b/indexer/service/configs/indexer_config.json index 30ae0e64..0310aae1 100644 --- a/indexer/service/configs/indexer_config.json +++ b/indexer/service/configs/indexer_config.json @@ -1,160 +1,8 @@ { "home": ".", "consensus_info_polling_interval": "1s", - "bedrock_client_config": { - "addr": "http://localhost:8080", - "backoff": { - "start_delay": "100ms", - "max_retries": 5 - } + "bedrock_config": { + "addr": "http://localhost:8080" }, "channel_id": "0101010101010101010101010101010101010101010101010101010101010101", - "initial_accounts": [ - { - "account_id": "jZvdpERLqEkzk6CAz6vDuDJ1wx5aoyFpDa1VFmRvuPX", - "balance": 10000 - }, - { - "account_id": "3jQfsyRyvVpBfdkZegf8QpjfcDq1M5RAXB4H4eJ4kTtf", - "balance": 20000 - } - ], - "initial_commitments": [ - { - "npk": [ - 139, - 19, - 158, - 11, - 155, - 231, - 85, - 206, - 132, - 228, - 220, - 114, - 145, - 89, - 113, - 156, - 238, - 142, - 242, - 74, - 182, - 91, - 43, - 100, - 6, - 190, - 31, - 15, - 31, - 88, - 96, - 204 - ], - "account": { - "program_owner": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "balance": 10000, - "data": [], - "nonce": 0 - } - }, - { - "npk": [ - 173, - 134, - 33, - 223, - 54, - 226, - 10, - 71, - 215, - 254, - 143, - 172, - 24, - 244, - 243, - 208, - 65, - 112, - 118, - 70, - 217, - 240, - 69, - 100, - 129, - 3, - 121, - 25, - 213, - 132, - 42, - 45 - ], - "account": { - "program_owner": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "balance": 20000, - "data": [], - "nonce": 0 - } - } - ], - "signing_key": [ - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37, - 37 - ] } \ No newline at end of file diff --git a/indexer/service/protocol/src/lib.rs b/indexer/service/protocol/src/lib.rs index 59e936bf..d554267e 100644 --- a/indexer/service/protocol/src/lib.rs +++ b/indexer/service/protocol/src/lib.rs @@ -138,7 +138,7 @@ pub struct Account { } pub type BlockId = u64; -pub type TimeStamp = u64; +pub type Timestamp = u64; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] pub struct Block { @@ -153,7 +153,7 @@ pub struct BlockHeader { pub block_id: BlockId, pub prev_block_hash: HashType, pub hash: HashType, - pub timestamp: TimeStamp, + pub timestamp: Timestamp, pub signature: Signature, } diff --git a/indexer/service/rpc/src/lib.rs b/indexer/service/rpc/src/lib.rs index 217c60d4..5763fe82 100644 --- a/indexer/service/rpc/src/lib.rs +++ b/indexer/service/rpc/src/lib.rs @@ -27,7 +27,7 @@ pub trait Rpc { async fn subscribe_to_finalized_blocks(&self) -> SubscriptionResult; #[method(name = "getLastFinalizedBlockId")] - async fn get_last_finalized_block_id(&self) -> Result; + async fn get_last_finalized_block_id(&self) -> Result, ErrorObjectOwned>; #[method(name = "getBlockById")] async fn get_block_by_id(&self, block_id: BlockId) -> Result, ErrorObjectOwned>; @@ -41,6 +41,13 @@ pub trait Rpc { #[method(name = "getAccount")] async fn get_account(&self, account_id: AccountId) -> Result; + #[method(name = "getAccountAtBlock")] + async fn get_account_at_block( + &self, + account_id: AccountId, + block_id: BlockId, + ) -> Result; + #[method(name = "getTransaction")] async fn get_transaction( &self, diff --git a/indexer/service/src/lib.rs b/indexer/service/src/lib.rs index 10f1cade..b0a6e516 100644 --- a/indexer/service/src/lib.rs +++ b/indexer/service/src/lib.rs @@ -16,6 +16,7 @@ pub struct IndexerHandle { /// Option because of `Drop` which forbids to simply move out of `self` in `stopped()`. server_handle: Option, } + impl IndexerHandle { const fn new(addr: SocketAddr, server_handle: ServerHandle) -> Self { Self { diff --git a/indexer/service/src/mock_service.rs b/indexer/service/src/mock_service.rs index 09ae96f5..a83e9ccc 100644 --- a/indexer/service/src/mock_service.rs +++ b/indexer/service/src/mock_service.rs @@ -6,7 +6,7 @@ clippy::integer_division_remainder_used, reason = "Mock service uses intentional casts and format patterns for test data generation" )] -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc, time::Duration}; use indexer_service_protocol::{ Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, BlockId, Commitment, @@ -19,15 +19,73 @@ use jsonrpsee::{ core::{SubscriptionResult, async_trait}, types::ErrorObjectOwned, }; +use tokio::sync::{RwLock, broadcast}; -/// A mock implementation of the `IndexerService` RPC for testing purposes. -pub struct MockIndexerService { +const MOCK_GENESIS_TIMESTAMP_MS: u64 = 1_704_067_200_000; +const MOCK_BLOCK_INTERVAL_MS: u64 = 30_000; + +struct MockState { blocks: Vec, accounts: HashMap, + account_ids: Vec, transactions: HashMap, } +/// A mock implementation of the `IndexerService` RPC for testing purposes. +pub struct MockIndexerService { + state: Arc>, + finalized_blocks_tx: broadcast::Sender, +} + impl MockIndexerService { + fn spawn_block_generation_task( + state: Arc>, + finalized_blocks_tx: broadcast::Sender, + ) { + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(30)).await; + + let new_block = { + let mut state = state.write().await; + + let next_block_id = state + .blocks + .last() + .map_or(1, |block| block.header.block_id.saturating_add(1)); + let prev_hash = state + .blocks + .last() + .map_or(HashType([0_u8; 32]), |block| block.header.hash); + let timestamp = state.blocks.last().map_or( + MOCK_GENESIS_TIMESTAMP_MS + MOCK_BLOCK_INTERVAL_MS, + |block| { + block + .header + .timestamp + .saturating_add(MOCK_BLOCK_INTERVAL_MS) + }, + ); + + let block = build_mock_block( + next_block_id, + prev_hash, + timestamp, + &state.account_ids, + BedrockStatus::Finalized, + ); + + index_block_transactions(&mut state.transactions, &block); + state.blocks.push(block.clone()); + + block + }; + + let _res = finalized_blocks_tx.send(new_block); + } + }); + } + #[must_use] pub fn new_with_mock_blocks() -> Self { let mut blocks = Vec::new(); @@ -59,119 +117,38 @@ impl MockIndexerService { let mut prev_hash = HashType([0_u8; 32]); for block_id in 1..=100 { - let block_hash = { - let mut hash = [0_u8; 32]; - hash[0] = block_id as u8; - hash[1] = 0xff; - 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(); - - for tx_idx in 0..num_txs { - let tx_hash = { - let mut hash = [0_u8; 32]; - hash[0] = block_id as u8; - hash[1] = tx_idx as u8; - 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 */ - }, - }), - }; - - transactions.insert(tx_hash, (tx.clone(), block_id)); - block_transactions.push(tx); - } - - let block = Block { - header: BlockHeader { - block_id, - prev_block_hash: prev_hash, - hash: block_hash, - timestamp: 1_704_067_200_000 + (block_id * 12_000), // ~12 seconds per block - signature: Signature([0_u8; 64]), - }, - body: BlockBody { - transactions: block_transactions, - }, - bedrock_status: match block_id { + let block = build_mock_block( + block_id, + prev_hash, + MOCK_GENESIS_TIMESTAMP_MS + (block_id * MOCK_BLOCK_INTERVAL_MS), + &account_ids, + match block_id { 0..=5 => BedrockStatus::Finalized, 6..=8 => BedrockStatus::Safe, _ => BedrockStatus::Pending, }, - bedrock_parent_id: MantleMsgId([0; 32]), - }; + ); - prev_hash = block_hash; + index_block_transactions(&mut transactions, &block); + + prev_hash = block.header.hash; blocks.push(block); } - Self { + let state = Arc::new(RwLock::new(MockState { blocks, accounts, + account_ids, transactions, + })); + + let (finalized_blocks_tx, _) = broadcast::channel(32); + + Self::spawn_block_generation_task(Arc::clone(&state), finalized_blocks_tx.clone()); + + Self { + state, + finalized_blocks_tx, } } } @@ -183,28 +160,53 @@ impl indexer_service_rpc::RpcServer for MockIndexerService { subscription_sink: jsonrpsee::PendingSubscriptionSink, ) -> SubscriptionResult { let sink = subscription_sink.accept().await?; - for block in self - .blocks - .iter() - .filter(|b| b.bedrock_status == BedrockStatus::Finalized) - { + let initial_finalized_blocks: Vec = { + let state = self.state.read().await; + state + .blocks + .iter() + .filter(|b| b.bedrock_status == BedrockStatus::Finalized) + .cloned() + .collect() + }; + + for block in &initial_finalized_blocks { let json = serde_json::value::to_raw_value(block).unwrap(); sink.send(json).await?; } + + let mut receiver = self.finalized_blocks_tx.subscribe(); + loop { + match receiver.recv().await { + Ok(block) => { + let json = serde_json::value::to_raw_value(&block).unwrap(); + sink.send(json).await?; + } + Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => break, + } + } + Ok(()) } - async fn get_last_finalized_block_id(&self) -> Result { - self.blocks - .last() - .map(|bl| bl.header.block_id) - .ok_or_else(|| { - ErrorObjectOwned::owned(-32001, "Last block not found".to_owned(), None::<()>) - }) + async fn get_last_finalized_block_id(&self) -> Result, ErrorObjectOwned> { + Ok(self + .state + .read() + .await + .blocks + .iter() + .rev() + .find(|block| block.bedrock_status == BedrockStatus::Finalized) + .map(|block| block.header.block_id)) } async fn get_block_by_id(&self, block_id: BlockId) -> Result, ErrorObjectOwned> { Ok(self + .state + .read() + .await .blocks .iter() .find(|b| b.header.block_id == block_id) @@ -216,6 +218,9 @@ impl indexer_service_rpc::RpcServer for MockIndexerService { block_hash: HashType, ) -> Result, ErrorObjectOwned> { Ok(self + .state + .read() + .await .blocks .iter() .find(|b| b.header.hash == block_hash) @@ -223,7 +228,26 @@ impl indexer_service_rpc::RpcServer for MockIndexerService { } async fn get_account(&self, account_id: AccountId) -> Result { - self.accounts + self.state + .read() + .await + .accounts + .get(&account_id) + .cloned() + .ok_or_else(|| ErrorObjectOwned::owned(-32001, "Account not found", None::<()>)) + } + + async fn get_account_at_block( + &self, + account_id: AccountId, + _block_id: BlockId, + ) -> Result { + // Mock service does not track historical state; returns current state regardless of + // block_id. + self.state + .read() + .await + .accounts .get(&account_id) .cloned() .ok_or_else(|| ErrorObjectOwned::owned(-32001, "Account not found", None::<()>)) @@ -233,7 +257,13 @@ impl indexer_service_rpc::RpcServer for MockIndexerService { &self, tx_hash: HashType, ) -> Result, ErrorObjectOwned> { - Ok(self.transactions.get(&tx_hash).map(|(tx, _)| tx.clone())) + Ok(self + .state + .read() + .await + .transactions + .get(&tx_hash) + .map(|(tx, _)| tx.clone())) } async fn get_blocks( @@ -241,15 +271,17 @@ impl indexer_service_rpc::RpcServer for MockIndexerService { before: Option, limit: u64, ) -> Result, ErrorObjectOwned> { + let state = self.state.read().await; + let start_id = before.map_or_else( - || self.blocks.len(), + || state.blocks.len(), |id| usize::try_from(id.saturating_sub(1)).expect("u64 should fit in usize"), ); let result = (1..=start_id) .rev() .take(limit as usize) - .map_while(|block_id| self.blocks.get(block_id - 1).cloned()) + .map_while(|block_id| state.blocks.get(block_id - 1).cloned()) .collect(); Ok(result) @@ -261,20 +293,24 @@ impl indexer_service_rpc::RpcServer for MockIndexerService { offset: u64, limit: u64, ) -> Result, ErrorObjectOwned> { - let mut account_txs: Vec<_> = self - .transactions - .values() - .filter(|(tx, _)| match tx { - Transaction::Public(pub_tx) => pub_tx.message.account_ids.contains(&account_id), - Transaction::PrivacyPreserving(priv_tx) => { - priv_tx.message.public_account_ids.contains(&account_id) - } - Transaction::ProgramDeployment(_) => false, - }) - .collect(); + let mut account_txs: Vec<(Transaction, BlockId)> = { + let state = self.state.read().await; + state + .transactions + .values() + .filter(|(tx, _)| match tx { + Transaction::Public(pub_tx) => pub_tx.message.account_ids.contains(&account_id), + Transaction::PrivacyPreserving(priv_tx) => { + priv_tx.message.public_account_ids.contains(&account_id) + } + Transaction::ProgramDeployment(_) => false, + }) + .cloned() + .collect() + }; // Sort by block ID descending (most recent first) - account_txs.sort_by_key(|b| std::cmp::Reverse(b.1)); + account_txs.sort_by_key(|(_, block_id)| std::cmp::Reverse(*block_id)); let start = offset as usize; if start >= account_txs.len() { @@ -293,3 +329,123 @@ impl indexer_service_rpc::RpcServer for MockIndexerService { Ok(()) } } + +fn build_mock_block( + block_id: BlockId, + prev_hash: HashType, + timestamp: u64, + account_ids: &[AccountId], + bedrock_status: BedrockStatus, +) -> Block { + let block_hash = { + let mut hash = [0_u8; 32]; + hash[0] = block_id as u8; + hash[1] = 0xff; + 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(); + + for tx_idx in 0..num_txs { + let tx_hash = { + let mut hash = [0_u8; 32]; + hash[0] = block_id as u8; + hash[1] = tx_idx as u8; + 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 */ + }, + }), + }; + + block_transactions.push(tx); + } + + Block { + header: BlockHeader { + block_id, + prev_block_hash: prev_hash, + hash: block_hash, + timestamp, + signature: Signature([0_u8; 64]), + }, + body: BlockBody { + transactions: block_transactions, + }, + bedrock_status, + bedrock_parent_id: MantleMsgId([0; 32]), + } +} + +fn index_block_transactions( + transactions: &mut HashMap, + block: &Block, +) { + for tx in &block.body.transactions { + let tx_hash = match tx { + Transaction::Public(public_tx) => public_tx.hash, + Transaction::PrivacyPreserving(private_tx) => private_tx.hash, + Transaction::ProgramDeployment(deployment_tx) => deployment_tx.hash, + }; + transactions.insert(tx_hash, (tx.clone(), block.header.block_id)); + } +} diff --git a/indexer/service/src/service.rs b/indexer/service/src/service.rs index e2f8a321..a959b80c 100644 --- a/indexer/service/src/service.rs +++ b/indexer/service/src/service.rs @@ -48,7 +48,7 @@ impl indexer_service_rpc::RpcServer for IndexerService { Ok(()) } - async fn get_last_finalized_block_id(&self) -> Result { + async fn get_last_finalized_block_id(&self) -> Result, ErrorObjectOwned> { self.indexer.store.get_last_block_id().map_err(db_error) } @@ -83,6 +83,19 @@ impl indexer_service_rpc::RpcServer for IndexerService { .into()) } + async fn get_account_at_block( + &self, + account_id: AccountId, + block_id: BlockId, + ) -> Result { + Ok(self + .indexer + .store + .account_state_at_block(&account_id.into(), block_id) + .map_err(db_error)? + .into()) + } + async fn get_transaction( &self, tx_hash: HashType, @@ -201,43 +214,49 @@ impl SubscriptionService { tokio::sync::mpsc::unbounded_channel::>(); let handle = tokio::spawn(async move { - let mut subscribers = Vec::new(); + let run_loop = async { + let mut subscribers = Vec::new(); - let mut block_stream = pin!(indexer.subscribe_parse_block_stream()); + let mut block_stream = pin!(indexer.subscribe_parse_block_stream()); - #[expect( - clippy::integer_division_remainder_used, - reason = "Generated by select! macro, can't be easily rewritten to avoid this lint" - )] - loop { - tokio::select! { - sub = sub_receiver.recv() => { - let Some(subscription) = sub else { - bail!("Subscription receiver closed unexpectedly"); - }; - info!("Added new subscription with ID {:?}", subscription.sink.subscription_id()); - subscribers.push(subscription); - } - block_opt = block_stream.next() => { - debug!("Got new block from block stream"); - let Some(block) = block_opt else { - bail!("Block stream ended unexpectedly"); - }; - let block = block.context("Failed to get L2 block data")?; - let block: indexer_service_protocol::Block = block.into(); + #[expect( + clippy::integer_division_remainder_used, + reason = "Generated by select! macro, can't be easily rewritten to avoid this lint" + )] + loop { + tokio::select! { + sub = sub_receiver.recv() => { + let Some(subscription) = sub else { + bail!("Subscription receiver closed unexpectedly"); + }; + info!("Added new subscription with ID {:?}", subscription.sink.subscription_id()); + subscribers.push(subscription); + } + block_opt = block_stream.next() => { + debug!("Got new block from block stream"); + let Some(block) = block_opt else { + bail!("Block stream ended unexpectedly"); + }; + let block = block.context("Failed to get L2 block data")?; + let block: indexer_service_protocol::Block = block.into(); - for sub in &mut subscribers { - if let Err(err) = sub.try_send(&block.header.block_id) { - warn!( - "Failed to send block ID {:?} to subscription ID {:?} with error: {err:#?}", - block.header.block_id, - sub.sink.subscription_id(), - ); + for sub in &mut subscribers { + if let Err(err) = sub.try_send(&block.header.block_id) { + warn!( + "Failed to send block ID {:?} to subscription ID {:?} with error: {err:#?}", + block.header.block_id, + sub.sink.subscription_id(), + ); + } } } } } - } + }; + let res: anyhow::Result = run_loop.await; + let Err(err) = res; + error!("Subscription service loop has unexpectedly finished with error: {err:#?}"); + Err(err) }); SubscriptionLoopParts { handle, diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index cb5277d2..82d8ebd1 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -8,30 +8,29 @@ license = { workspace = true } workspace = true [dependencies] +test_fixtures.workspace = true + nssa_core = { workspace = true, features = ["host"] } nssa.workspace = true +authenticated_transfer_core.workspace = true sequencer_core = { workspace = true, features = ["default", "testnet"] } -sequencer_service.workspace = true wallet.workspace = true common.workspace = true key_protocol.workspace = true -indexer_service.workspace = true serde_json.workspace = true token_core.workspace = true ata_core.workspace = true -indexer_service_rpc.workspace = true +vault_core.workspace = true +faucet_core.workspace = true +indexer_service_rpc = { workspace = true, features = ["client"] } sequencer_service_rpc = { workspace = true, features = ["client"] } wallet-ffi.workspace = true -testnet_initial_state.workspace = true - -url.workspace = true +indexer_ffi.workspace = true +indexer_service_protocol.workspace = true anyhow.workspace = true -env_logger.workspace = true log.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } hex.workspace = true tempfile.workspace = true bytesize.workspace = true -futures.workspace = true -testcontainers = { version = "0.27.0", features = ["docker-compose"] } diff --git a/integration_tests/src/config.rs b/integration_tests/src/config.rs deleted file mode 100644 index 1dd726eb..00000000 --- a/integration_tests/src/config.rs +++ /dev/null @@ -1,268 +0,0 @@ -use std::{net::SocketAddr, path::PathBuf, time::Duration}; - -use anyhow::{Context as _, Result}; -use bytesize::ByteSize; -use indexer_service::{BackoffConfig, ChannelId, ClientConfig, IndexerConfig}; -use key_protocol::key_management::KeyChain; -use nssa::{Account, AccountId, PrivateKey, PublicKey}; -use nssa_core::{account::Data, program::DEFAULT_PROGRAM_ID}; -use sequencer_core::config::{BedrockConfig, SequencerConfig}; -use testnet_initial_state::{ - PrivateAccountPrivateInitialData, PrivateAccountPublicInitialData, - PublicAccountPrivateInitialData, PublicAccountPublicInitialData, -}; -use url::Url; -use wallet::config::{InitialAccountData, WalletConfig}; - -/// Sequencer config options available for custom changes in integration tests. -#[derive(Debug, Clone, Copy)] -pub struct SequencerPartialConfig { - pub max_num_tx_in_block: usize, - pub max_block_size: ByteSize, - pub mempool_max_size: usize, - pub block_create_timeout: Duration, -} - -impl Default for SequencerPartialConfig { - fn default() -> Self { - Self { - max_num_tx_in_block: 20, - max_block_size: ByteSize::mib(1), - mempool_max_size: 10_000, - block_create_timeout: Duration::from_secs(10), - } - } -} - -pub struct InitialData { - pub public_accounts: Vec<(PrivateKey, u128)>, - pub private_accounts: Vec<(KeyChain, Account)>, -} - -impl InitialData { - #[must_use] - pub fn with_two_public_and_two_private_initialized_accounts() -> Self { - let mut public_alice_private_key = PrivateKey::new_os_random(); - let mut public_alice_public_key = - PublicKey::new_from_private_key(&public_alice_private_key); - let mut public_alice_account_id = AccountId::from(&public_alice_public_key); - - let mut public_bob_private_key = PrivateKey::new_os_random(); - let mut public_bob_public_key = PublicKey::new_from_private_key(&public_bob_private_key); - let mut public_bob_account_id = AccountId::from(&public_bob_public_key); - - // Ensure consistent ordering - if public_alice_account_id > public_bob_account_id { - std::mem::swap(&mut public_alice_private_key, &mut public_bob_private_key); - std::mem::swap(&mut public_alice_public_key, &mut public_bob_public_key); - std::mem::swap(&mut public_alice_account_id, &mut public_bob_account_id); - } - - let mut private_charlie_key_chain = KeyChain::new_os_random(); - let mut private_charlie_account_id = - AccountId::from(&private_charlie_key_chain.nullifier_public_key); - - let mut private_david_key_chain = KeyChain::new_os_random(); - let mut private_david_account_id = - AccountId::from(&private_david_key_chain.nullifier_public_key); - - // Ensure consistent ordering - if private_charlie_account_id > private_david_account_id { - std::mem::swap(&mut private_charlie_key_chain, &mut private_david_key_chain); - std::mem::swap( - &mut private_charlie_account_id, - &mut private_david_account_id, - ); - } - - Self { - public_accounts: vec![ - (public_alice_private_key, 10_000), - (public_bob_private_key, 20_000), - ], - private_accounts: vec![ - ( - private_charlie_key_chain, - Account { - balance: 10_000, - data: Data::default(), - program_owner: DEFAULT_PROGRAM_ID, - nonce: 0_u128.into(), - }, - ), - ( - private_david_key_chain, - Account { - balance: 20_000, - data: Data::default(), - program_owner: DEFAULT_PROGRAM_ID, - nonce: 0_u128.into(), - }, - ), - ], - } - } - - fn sequencer_initial_public_accounts(&self) -> Vec { - self.public_accounts - .iter() - .map(|(priv_key, balance)| { - let pub_key = PublicKey::new_from_private_key(priv_key); - let account_id = AccountId::from(&pub_key); - PublicAccountPublicInitialData { - account_id, - balance: *balance, - } - }) - .collect() - } - - fn sequencer_initial_private_accounts(&self) -> Vec { - self.private_accounts - .iter() - .map(|(key_chain, account)| PrivateAccountPublicInitialData { - npk: key_chain.nullifier_public_key.clone(), - account: account.clone(), - }) - .collect() - } - - fn wallet_initial_accounts(&self) -> Vec { - self.public_accounts - .iter() - .map(|(priv_key, _)| { - let pub_key = PublicKey::new_from_private_key(priv_key); - let account_id = AccountId::from(&pub_key); - InitialAccountData::Public(PublicAccountPrivateInitialData { - account_id, - pub_sign_key: priv_key.clone(), - }) - }) - .chain(self.private_accounts.iter().map(|(key_chain, account)| { - let account_id = AccountId::from(&key_chain.nullifier_public_key); - InitialAccountData::Private(Box::new(PrivateAccountPrivateInitialData { - account_id, - account: account.clone(), - key_chain: key_chain.clone(), - })) - })) - .collect() - } -} - -#[derive(Debug, Clone, Copy)] -pub enum UrlProtocol { - Http, - Ws, -} - -impl std::fmt::Display for UrlProtocol { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Http => write!(f, "http"), - Self::Ws => write!(f, "ws"), - } - } -} - -pub fn indexer_config( - bedrock_addr: SocketAddr, - home: PathBuf, - initial_data: &InitialData, -) -> Result { - Ok(IndexerConfig { - home, - consensus_info_polling_interval: Duration::from_secs(1), - bedrock_client_config: ClientConfig { - addr: addr_to_url(UrlProtocol::Http, bedrock_addr) - .context("Failed to convert bedrock addr to URL")?, - auth: None, - backoff: BackoffConfig { - start_delay: Duration::from_millis(100), - max_retries: 10, - }, - }, - initial_public_accounts: Some(initial_data.sequencer_initial_public_accounts()), - initial_private_accounts: Some(initial_data.sequencer_initial_private_accounts()), - signing_key: [37; 32], - channel_id: bedrock_channel_id(), - }) -} - -pub fn sequencer_config( - partial: SequencerPartialConfig, - home: PathBuf, - bedrock_addr: SocketAddr, - indexer_addr: SocketAddr, - initial_data: &InitialData, -) -> Result { - let SequencerPartialConfig { - max_num_tx_in_block, - max_block_size, - mempool_max_size, - block_create_timeout, - } = partial; - - Ok(SequencerConfig { - home, - genesis_id: 1, - is_genesis_random: true, - max_num_tx_in_block, - max_block_size, - mempool_max_size, - block_create_timeout, - retry_pending_blocks_timeout: Duration::from_mins(2), - initial_public_accounts: Some(initial_data.sequencer_initial_public_accounts()), - initial_private_accounts: Some(initial_data.sequencer_initial_private_accounts()), - signing_key: [37; 32], - bedrock_config: BedrockConfig { - backoff: BackoffConfig { - start_delay: Duration::from_millis(100), - max_retries: 5, - }, - channel_id: bedrock_channel_id(), - node_url: addr_to_url(UrlProtocol::Http, bedrock_addr) - .context("Failed to convert bedrock addr to URL")?, - auth: None, - }, - indexer_rpc_url: addr_to_url(UrlProtocol::Ws, indexer_addr) - .context("Failed to convert indexer addr to URL")?, - }) -} - -pub fn wallet_config( - sequencer_addr: SocketAddr, - initial_data: &InitialData, -) -> Result { - Ok(WalletConfig { - sequencer_addr: addr_to_url(UrlProtocol::Http, sequencer_addr) - .context("Failed to convert sequencer addr to URL")?, - seq_poll_timeout: Duration::from_secs(30), - seq_tx_poll_max_blocks: 15, - seq_poll_max_retries: 10, - seq_block_poll_max_amount: 100, - initial_accounts: Some(initial_data.wallet_initial_accounts()), - basic_auth: None, - }) -} - -pub fn addr_to_url(protocol: UrlProtocol, addr: SocketAddr) -> Result { - // Convert 0.0.0.0 to 127.0.0.1 for client connections - // When binding to port 0, the server binds to 0.0.0.0: - // but clients need to connect to 127.0.0.1: to work reliably - let url_string = if addr.ip().is_unspecified() { - format!("{protocol}://127.0.0.1:{}", addr.port()) - } else { - format!("{protocol}://{addr}") - }; - - url_string.parse().map_err(Into::into) -} - -fn bedrock_channel_id() -> ChannelId { - let channel_id: [u8; 32] = [0_u8, 1] - .repeat(16) - .try_into() - .unwrap_or_else(|_| unreachable!()); - ChannelId::from(channel_id) -} diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index 08e7cf9f..d3fa7c64 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -1,486 +1,6 @@ -//! This library contains common code for integration tests. +//! Integration test helpers, re-exported from `test_fixtures` for backwards +//! compatibility. The actual fixtures live in the `test_fixtures` crate so that +//! non-test consumers (e.g. `integration_bench`) can depend on them without +//! pulling in the test files. -use std::{net::SocketAddr, path::PathBuf, sync::LazyLock}; - -use anyhow::{Context as _, Result, bail}; -use common::{HashType, transaction::NSSATransaction}; -use futures::FutureExt as _; -use indexer_service::IndexerHandle; -use log::{debug, error, warn}; -use nssa::{AccountId, PrivacyPreservingTransaction}; -use nssa_core::Commitment; -use sequencer_core::indexer_client::{IndexerClient, IndexerClientTrait as _}; -use sequencer_service::SequencerHandle; -use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; -use tempfile::TempDir; -use testcontainers::compose::DockerCompose; -use wallet::{WalletCore, config::WalletConfigOverrides}; - -pub mod config; - -// TODO: Remove this and control time from tests -pub const TIME_TO_WAIT_FOR_BLOCK_SECONDS: u64 = 12; -pub const NSSA_PROGRAM_FOR_TEST_DATA_CHANGER: &str = "data_changer.bin"; -pub const NSSA_PROGRAM_FOR_TEST_NOOP: &str = "noop.bin"; - -const BEDROCK_SERVICE_WITH_OPEN_PORT: &str = "logos-blockchain-node-0"; -const BEDROCK_SERVICE_PORT: u16 = 18080; - -static LOGGER: LazyLock<()> = LazyLock::new(env_logger::init); - -/// Test context which sets up a sequencer and a wallet for integration tests. -/// -/// It's memory and logically safe to create multiple instances of this struct in parallel tests, -/// as each instance uses its own temporary directories for sequencer and wallet data. -// NOTE: Order of fields is important for proper drop order. -pub struct TestContext { - sequencer_client: SequencerClient, - indexer_client: IndexerClient, - wallet: WalletCore, - wallet_password: String, - /// Optional to move out value in Drop. - sequencer_handle: Option, - indexer_handle: IndexerHandle, - bedrock_compose: DockerCompose, - _temp_indexer_dir: TempDir, - _temp_sequencer_dir: TempDir, - _temp_wallet_dir: TempDir, -} - -impl TestContext { - /// Create new test context. - pub async fn new() -> Result { - Self::builder().build().await - } - - #[must_use] - pub const fn builder() -> TestContextBuilder { - TestContextBuilder::new() - } - - async fn new_configured( - sequencer_partial_config: config::SequencerPartialConfig, - initial_data: config::InitialData, - ) -> Result { - // Ensure logger is initialized only once - *LOGGER; - - debug!("Test context setup"); - - let (bedrock_compose, bedrock_addr) = Self::setup_bedrock_node().await?; - - let (indexer_handle, temp_indexer_dir) = Self::setup_indexer(bedrock_addr, &initial_data) - .await - .context("Failed to setup Indexer")?; - - let (sequencer_handle, temp_sequencer_dir) = Self::setup_sequencer( - sequencer_partial_config, - bedrock_addr, - indexer_handle.addr(), - &initial_data, - ) - .await - .context("Failed to setup Sequencer")?; - - let (wallet, temp_wallet_dir, wallet_password) = - Self::setup_wallet(sequencer_handle.addr(), &initial_data) - .await - .context("Failed to setup wallet")?; - - let sequencer_url = config::addr_to_url(config::UrlProtocol::Http, sequencer_handle.addr()) - .context("Failed to convert sequencer addr to URL")?; - let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_handle.addr()) - .context("Failed to convert indexer addr to URL")?; - let sequencer_client = SequencerClientBuilder::default() - .build(sequencer_url) - .context("Failed to create sequencer client")?; - let indexer_client = IndexerClient::new(&indexer_url) - .await - .context("Failed to create indexer client")?; - - Ok(Self { - sequencer_client, - indexer_client, - wallet, - wallet_password, - bedrock_compose, - sequencer_handle: Some(sequencer_handle), - indexer_handle, - _temp_indexer_dir: temp_indexer_dir, - _temp_sequencer_dir: temp_sequencer_dir, - _temp_wallet_dir: temp_wallet_dir, - }) - } - - async fn setup_bedrock_node() -> Result<(DockerCompose, SocketAddr)> { - let manifest_dir = env!("CARGO_MANIFEST_DIR"); - let bedrock_compose_path = - PathBuf::from(manifest_dir).join("../bedrock/docker-compose.yml"); - - let mut compose = DockerCompose::with_auto_client(&[bedrock_compose_path]) - .await - .context("Failed to setup docker compose for Bedrock")? - // Setting port to 0 to avoid conflicts between parallel tests, actual port will be retrieved after container is up - .with_env("PORT", "0"); - - #[expect( - clippy::items_after_statements, - reason = "This is more readable is this function used just after its definition" - )] - async fn up_and_retrieve_port(compose: &mut DockerCompose) -> Result { - compose - .up() - .await - .context("Failed to bring up Bedrock services")?; - let container = compose - .service(BEDROCK_SERVICE_WITH_OPEN_PORT) - .with_context(|| { - format!( - "Failed to get Bedrock service container `{BEDROCK_SERVICE_WITH_OPEN_PORT}`" - ) - })?; - - let ports = container.ports().await.with_context(|| { - format!( - "Failed to get ports for Bedrock service container `{}`", - container.id() - ) - })?; - ports - .map_to_host_port_ipv4(BEDROCK_SERVICE_PORT) - .with_context(|| { - format!( - "Failed to retrieve host port of {BEDROCK_SERVICE_PORT} container \ - port for container `{}`, existing ports: {ports:?}", - container.id() - ) - }) - } - - let mut port = None; - let mut attempt = 0_u32; - let max_attempts = 5_u32; - while port.is_none() && attempt < max_attempts { - attempt = attempt - .checked_add(1) - .expect("We check that attempt < max_attempts, so this won't overflow"); - match up_and_retrieve_port(&mut compose).await { - Ok(p) => { - port = Some(p); - } - Err(err) => { - warn!( - "Failed to bring up Bedrock services: {err:?}, attempt {attempt}/{max_attempts}" - ); - } - } - } - let Some(port) = port else { - bail!("Failed to bring up Bedrock services after {max_attempts} attempts"); - }; - - let addr = SocketAddr::from(([127, 0, 0, 1], port)); - Ok((compose, addr)) - } - - async fn setup_indexer( - bedrock_addr: SocketAddr, - initial_data: &config::InitialData, - ) -> Result<(IndexerHandle, TempDir)> { - let temp_indexer_dir = - tempfile::tempdir().context("Failed to create temp dir for indexer home")?; - - debug!( - "Using temp indexer home at {}", - temp_indexer_dir.path().display() - ); - - let indexer_config = config::indexer_config( - bedrock_addr, - temp_indexer_dir.path().to_owned(), - initial_data, - ) - .context("Failed to create Indexer config")?; - - indexer_service::run_server(indexer_config, 0) - .await - .context("Failed to run Indexer Service") - .map(|handle| (handle, temp_indexer_dir)) - } - - async fn setup_sequencer( - partial: config::SequencerPartialConfig, - bedrock_addr: SocketAddr, - indexer_addr: SocketAddr, - initial_data: &config::InitialData, - ) -> Result<(SequencerHandle, TempDir)> { - let temp_sequencer_dir = - tempfile::tempdir().context("Failed to create temp dir for sequencer home")?; - - debug!( - "Using temp sequencer home at {}", - temp_sequencer_dir.path().display() - ); - - let config = config::sequencer_config( - partial, - temp_sequencer_dir.path().to_owned(), - bedrock_addr, - indexer_addr, - initial_data, - ) - .context("Failed to create Sequencer config")?; - - let sequencer_handle = sequencer_service::run(config, 0).await?; - - Ok((sequencer_handle, temp_sequencer_dir)) - } - - async fn setup_wallet( - sequencer_addr: SocketAddr, - initial_data: &config::InitialData, - ) -> Result<(WalletCore, TempDir, String)> { - let config = config::wallet_config(sequencer_addr, initial_data) - .context("Failed to create Wallet config")?; - let config_serialized = - serde_json::to_string_pretty(&config).context("Failed to serialize Wallet config")?; - - let temp_wallet_dir = - tempfile::tempdir().context("Failed to create temp dir for wallet home")?; - - let config_path = temp_wallet_dir.path().join("wallet_config.json"); - std::fs::write(&config_path, config_serialized) - .context("Failed to write wallet config in temp dir")?; - - let storage_path = temp_wallet_dir.path().join("storage.json"); - let config_overrides = WalletConfigOverrides::default(); - - let wallet_password = "test_pass".to_owned(); - let wallet = WalletCore::new_init_storage( - config_path, - storage_path, - Some(config_overrides), - wallet_password.clone(), - ) - .context("Failed to init wallet")?; - wallet - .store_persistent_data() - .await - .context("Failed to store wallet persistent data")?; - - Ok((wallet, temp_wallet_dir, wallet_password)) - } - - /// Get reference to the wallet. - #[must_use] - pub const fn wallet(&self) -> &WalletCore { - &self.wallet - } - - #[must_use] - pub fn wallet_password(&self) -> &str { - &self.wallet_password - } - - /// Get mutable reference to the wallet. - pub const fn wallet_mut(&mut self) -> &mut WalletCore { - &mut self.wallet - } - - /// Get reference to the sequencer client. - #[must_use] - pub const fn sequencer_client(&self) -> &SequencerClient { - &self.sequencer_client - } - - /// Get reference to the indexer client. - #[must_use] - pub const fn indexer_client(&self) -> &IndexerClient { - &self.indexer_client - } - - /// Get existing public account IDs in the wallet. - #[must_use] - pub fn existing_public_accounts(&self) -> Vec { - self.wallet - .storage() - .user_data - .public_account_ids() - .collect() - } - - /// Get existing private account IDs in the wallet. - #[must_use] - pub fn existing_private_accounts(&self) -> Vec { - self.wallet - .storage() - .user_data - .private_account_ids() - .collect() - } -} - -impl Drop for TestContext { - fn drop(&mut self) { - let Self { - sequencer_handle, - indexer_handle, - bedrock_compose, - _temp_indexer_dir: _, - _temp_sequencer_dir: _, - _temp_wallet_dir: _, - sequencer_client: _, - indexer_client: _, - wallet: _, - wallet_password: _, - } = self; - - let sequencer_handle = sequencer_handle - .take() - .expect("Sequencer handle should be present in TestContext drop"); - if !sequencer_handle.is_healthy() { - let Err(err) = sequencer_handle - .failed() - .now_or_never() - .expect("Sequencer handle should not be running"); - error!( - "Sequencer handle has unexpectedly stopped before TestContext drop with error: {err:#}" - ); - } - - if !indexer_handle.is_healthy() { - error!("Indexer handle has unexpectedly stopped before TestContext drop"); - } - - let container = bedrock_compose - .service(BEDROCK_SERVICE_WITH_OPEN_PORT) - .unwrap_or_else(|| { - panic!("Failed to get Bedrock service container `{BEDROCK_SERVICE_WITH_OPEN_PORT}`") - }); - let output = std::process::Command::new("docker") - .args(["inspect", "-f", "{{.State.Running}}", container.id()]) - .output() - .expect("Failed to execute docker inspect command to check if Bedrock container is still running"); - let stdout = String::from_utf8(output.stdout) - .expect("Failed to parse docker inspect output as String"); - if stdout.trim() != "true" { - error!( - "Bedrock container `{}` is not running during TestContext drop, docker inspect output: {stdout}", - container.id() - ); - } - } -} - -/// A test context to be used in normal #[test] tests. -pub struct BlockingTestContext { - ctx: Option, - runtime: tokio::runtime::Runtime, -} - -impl BlockingTestContext { - pub fn new() -> Result { - let runtime = tokio::runtime::Runtime::new().unwrap(); - let ctx = runtime.block_on(TestContext::new())?; - Ok(Self { - ctx: Some(ctx), - runtime, - }) - } - - pub const fn ctx(&self) -> &TestContext { - self.ctx.as_ref().expect("TestContext is set") - } -} - -pub struct TestContextBuilder { - initial_data: Option, - sequencer_partial_config: Option, -} - -impl TestContextBuilder { - const fn new() -> Self { - Self { - initial_data: None, - sequencer_partial_config: None, - } - } - - #[must_use] - pub fn with_initial_data(mut self, initial_data: config::InitialData) -> Self { - self.initial_data = Some(initial_data); - self - } - - #[must_use] - pub const fn with_sequencer_partial_config( - mut self, - sequencer_partial_config: config::SequencerPartialConfig, - ) -> Self { - self.sequencer_partial_config = Some(sequencer_partial_config); - self - } - - pub async fn build(self) -> Result { - TestContext::new_configured( - self.sequencer_partial_config.unwrap_or_default(), - self.initial_data.unwrap_or_else(|| { - config::InitialData::with_two_public_and_two_private_initialized_accounts() - }), - ) - .await - } -} - -impl Drop for BlockingTestContext { - fn drop(&mut self) { - let Self { ctx, runtime } = self; - - // Ensure async cleanup of TestContext by blocking on its drop in the runtime. - runtime.block_on(async { - if let Some(ctx) = ctx.take() { - drop(ctx); - } - }); - } -} - -#[must_use] -pub fn format_public_account_id(account_id: AccountId) -> String { - format!("Public/{account_id}") -} - -#[must_use] -pub fn format_private_account_id(account_id: AccountId) -> String { - format!("Private/{account_id}") -} - -#[expect( - clippy::wildcard_enum_match_arm, - reason = "We want the code to panic if the transaction type is not PrivacyPreserving" -)] -pub async fn fetch_privacy_preserving_tx( - seq_client: &SequencerClient, - tx_hash: HashType, -) -> PrivacyPreservingTransaction { - let tx = seq_client.get_transaction(tx_hash).await.unwrap().unwrap(); - - match tx { - NSSATransaction::PrivacyPreserving(privacy_preserving_transaction) => { - privacy_preserving_transaction - } - _ => panic!("Invalid tx type"), - } -} - -pub async fn verify_commitment_is_in_state( - commitment: Commitment, - seq_client: &SequencerClient, -) -> bool { - seq_client - .get_proof_for_commitment(commitment) - .await - .ok() - .flatten() - .is_some() -} +pub use test_fixtures::*; diff --git a/integration_tests/tests/account.rs b/integration_tests/tests/account.rs index 60c1aeaa..f779695d 100644 --- a/integration_tests/tests/account.rs +++ b/integration_tests/tests/account.rs @@ -3,16 +3,21 @@ reason = "We don't care about these in tests" )] -use anyhow::Result; -use integration_tests::TestContext; +use anyhow::{Context as _, Result}; +use integration_tests::{TestContext, private_mention}; +use key_protocol::key_management::KeyChain; use log::info; -use nssa::program::Program; +use nssa::{Data, program::Program}; +use nssa_core::account::Nonce; use sequencer_service_rpc::RpcClient as _; use tokio::test; -use wallet::cli::{ - Command, - account::{AccountSubcommand, NewSubcommand}, - execute_subcommand, +use wallet::{ + account::{AccountIdWithPrivacy, HumanReadableAccount, Label}, + cli::{ + Command, SubcommandReturnValue, + account::{AccountSubcommand, ImportSubcommand, NewSubcommand}, + execute_subcommand, + }, }; #[test] @@ -30,7 +35,7 @@ async fn get_existing_account() -> Result<()> { ); assert_eq!(account.balance, 10000); assert!(account.data.is_empty()); - assert_eq!(account.nonce.0, 0); + assert_eq!(account.nonce.0, 1); info!("Successfully retrieved account with correct details"); @@ -41,7 +46,7 @@ async fn get_existing_account() -> Result<()> { async fn new_public_account_with_label() -> Result<()> { let mut ctx = TestContext::new().await?; - let label = "my-test-public-account".to_owned(); + let label = Label::new("my-test-public-account"); let command = Command::Account(AccountSubcommand::New(NewSubcommand::Public { cci: None, label: Some(label.clone()), @@ -55,14 +60,9 @@ async fn new_public_account_with_label() -> Result<()> { }; // Verify the label was stored - let stored_label = ctx - .wallet() - .storage() - .labels - .get(&account_id.to_string()) - .expect("Label should be stored for the new account"); + let resolved = ctx.wallet().storage().resolve_label(&label); - assert_eq!(stored_label.to_string(), label); + assert_eq!(resolved, Some(AccountIdWithPrivacy::Public(account_id))); info!("Successfully created public account with label"); @@ -70,34 +70,23 @@ async fn new_public_account_with_label() -> Result<()> { } #[test] -async fn new_private_account_with_label() -> Result<()> { +async fn add_label_to_existing_account() -> Result<()> { let mut ctx = TestContext::new().await?; - let label = "my-test-private-account".to_owned(); - let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { - cci: None, - label: Some(label.clone()), - })); + let account_id = ctx.existing_private_accounts()[0]; + let label = Label::new("my-test-private-account"); + let command = Command::Account(AccountSubcommand::Label { + account_id: private_mention(account_id), + label: label.clone(), + }); - let result = execute_subcommand(ctx.wallet_mut(), command).await?; + execute_subcommand(ctx.wallet_mut(), command).await?; - // Extract the account_id from the result + let resolved = ctx.wallet().storage().resolve_label(&label); - let wallet::cli::SubcommandReturnValue::RegisterAccount { account_id } = result else { - panic!("Expected RegisterAccount return value") - }; + assert_eq!(resolved, Some(AccountIdWithPrivacy::Private(account_id))); - // Verify the label was stored - let stored_label = ctx - .wallet() - .storage() - .labels - .get(&account_id.to_string()) - .expect("Label should be stored for the new account"); - - assert_eq!(stored_label.to_string(), label); - - info!("Successfully created private account with label"); + info!("Successfully set label on existing private account"); Ok(()) } @@ -119,12 +108,13 @@ async fn new_public_account_without_label() -> Result<()> { panic!("Expected RegisterAccount return value") }; - // Verify no label was stored + // Verify no label was stored for the account id assert!( - !ctx.wallet() + ctx.wallet() .storage() - .labels - .contains_key(&account_id.to_string()), + .labels_for_account(AccountIdWithPrivacy::Public(account_id)) + .next() + .is_none(), "No label should be stored when not provided" ); @@ -132,3 +122,150 @@ async fn new_public_account_without_label() -> Result<()> { Ok(()) } + +#[test] +async fn import_public_account() -> Result<()> { + let mut ctx = TestContext::new().await?; + + let private_key = nssa::PrivateKey::new_os_random(); + let account_id = nssa::AccountId::from(&nssa::PublicKey::new_from_private_key(&private_key)); + + let command = Command::Account(AccountSubcommand::Import(ImportSubcommand::Public { + private_key, + })); + let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + let SubcommandReturnValue::Empty = sub_ret else { + anyhow::bail!("Expected Empty return value"); + }; + + let imported_key = ctx + .wallet() + .storage() + .key_chain() + .pub_account_signing_key(account_id); + assert!( + imported_key.is_some(), + "Imported public account should be present" + ); + + Ok(()) +} + +#[test] +async fn import_private_account() -> Result<()> { + let mut ctx = TestContext::new().await?; + + let key_chain = KeyChain::new_os_random(); + let account_id = nssa::AccountId::from((&key_chain.nullifier_public_key, 0)); + let account = nssa::Account { + program_owner: Program::authenticated_transfer_program().id(), + balance: 777, + data: Data::default(), + nonce: Nonce::default(), + }; + + let key_chain_json = serde_json::to_string(&key_chain) + .context("Failed to serialize key chain for private import")?; + let account_state = HumanReadableAccount::from(account.clone()); + + let command = Command::Account(AccountSubcommand::Import(ImportSubcommand::Private { + key_chain_json, + account_state, + chain_index: None, + identifier: 0, + })); + let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + let SubcommandReturnValue::Empty = sub_ret else { + anyhow::bail!("Expected Empty return value"); + }; + + let imported_acc = ctx + .wallet() + .storage() + .key_chain() + .private_account(account_id) + .context("Imported private account should be present")?; + + assert_eq!( + imported_acc.key_chain.secret_spending_key, + key_chain.secret_spending_key + ); + assert_eq!( + imported_acc.key_chain.nullifier_public_key, + key_chain.nullifier_public_key + ); + assert_eq!( + imported_acc.key_chain.viewing_public_key, + key_chain.viewing_public_key + ); + + assert_eq!(imported_acc.chain_index, None); + + assert_eq!(imported_acc.kind.identifier(), 0); + + assert_eq!(imported_acc.account, &account); + + Ok(()) +} + +#[test] +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 = nssa::AccountId::from((&key_chain.nullifier_public_key, 0)); + let key_chain_json = + serde_json::to_string(&key_chain).context("Failed to serialize key chain")?; + + let initial_account = nssa::Account { + program_owner: Program::authenticated_transfer_program().id(), + balance: 100, + data: Data::default(), + nonce: Nonce::default(), + }; + + // First import + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::Import(ImportSubcommand::Private { + key_chain_json: key_chain_json.clone(), + account_state: HumanReadableAccount::from(initial_account), + chain_index: None, + identifier: 0, + })), + ) + .await?; + + let updated_account = nssa::Account { + program_owner: Program::authenticated_transfer_program().id(), + balance: 999, + data: Data::default(), + nonce: Nonce::default(), + }; + + // Second import with different account data (same key chain) + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::Import(ImportSubcommand::Private { + key_chain_json, + account_state: HumanReadableAccount::from(updated_account.clone()), + chain_index: None, + identifier: 0, + })), + ) + .await?; + + let imported = ctx + .wallet() + .storage() + .key_chain() + .private_account(account_id) + .context("Imported private account should be present")?; + + assert_eq!( + imported.account, &updated_account, + "Second import should override account data" + ); + + Ok(()) +} diff --git a/integration_tests/tests/amm.rs b/integration_tests/tests/amm.rs index 42aa5f3f..b7a747f1 100644 --- a/integration_tests/tests/amm.rs +++ b/integration_tests/tests/amm.rs @@ -7,14 +7,17 @@ use std::time::Duration; use anyhow::Result; -use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, format_public_account_id}; +use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, public_mention}; use log::info; use sequencer_service_rpc::RpcClient as _; use tokio::test; -use wallet::cli::{ - Command, SubcommandReturnValue, - account::{AccountSubcommand, NewSubcommand}, - programs::{amm::AmmProgramAgnosticSubcommand, token::TokenProgramAgnosticSubcommand}, +use wallet::{ + account::Label, + cli::{ + Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + programs::{amm::AmmProgramAgnosticSubcommand, token::TokenProgramAgnosticSubcommand}, + }, }; #[test] @@ -113,9 +116,10 @@ async fn amm_public() -> Result<()> { // Create new token let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: format_public_account_id(definition_account_id_1), - supply_account_id: format_public_account_id(supply_account_id_1), + definition_account_id: public_mention(definition_account_id_1), + supply_account_id: public_mention(supply_account_id_1), name: "A NAM1".to_owned(), + total_supply: 37, }; wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; @@ -124,10 +128,11 @@ async fn amm_public() -> Result<()> { // Transfer 7 tokens from `supply_acc` to the account at account_id `recipient_account_id_1` let subcommand = TokenProgramAgnosticSubcommand::Send { - from: format_public_account_id(supply_account_id_1), - to: Some(format_public_account_id(recipient_account_id_1)), + from: public_mention(supply_account_id_1), + to: Some(public_mention(recipient_account_id_1)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: 7, }; @@ -137,9 +142,10 @@ async fn amm_public() -> Result<()> { // Create new token let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: format_public_account_id(definition_account_id_2), - supply_account_id: format_public_account_id(supply_account_id_2), + 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?; @@ -148,10 +154,11 @@ async fn amm_public() -> Result<()> { // Transfer 7 tokens from `supply_acc` to the account at account_id `recipient_account_id_2` let subcommand = TokenProgramAgnosticSubcommand::Send { - from: format_public_account_id(supply_account_id_2), - to: Some(format_public_account_id(recipient_account_id_2)), + from: public_mention(supply_account_id_2), + to: Some(public_mention(recipient_account_id_2)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: 7, }; @@ -181,9 +188,9 @@ async fn amm_public() -> Result<()> { // Send creation tx let subcommand = AmmProgramAgnosticSubcommand::New { - user_holding_a: format_public_account_id(recipient_account_id_1), - user_holding_b: format_public_account_id(recipient_account_id_2), - user_holding_lp: format_public_account_id(user_holding_lp), + user_holding_a: public_mention(recipient_account_id_1), + user_holding_b: public_mention(recipient_account_id_2), + user_holding_lp: public_mention(user_holding_lp), balance_a: 3, balance_b: 3, }; @@ -223,12 +230,12 @@ async fn amm_public() -> Result<()> { // Make swap - let subcommand = AmmProgramAgnosticSubcommand::Swap { - user_holding_a: format_public_account_id(recipient_account_id_1), - user_holding_b: format_public_account_id(recipient_account_id_2), + let subcommand = AmmProgramAgnosticSubcommand::SwapExactInput { + user_holding_a: public_mention(recipient_account_id_1), + user_holding_b: public_mention(recipient_account_id_2), amount_in: 2, min_amount_out: 1, - token_definition: definition_account_id_1.to_string(), + token_definition: definition_account_id_1, }; wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::AMM(subcommand)).await?; @@ -266,12 +273,12 @@ async fn amm_public() -> Result<()> { // Make swap - let subcommand = AmmProgramAgnosticSubcommand::Swap { - user_holding_a: format_public_account_id(recipient_account_id_1), - user_holding_b: format_public_account_id(recipient_account_id_2), + let subcommand = AmmProgramAgnosticSubcommand::SwapExactInput { + user_holding_a: public_mention(recipient_account_id_1), + user_holding_b: public_mention(recipient_account_id_2), amount_in: 2, min_amount_out: 1, - token_definition: definition_account_id_2.to_string(), + token_definition: definition_account_id_2, }; wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::AMM(subcommand)).await?; @@ -310,9 +317,9 @@ async fn amm_public() -> Result<()> { // Add liquidity let subcommand = AmmProgramAgnosticSubcommand::AddLiquidity { - user_holding_a: format_public_account_id(recipient_account_id_1), - user_holding_b: format_public_account_id(recipient_account_id_2), - user_holding_lp: format_public_account_id(user_holding_lp), + user_holding_a: public_mention(recipient_account_id_1), + user_holding_b: public_mention(recipient_account_id_2), + user_holding_lp: public_mention(user_holding_lp), min_amount_lp: 1, max_amount_a: 2, max_amount_b: 2, @@ -354,9 +361,9 @@ async fn amm_public() -> Result<()> { // Remove liquidity let subcommand = AmmProgramAgnosticSubcommand::RemoveLiquidity { - user_holding_a: format_public_account_id(recipient_account_id_1), - user_holding_b: format_public_account_id(recipient_account_id_2), - user_holding_lp: format_public_account_id(user_holding_lp), + user_holding_a: public_mention(recipient_account_id_1), + user_holding_b: public_mention(recipient_account_id_2), + user_holding_lp: public_mention(user_holding_lp), balance_lp: 2, min_amount_a: 1, min_amount_b: 1, @@ -397,3 +404,179 @@ async fn amm_public() -> Result<()> { Ok(()) } + +#[test] +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 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"); + }; + + // Create holding_a with a label + let holding_a_label = Label::new("amm-holding-a-label"); + let SubcommandReturnValue::RegisterAccount { + account_id: holding_a_id, + } = wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Public { + cci: None, + label: Some(Label::new(holding_a_label.clone())), + })), + ) + .await? + else { + anyhow::bail!("Expected RegisterAccount return value"); + }; + + // 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 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"); + }; + + // Create holding_b with a label + let holding_b_label = Label::new("amm-holding-b-label"); + let SubcommandReturnValue::RegisterAccount { + account_id: holding_b_id, + } = wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Public { + cci: None, + label: Some(Label::new(holding_b_label.clone())), + })), + ) + .await? + else { + anyhow::bail!("Expected RegisterAccount return value"); + }; + + // Create holding_lp with a label + let holding_lp_label = Label::new("amm-holding-lp-label"); + let SubcommandReturnValue::RegisterAccount { + account_id: holding_lp_id, + } = wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Public { + cci: None, + label: Some(Label::new(holding_lp_label.clone())), + })), + ) + .await? + else { + anyhow::bail!("Expected RegisterAccount return value"); + }; + + // 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; + + 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_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; + + // 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; + + 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_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; + + // Create AMM pool using account labels instead of IDs + let subcommand = AmmProgramAgnosticSubcommand::New { + user_holding_a: holding_a_label.into(), + user_holding_b: holding_b_label.into(), + user_holding_lp: holding_lp_label.into(), + balance_a: 3, + balance_b: 3, + }; + 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?; + + // LP balance should be 3 (geometric mean of 3, 3) + assert_eq!( + u128::from_le_bytes(holding_lp_acc.data[33..].try_into().unwrap()), + 3 + ); + + info!("Successfully created AMM pool using account labels"); + + Ok(()) +} diff --git a/integration_tests/tests/ata.rs b/integration_tests/tests/ata.rs index 94ba98c9..d0eddeae 100644 --- a/integration_tests/tests/ata.rs +++ b/integration_tests/tests/ata.rs @@ -9,8 +9,8 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use ata_core::{compute_ata_seed, get_associated_token_account_id}; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, format_private_account_id, - format_public_account_id, verify_commitment_is_in_state, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention, + verify_commitment_is_in_state, }; use log::info; use nssa::program::Program; @@ -68,8 +68,8 @@ async fn create_ata_initializes_holding_account() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Token(TokenProgramAgnosticSubcommand::New { - definition_account_id: format_public_account_id(definition_account_id), - supply_account_id: format_public_account_id(supply_account_id), + definition_account_id: public_mention(definition_account_id), + supply_account_id: public_mention(supply_account_id), name: "TEST".to_owned(), total_supply, }), @@ -83,8 +83,8 @@ async fn create_ata_initializes_holding_account() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Ata(AtaSubcommand::Create { - owner: format_public_account_id(owner_account_id), - token_definition: definition_account_id.to_string(), + owner: public_mention(owner_account_id), + token_definition: definition_account_id, }), ) .await?; @@ -130,8 +130,8 @@ async fn create_ata_is_idempotent() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Token(TokenProgramAgnosticSubcommand::New { - definition_account_id: format_public_account_id(definition_account_id), - supply_account_id: format_public_account_id(supply_account_id), + definition_account_id: public_mention(definition_account_id), + supply_account_id: public_mention(supply_account_id), name: "TEST".to_owned(), total_supply: 100, }), @@ -145,8 +145,8 @@ async fn create_ata_is_idempotent() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Ata(AtaSubcommand::Create { - owner: format_public_account_id(owner_account_id), - token_definition: definition_account_id.to_string(), + owner: public_mention(owner_account_id), + token_definition: definition_account_id, }), ) .await?; @@ -158,8 +158,8 @@ async fn create_ata_is_idempotent() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Ata(AtaSubcommand::Create { - owner: format_public_account_id(owner_account_id), - token_definition: definition_account_id.to_string(), + owner: public_mention(owner_account_id), + token_definition: definition_account_id, }), ) .await?; @@ -208,8 +208,8 @@ async fn transfer_and_burn_via_ata() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Token(TokenProgramAgnosticSubcommand::New { - definition_account_id: format_public_account_id(definition_account_id), - supply_account_id: format_public_account_id(supply_account_id), + definition_account_id: public_mention(definition_account_id), + supply_account_id: public_mention(supply_account_id), name: "TEST".to_owned(), total_supply, }), @@ -234,16 +234,16 @@ async fn transfer_and_burn_via_ata() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Ata(AtaSubcommand::Create { - owner: format_public_account_id(sender_account_id), - token_definition: definition_account_id.to_string(), + owner: public_mention(sender_account_id), + token_definition: definition_account_id, }), ) .await?; wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Ata(AtaSubcommand::Create { - owner: format_public_account_id(recipient_account_id), - token_definition: definition_account_id.to_string(), + owner: public_mention(recipient_account_id), + token_definition: definition_account_id, }), ) .await?; @@ -256,10 +256,11 @@ async fn transfer_and_burn_via_ata() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Token(TokenProgramAgnosticSubcommand::Send { - from: format_public_account_id(supply_account_id), - to: Some(format_public_account_id(sender_ata_id)), + from: public_mention(supply_account_id), + to: Some(public_mention(sender_ata_id)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: fund_amount, }), ) @@ -273,9 +274,9 @@ async fn transfer_and_burn_via_ata() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Ata(AtaSubcommand::Send { - from: format_public_account_id(sender_account_id), - token_definition: definition_account_id.to_string(), - to: recipient_ata_id.to_string(), + from: public_mention(sender_account_id), + token_definition: definition_account_id, + to: recipient_ata_id, amount: transfer_amount, }), ) @@ -311,8 +312,8 @@ async fn transfer_and_burn_via_ata() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Ata(AtaSubcommand::Burn { - holder: format_public_account_id(sender_account_id), - token_definition: definition_account_id.to_string(), + holder: public_mention(sender_account_id), + token_definition: definition_account_id, amount: burn_amount, }), ) @@ -362,8 +363,8 @@ async fn create_ata_with_private_owner() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Token(TokenProgramAgnosticSubcommand::New { - definition_account_id: format_public_account_id(definition_account_id), - supply_account_id: format_public_account_id(supply_account_id), + definition_account_id: public_mention(definition_account_id), + supply_account_id: public_mention(supply_account_id), name: "TEST".to_owned(), total_supply: 100, }), @@ -377,8 +378,8 @@ async fn create_ata_with_private_owner() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Ata(AtaSubcommand::Create { - owner: format_private_account_id(owner_account_id), - token_definition: definition_account_id.to_string(), + owner: private_mention(owner_account_id), + token_definition: definition_account_id, }), ) .await?; @@ -434,8 +435,8 @@ async fn transfer_via_ata_private_owner() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Token(TokenProgramAgnosticSubcommand::New { - definition_account_id: format_public_account_id(definition_account_id), - supply_account_id: format_public_account_id(supply_account_id), + definition_account_id: public_mention(definition_account_id), + supply_account_id: public_mention(supply_account_id), name: "TEST".to_owned(), total_supply, }), @@ -460,16 +461,16 @@ async fn transfer_via_ata_private_owner() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Ata(AtaSubcommand::Create { - owner: format_private_account_id(sender_account_id), - token_definition: definition_account_id.to_string(), + owner: private_mention(sender_account_id), + token_definition: definition_account_id, }), ) .await?; wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Ata(AtaSubcommand::Create { - owner: format_public_account_id(recipient_account_id), - token_definition: definition_account_id.to_string(), + owner: public_mention(recipient_account_id), + token_definition: definition_account_id, }), ) .await?; @@ -482,10 +483,11 @@ async fn transfer_via_ata_private_owner() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Token(TokenProgramAgnosticSubcommand::Send { - from: format_public_account_id(supply_account_id), - to: Some(format_public_account_id(sender_ata_id)), + from: public_mention(supply_account_id), + to: Some(public_mention(sender_ata_id)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: fund_amount, }), ) @@ -499,9 +501,9 @@ async fn transfer_via_ata_private_owner() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Ata(AtaSubcommand::Send { - from: format_private_account_id(sender_account_id), - token_definition: definition_account_id.to_string(), - to: recipient_ata_id.to_string(), + from: private_mention(sender_account_id), + token_definition: definition_account_id, + to: recipient_ata_id, amount: transfer_amount, }), ) @@ -556,8 +558,8 @@ async fn burn_via_ata_private_owner() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Token(TokenProgramAgnosticSubcommand::New { - definition_account_id: format_public_account_id(definition_account_id), - supply_account_id: format_public_account_id(supply_account_id), + definition_account_id: public_mention(definition_account_id), + supply_account_id: public_mention(supply_account_id), name: "TEST".to_owned(), total_supply, }), @@ -578,8 +580,8 @@ async fn burn_via_ata_private_owner() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Ata(AtaSubcommand::Create { - owner: format_private_account_id(holder_account_id), - token_definition: definition_account_id.to_string(), + owner: private_mention(holder_account_id), + token_definition: definition_account_id, }), ) .await?; @@ -592,10 +594,11 @@ async fn burn_via_ata_private_owner() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Token(TokenProgramAgnosticSubcommand::Send { - from: format_public_account_id(supply_account_id), - to: Some(format_public_account_id(holder_ata_id)), + from: public_mention(supply_account_id), + to: Some(public_mention(holder_ata_id)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: fund_amount, }), ) @@ -609,8 +612,8 @@ async fn burn_via_ata_private_owner() -> Result<()> { wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Ata(AtaSubcommand::Burn { - holder: format_private_account_id(holder_account_id), - token_definition: definition_account_id.to_string(), + holder: private_mention(holder_account_id), + token_definition: definition_account_id, amount: burn_amount, }), ) diff --git a/integration_tests/tests/auth_transfer/private.rs b/integration_tests/tests/auth_transfer/private.rs index 59b4719a..a77ccf34 100644 --- a/integration_tests/tests/auth_transfer/private.rs +++ b/integration_tests/tests/auth_transfer/private.rs @@ -1,19 +1,23 @@ use std::time::Duration; use anyhow::{Context as _, Result}; +use common::transaction::NSSATransaction; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, fetch_privacy_preserving_tx, - format_private_account_id, format_public_account_id, verify_commitment_is_in_state, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, fetch_privacy_preserving_tx, private_mention, + public_mention, verify_commitment_is_in_state, }; use log::info; use nssa::{AccountId, program::Program}; use nssa_core::{NullifierPublicKey, encryption::shared_key_derivation::Secp256k1Point}; use sequencer_service_rpc::RpcClient as _; use tokio::test; -use wallet::cli::{ - Command, SubcommandReturnValue, - account::{AccountSubcommand, NewSubcommand}, - programs::native_token_transfer::AuthTransferSubcommand, +use wallet::{ + account::Label, + cli::{ + CliAccountMention, Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + programs::native_token_transfer::AuthTransferSubcommand, + }, }; #[test] @@ -24,10 +28,11 @@ async fn private_transfer_to_owned_account() -> Result<()> { let to: AccountId = ctx.existing_private_accounts()[1]; let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_private_account_id(from), - to: Some(format_private_account_id(to)), + from: private_mention(from), + to: Some(private_mention(to)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: 100, }); @@ -63,10 +68,11 @@ async fn private_transfer_to_foreign_account() -> Result<()> { let to_vpk = Secp256k1Point::from_scalar(to_npk.0); let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_private_account_id(from), + from: private_mention(from), to: None, to_npk: Some(to_npk_string), to_vpk: Some(hex::encode(to_vpk.0)), + to_identifier: Some(0), amount: 100, }); @@ -111,10 +117,11 @@ async fn deshielded_transfer_to_public_account() -> Result<()> { assert_eq!(from_acc.balance, 10000); let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_private_account_id(from), - to: Some(format_public_account_id(to)), + from: private_mention(from), + to: Some(public_mention(to)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: 100, }); @@ -164,20 +171,20 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> { }; // Get the keys for the newly created account - let (to_keys, _) = ctx + let to = ctx .wallet() .storage() - .user_data - .get_private_account(to_account_id) - .cloned() + .key_chain() + .private_account(to_account_id) .context("Failed to get private account")?; // Send to this account using claiming path (using npk and vpk instead of account ID) let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_private_account_id(from), + from: private_mention(from), to: None, - to_npk: Some(hex::encode(to_keys.nullifier_public_key.0)), - to_vpk: Some(hex::encode(to_keys.viewing_public_key.0)), + to_npk: Some(hex::encode(to.key_chain.nullifier_public_key.0)), + to_vpk: Some(hex::encode(&to.key_chain.viewing_public_key.0)), + to_identifier: Some(to.kind.identifier()), amount: 100, }); @@ -222,10 +229,11 @@ async fn shielded_transfer_to_owned_private_account() -> Result<()> { let to: AccountId = ctx.existing_private_accounts()[1]; let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_public_account_id(from), - to: Some(format_private_account_id(to)), + from: public_mention(from), + to: Some(private_mention(to)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: 100, }); @@ -264,10 +272,11 @@ async fn shielded_transfer_to_foreign_account() -> Result<()> { let from: AccountId = ctx.existing_public_accounts()[0]; let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_public_account_id(from), + from: public_mention(from), to: None, to_npk: Some(to_npk_string), to_vpk: Some(hex::encode(to_vpk.0)), + to_identifier: Some(0), amount: 100, }); @@ -324,20 +333,20 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> { }; // Get the newly created account's keys - let (to_keys, _) = ctx + let to = ctx .wallet() .storage() - .user_data - .get_private_account(to_account_id) - .cloned() + .key_chain() + .private_account(to_account_id) .context("Failed to get private account")?; // Send transfer using nullifier and viewing public keys let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_private_account_id(from), + from: private_mention(from), to: None, - to_npk: Some(hex::encode(to_keys.nullifier_public_key.0)), - to_vpk: Some(hex::encode(to_keys.viewing_public_key.0)), + to_npk: Some(hex::encode(to.key_chain.nullifier_public_key.0)), + to_vpk: Some(hex::encode(&to.key_chain.viewing_public_key.0)), + to_identifier: Some(to.kind.identifier()), amount: 100, }); @@ -383,7 +392,7 @@ async fn initialize_private_account() -> Result<()> { }; let command = Command::AuthTransfer(AuthTransferSubcommand::Init { - account_id: format_private_account_id(account_id), + account_id: private_mention(account_id), }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; @@ -415,3 +424,331 @@ async fn initialize_private_account() -> Result<()> { Ok(()) } + +#[test] +async fn private_transfer_using_from_label() -> Result<()> { + let mut ctx = TestContext::new().await?; + + let from: AccountId = ctx.existing_private_accounts()[0]; + let to: AccountId = ctx.existing_private_accounts()[1]; + + // Assign a label to the sender account + let label = Label::new("private-sender-label"); + let command = Command::Account(AccountSubcommand::Label { + account_id: private_mention(from), + label: label.clone(), + }); + 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_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 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); + + info!("Successfully transferred privately using from_label"); + + Ok(()) +} + +#[test] +async fn initialize_private_account_using_label() -> Result<()> { + let mut ctx = TestContext::new().await?; + + // Create a new private account with a label + let label = Label::new("init-private-label"); + let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private { + cci: None, + label: Some(label.clone()), + })); + let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + let SubcommandReturnValue::RegisterAccount { account_id } = result else { + anyhow::bail!("Expected RegisterAccount return value"); + }; + + // Initialize using the label instead of account ID + let command = Command::AuthTransfer(AuthTransferSubcommand::Init { + account_id: label.into(), + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + 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?; + + 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); + + let account = ctx + .wallet() + .get_account_private(account_id) + .context("Failed to get private account")?; + + assert_eq!( + account.program_owner, + Program::authenticated_transfer_program().id() + ); + + info!("Successfully initialized private account using label"); + + Ok(()) +} + +#[test] +async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> { + let mut ctx = TestContext::new().await?; + + // Both transfers below will target this same node with distinct identifiers. + let chain_index = ctx.wallet_mut().create_private_accounts_key(None); + let (npk, vpk) = { + let key_chain = ctx + .wallet() + .storage() + .key_chain() + .private_account_key_chain_by_index(&chain_index) + .expect("Failed to get private account key chain for chain index"); + ( + key_chain.nullifier_public_key, + key_chain.viewing_public_key.clone(), + ) + }; + + let npk_hex = hex::encode(npk.0); + let vpk_hex = hex::encode(vpk.0); + + let identifier_1 = 1_u128; + let identifier_2 = 2_u128; + + let sender_0: AccountId = ctx.existing_public_accounts()[0]; + let sender_1: AccountId = ctx.existing_public_accounts()[1]; + + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::AuthTransfer(AuthTransferSubcommand::Send { + from: public_mention(sender_0), + to: None, + to_npk: Some(npk_hex.clone()), + to_vpk: Some(vpk_hex.clone()), + to_identifier: Some(identifier_1), + amount: 100, + }), + ) + .await?; + + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::AuthTransfer(AuthTransferSubcommand::Send { + from: public_mention(sender_1), + to: None, + to_npk: Some(npk_hex), + to_vpk: Some(vpk_hex), + to_identifier: Some(identifier_2), + amount: 200, + }), + ) + .await?; + + 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?; + + // Both accounts must be discovered with the correct balances. + let account_id_1 = AccountId::for_regular_private_account(&npk, 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 acc_2 = ctx + .wallet() + .get_account_private(account_id_2) + .context("account for identifier 2 not found after sync")?; + assert_eq!(acc_2.balance, 200); + + // Both account ids must resolve to the same key node. + let found_acc1 = ctx + .wallet() + .storage() + .key_chain() + .private_account(account_id_1) + .context("account_id_1 not found in key chain")?; + let found_acc2 = ctx + .wallet() + .storage() + .key_chain() + .private_account(account_id_2) + .context("account_id_2 not found in key chain")?; + assert_eq!( + found_acc1.chain_index, found_acc2.chain_index, + "identifiers 1 and 2 under the same NPK must share a single chain_index" + ); + assert_eq!( + found_acc1.chain_index, + Some(chain_index), + "both accounts must resolve to the key node created at the start of the test" + ); + + info!("Successfully transferred to two distinct identifiers under the same NPK"); + + Ok(()) +} + +#[test] +async fn ppt_that_chain_calls_faucet_is_dropped() -> Result<()> { + use nssa::{ + EphemeralPublicKey, SharedSecretKey, execute_and_prove, + privacy_preserving_transaction::{self, circuit::ProgramWithDependencies}, + }; + use nssa_core::{InputAccountIdentity, account::AccountWithMetadata}; + + let ctx = TestContext::new().await?; + + let binary = std::fs::read( + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../artifacts/test_program_methods/faucet_chain_caller.bin"), + )?; + let deploy_tx = NSSATransaction::ProgramDeployment(nssa::ProgramDeploymentTransaction::new( + nssa::program_deployment_transaction::Message::new(binary.clone()), + )); + ctx.sequencer_client().send_transaction(deploy_tx).await?; + + info!("Waiting for deploy block creation"); + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + + let faucet_account_id = nssa::system_faucet_account_id(); + let attacker_id = ctx.existing_public_accounts()[0]; + let faucet_program_id = Program::faucet().id(); + let vault_program_id = Program::vault().id(); + let auth_transfer_program_id = Program::authenticated_transfer_program().id(); + let nsk: nssa_core::NullifierSecretKey = [3; 32]; + let npk = NullifierPublicKey::from(&nsk); + let vpk = Secp256k1Point::from_scalar([4; 32]); + let ssk = SharedSecretKey::new([55; 32], &vpk); + let epk = EphemeralPublicKey::from_scalar([55; 32]); + let attacker_vault_id = { + let seed = vault_core::compute_vault_seed(attacker_id); + AccountId::for_private_pda(&vault_program_id, &seed, &npk, 1337) + }; + let amount: u128 = 1; + + let faucet_pre = AccountWithMetadata::new( + ctx.sequencer_client() + .get_account(faucet_account_id) + .await?, + false, + faucet_account_id, + ); + let vault_pda_pre = AccountWithMetadata::new( + ctx.sequencer_client() + .get_account(attacker_vault_id) + .await?, + false, + attacker_vault_id, + ); + + let faucet_chain_caller = Program::new(binary)?; + let program_with_deps = ProgramWithDependencies::new( + faucet_chain_caller, + [ + (faucet_program_id, Program::faucet()), + (vault_program_id, Program::vault()), + ( + auth_transfer_program_id, + Program::authenticated_transfer_program(), + ), + ] + .into(), + ); + + let instruction = + Program::serialize_instruction((faucet_program_id, vault_program_id, attacker_id, amount))?; + + let (output, proof) = execute_and_prove( + vec![faucet_pre, vault_pda_pre], + instruction, + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivatePdaInit { + npk, + ssk, + identifier: 1337, + seed: None, + }, + ], + &program_with_deps, + )?; + + let message = privacy_preserving_transaction::Message::try_from_circuit_output( + vec![faucet_account_id], + vec![], + vec![(npk, vpk, epk)], + output, + )?; + let witness_set = privacy_preserving_transaction::WitnessSet::for_message(&message, proof, &[]); + let attack_ppt = NSSATransaction::PrivacyPreserving(nssa::PrivacyPreservingTransaction::new( + message, + witness_set, + )); + + 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 tx_hash = ctx.sequencer_client().send_transaction(attack_ppt).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 tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?; + + assert_eq!(faucet_balance_after, faucet_balance_before); + assert_eq!(vault_balance_after, vault_balance_before); + assert!(tx_on_chain.is_none()); + + Ok(()) +} diff --git a/integration_tests/tests/auth_transfer/public.rs b/integration_tests/tests/auth_transfer/public.rs index 7f8c3836..72685d0b 100644 --- a/integration_tests/tests/auth_transfer/public.rs +++ b/integration_tests/tests/auth_transfer/public.rs @@ -1,15 +1,19 @@ -use std::time::Duration; +use std::{path::PathBuf, time::Duration}; use anyhow::Result; -use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, format_public_account_id}; +use common::transaction::NSSATransaction; +use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, public_mention}; use log::info; -use nssa::program::Program; +use nssa::{program::Program, public_transaction, system_faucet_account_id}; use sequencer_service_rpc::RpcClient as _; use tokio::test; -use wallet::cli::{ - Command, SubcommandReturnValue, - account::{AccountSubcommand, NewSubcommand}, - programs::native_token_transfer::AuthTransferSubcommand, +use wallet::{ + account::Label, + cli::{ + CliAccountMention, Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + programs::native_token_transfer::AuthTransferSubcommand, + }, }; #[test] @@ -17,10 +21,11 @@ async fn successful_transfer_to_existing_account() -> Result<()> { let mut ctx = TestContext::new().await?; let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_public_account_id(ctx.existing_public_accounts()[0]), - to: Some(format_public_account_id(ctx.existing_public_accounts()[1])), + from: public_mention(ctx.existing_public_accounts()[0]), + to: Some(public_mention(ctx.existing_public_accounts()[1])), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: 100, }); @@ -64,8 +69,9 @@ pub async fn successful_transfer_to_new_account() -> Result<()> { let new_persistent_account_id = ctx .wallet() .storage() - .user_data - .account_ids() + .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] @@ -73,10 +79,11 @@ pub async fn successful_transfer_to_new_account() -> Result<()> { .expect("Failed to find newly created account in the wallet storage"); let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_public_account_id(ctx.existing_public_accounts()[0]), - to: Some(format_public_account_id(new_persistent_account_id)), + from: public_mention(ctx.existing_public_accounts()[0]), + to: Some(public_mention(new_persistent_account_id)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: 100, }); @@ -109,10 +116,11 @@ async fn failed_transfer_with_insufficient_balance() -> Result<()> { let mut ctx = TestContext::new().await?; let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_public_account_id(ctx.existing_public_accounts()[0]), - to: Some(format_public_account_id(ctx.existing_public_accounts()[1])), + from: public_mention(ctx.existing_public_accounts()[0]), + to: Some(public_mention(ctx.existing_public_accounts()[1])), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: 1_000_000, }); @@ -147,10 +155,11 @@ async fn two_consecutive_successful_transfers() -> Result<()> { // First transfer let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_public_account_id(ctx.existing_public_accounts()[0]), - to: Some(format_public_account_id(ctx.existing_public_accounts()[1])), + from: public_mention(ctx.existing_public_accounts()[0]), + to: Some(public_mention(ctx.existing_public_accounts()[1])), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: 100, }); @@ -179,10 +188,11 @@ async fn two_consecutive_successful_transfers() -> Result<()> { // Second transfer let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_public_account_id(ctx.existing_public_accounts()[0]), - to: Some(format_public_account_id(ctx.existing_public_accounts()[1])), + from: public_mention(ctx.existing_public_accounts()[0]), + to: Some(public_mention(ctx.existing_public_accounts()[1])), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: 100, }); @@ -226,7 +236,7 @@ async fn initialize_public_account() -> Result<()> { }; let command = Command::AuthTransfer(AuthTransferSubcommand::Init { - account_id: format_public_account_id(account_id), + account_id: public_mention(account_id), }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; @@ -245,3 +255,268 @@ async fn initialize_public_account() -> Result<()> { Ok(()) } + +#[test] +async fn successful_transfer_using_from_label() -> Result<()> { + let mut ctx = TestContext::new().await?; + + // Assign a label to the sender account + let label = Label::new("sender-label"); + let command = Command::Account(AccountSubcommand::Label { + account_id: public_mention(ctx.existing_public_accounts()[0]), + label: label.clone(), + }); + 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_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; + + 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?; + + assert_eq!(acc_1_balance, 9900); + assert_eq!(acc_2_balance, 20100); + + info!("Successfully transferred using from_label"); + + Ok(()) +} + +#[test] +async fn successful_transfer_using_to_label() -> Result<()> { + let mut ctx = TestContext::new().await?; + + // Assign a label to the receiver account + let label = Label::new("receiver-label"); + let command = Command::Account(AccountSubcommand::Label { + account_id: public_mention(ctx.existing_public_accounts()[1]), + label: label.clone(), + }); + 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_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; + + 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?; + + assert_eq!(acc_1_balance, 9900); + assert_eq!(acc_2_balance, 20100); + + info!("Successfully transferred using to_label"); + + Ok(()) +} + +#[test] +async fn cannot_transfer_funds_from_system_faucet_account() -> Result<()> { + let ctx = TestContext::new().await?; + let faucet_account_id = system_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 amount = 1_u128; + let message = public_transaction::Message::try_new( + Program::authenticated_transfer_program().id(), + vec![faucet_account_id, recipient], + vec![], + authenticated_transfer_core::Instruction::Transfer { amount }, + )?; + let tx = nssa::PublicTransaction::new( + message, + nssa::public_transaction::WitnessSet::from_raw_parts(vec![]), + ); + let tx_hash = ctx + .sequencer_client() + .send_transaction(NSSATransaction::Public(tx)) + .await?; + + 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 tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?; + + assert_eq!(recipient_balance_after, recipient_balance_before); + assert_eq!(faucet_balance_after, faucet_balance_before); + assert!(tx_on_chain.is_none()); + + Ok(()) +} + +#[test] +async fn cannot_execute_faucet_program() -> Result<()> { + let ctx = TestContext::new().await?; + let faucet_account_id = system_faucet_account_id(); + + let recipient = ctx.existing_public_accounts()[0]; + let vault_program_id = Program::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 amount = 1_u128; + let message = public_transaction::Message::try_new( + Program::faucet().id(), + vec![faucet_account_id, recipient_vault_id], + vec![], + faucet_core::Instruction::Transfer { + vault_program_id, + recipient_id: recipient, + amount, + }, + )?; + let tx = nssa::PublicTransaction::new( + message, + nssa::public_transaction::WitnessSet::from_raw_parts(vec![]), + ); + let tx_hash = ctx + .sequencer_client() + .send_transaction(NSSATransaction::Public(tx)) + .await?; + + 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 tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?; + + assert_eq!(recipient_balance_after, recipient_balance_before); + assert_eq!(faucet_balance_after, faucet_balance_before); + assert!(tx_on_chain.is_none()); + + Ok(()) +} + +#[test] +async fn user_tx_that_chain_calls_faucet_is_dropped() -> Result<()> { + let ctx = TestContext::new().await?; + + let binary = std::fs::read( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../artifacts/test_program_methods/faucet_chain_caller.bin"), + )?; + let faucet_chain_caller_id = Program::new(binary.clone())?.id(); + let deploy_tx = NSSATransaction::ProgramDeployment(nssa::ProgramDeploymentTransaction::new( + nssa::program_deployment_transaction::Message::new(binary), + )); + ctx.sequencer_client().send_transaction(deploy_tx).await?; + + info!("Waiting for deploy block creation"); + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + + let faucet_account_id = system_faucet_account_id(); + let attacker = ctx.existing_public_accounts()[0]; + let faucet_program_id = Program::faucet().id(); + let vault_program_id = Program::vault().id(); + let attacker_vault_id = vault_core::compute_vault_account_id(vault_program_id, attacker); + let amount: u128 = 1; + + let message = public_transaction::Message::try_new( + faucet_chain_caller_id, + vec![faucet_account_id, attacker_vault_id], + vec![], + (faucet_program_id, vault_program_id, attacker, amount), + )?; + let attack_tx = NSSATransaction::Public(nssa::PublicTransaction::new( + message, + nssa::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 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 tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?; + + assert_eq!(faucet_balance_after, faucet_balance_before); + assert_eq!(vault_balance_after, vault_balance_before); + assert!(tx_on_chain.is_none()); + + Ok(()) +} diff --git a/integration_tests/tests/indexer.rs b/integration_tests/tests/indexer.rs index cb8cf0e9..5cf33cde 100644 --- a/integration_tests/tests/indexer.rs +++ b/integration_tests/tests/indexer.rs @@ -1,62 +1,91 @@ #![expect( + clippy::shadow_unrelated, clippy::tests_outside_test_module, reason = "We don't care about these in tests" )] use std::time::Duration; -use anyhow::Result; +use anyhow::{Context as _, Result}; use indexer_service_rpc::RpcClient as _; -use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, format_public_account_id}; +use integration_tests::{ + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention, + verify_commitment_is_in_state, +}; use log::info; -use tokio::test; -use wallet::cli::{Command, programs::native_token_transfer::AuthTransferSubcommand}; +use nssa::AccountId; +use wallet::{ + account::Label, + cli::{CliAccountMention, Command, programs::native_token_transfer::AuthTransferSubcommand}, +}; -/// Timeout in milliseconds to reliably await for block finalization. -const L2_TO_L1_TIMEOUT_MILLIS: u64 = 600_000; +/// Maximum time to wait for the indexer to catch up to the sequencer. +const L2_TO_L1_TIMEOUT_MILLIS: u64 = 180_000; -#[test] +/// Poll the indexer until its last finalized block id reaches the sequencer's +/// current last block id or until [`L2_TO_L1_TIMEOUT_MILLIS`] elapses. +/// Returns the last indexer block id observed. +async fn wait_for_indexer_to_catch_up(ctx: &TestContext) -> Result { + let timeout = Duration::from_millis(L2_TO_L1_TIMEOUT_MILLIS); + let block_id_to_catch_up = + sequencer_service_rpc::RpcClient::get_last_block_id(ctx.sequencer_client()).await?; + let mut last_ind: u64 = 1; + let inner = async { + loop { + let ind = ctx + .indexer_client() + .get_last_finalized_block_id() + .await? + .unwrap_or(0); + last_ind = ind; + if ind >= block_id_to_catch_up { + let last_seq = + sequencer_service_rpc::RpcClient::get_last_block_id(ctx.sequencer_client()) + .await?; + info!( + "Indexer caught up. Indexer last block id: {ind}. Current sequencer last block id: {last_seq}" + ); + return Ok(ind); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + }; + tokio::time::timeout(timeout, inner) + .await + .with_context(|| { + format!( + "Indexer failed to catch up within {L2_TO_L1_TIMEOUT_MILLIS} milliseconds. Last indexer block id observed: {last_ind}, but needed to catch up to at least {block_id_to_catch_up}" + ) + })? +} + +#[tokio::test] async fn indexer_test_run() -> Result<()> { let ctx = TestContext::new().await?; - // RUN OBSERVATION - tokio::time::sleep(std::time::Duration::from_millis(L2_TO_L1_TIMEOUT_MILLIS)).await; + let last_block_indexer = wait_for_indexer_to_catch_up(&ctx).await?; let last_block_seq = sequencer_service_rpc::RpcClient::get_last_block_id(ctx.sequencer_client()).await?; info!("Last block on seq now is {last_block_seq}"); - - let last_block_indexer = ctx - .indexer_client() - .get_last_finalized_block_id() - .await - .unwrap(); - info!("Last block on ind now is {last_block_indexer}"); - assert!(last_block_indexer > 1); + assert!(last_block_indexer > 0); Ok(()) } -#[test] +#[tokio::test] async fn indexer_block_batching() -> Result<()> { let ctx = TestContext::new().await?; - // WAIT info!("Waiting for indexer to parse blocks"); - tokio::time::sleep(std::time::Duration::from_millis(L2_TO_L1_TIMEOUT_MILLIS)).await; - - let last_block_indexer = ctx - .indexer_client() - .get_last_finalized_block_id() - .await - .unwrap(); + let last_block_indexer = wait_for_indexer_to_catch_up(&ctx).await?; info!("Last block on ind now is {last_block_indexer}"); - assert!(last_block_indexer > 1); + assert!(last_block_indexer > 0); // Getting wide batch to fit all blocks (from latest backwards) let mut block_batch = ctx.indexer_client().get_blocks(None, 100).await.unwrap(); @@ -78,15 +107,16 @@ async fn indexer_block_batching() -> Result<()> { Ok(()) } -#[test] +#[tokio::test] async fn indexer_state_consistency() -> Result<()> { let mut ctx = TestContext::new().await?; let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_public_account_id(ctx.existing_public_accounts()[0]), - to: Some(format_public_account_id(ctx.existing_public_accounts()[1])), + from: public_mention(ctx.existing_public_accounts()[0]), + to: Some(public_mention(ctx.existing_public_accounts()[1])), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: 100, }); @@ -113,9 +143,39 @@ async fn indexer_state_consistency() -> Result<()> { assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); - // WAIT + 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_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 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); + + info!("Successfully transferred privately to owned account"); + info!("Waiting for indexer to parse blocks"); - tokio::time::sleep(std::time::Duration::from_millis(L2_TO_L1_TIMEOUT_MILLIS)).await; + wait_for_indexer_to_catch_up(&ctx).await?; let acc1_ind_state = ctx .indexer_client() @@ -147,3 +207,73 @@ async fn indexer_state_consistency() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn indexer_state_consistency_with_labels() -> Result<()> { + let mut ctx = TestContext::new().await?; + + // Assign labels to both accounts + let from_label = Label::new("idx-sender-label"); + let to_label = Label::new("idx-receiver-label"); + + let label_cmd = Command::Account(wallet::cli::account::AccountSubcommand::Label { + account_id: public_mention(ctx.existing_public_accounts()[0]), + label: from_label.clone(), + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), label_cmd).await?; + + let label_cmd = Command::Account(wallet::cli::account::AccountSubcommand::Label { + account_id: public_mention(ctx.existing_public_accounts()[1]), + label: to_label.clone(), + }); + 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_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 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?; + + assert_eq!(acc_1_balance, 9900); + assert_eq!(acc_2_balance, 20100); + + info!("Waiting for indexer to parse blocks"); + wait_for_indexer_to_catch_up(&ctx).await?; + + let acc1_ind_state = ctx + .indexer_client() + .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?; + + assert_eq!(acc1_ind_state, acc1_seq_state.into()); + + info!("Indexer state is consistent after label-based transfer"); + + Ok(()) +} diff --git a/integration_tests/tests/indexer_ffi.rs b/integration_tests/tests/indexer_ffi.rs new file mode 100644 index 00000000..178b2640 --- /dev/null +++ b/integration_tests/tests/indexer_ffi.rs @@ -0,0 +1,403 @@ +#![expect( + clippy::shadow_unrelated, + clippy::tests_outside_test_module, + clippy::undocumented_unsafe_blocks, + reason = "We don't care about these in tests" +)] + +use std::{ + ffi::{CString, c_char}, + fs::File, + io::Write as _, + net::SocketAddr, +}; + +use anyhow::{Context as _, Result}; +use indexer_ffi::{ + IndexerServiceFFI, OperationStatus, Runtime, + api::{ + PointerResult, + lifecycle::InitializedIndexerServiceFFIResult, + types::{FfiAccountId, FfiOption, FfiVec, account::FfiAccount, block::FfiBlock}, + }, +}; +use integration_tests::{ + BlockingTestContext, TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, + public_mention, verify_commitment_is_in_state, +}; +use log::{debug, info}; +use nssa::AccountId; +use tempfile::TempDir; +use wallet::{ + account::Label, + cli::{Command, programs::native_token_transfer::AuthTransferSubcommand}, +}; + +/// Maximum time to wait for the indexer to catch up to the sequencer. +const L2_TO_L1_TIMEOUT_MILLIS: u64 = 180_000; + +unsafe extern "C" { + unsafe fn query_last_block( + runtime: *const Runtime, + indexer: *const IndexerServiceFFI, + ) -> PointerResult; + + unsafe fn query_block_vec( + runtime: *const Runtime, + indexer: *const IndexerServiceFFI, + before: FfiOption, + limit: u64, + ) -> PointerResult, OperationStatus>; + + unsafe fn query_account( + runtime: *const Runtime, + indexer: *const IndexerServiceFFI, + account_id: FfiAccountId, + ) -> PointerResult; + + unsafe fn start_indexer( + runtime: *const Runtime, + config_path: *const c_char, + port: u16, + ) -> InitializedIndexerServiceFFIResult; +} + +fn setup_indexer_ffi( + runtime: &Runtime, + bedrock_addr: SocketAddr, +) -> Result<(IndexerServiceFFI, TempDir)> { + let temp_indexer_dir = + tempfile::tempdir().context("Failed to create temp dir for indexer home")?; + + debug!( + "Using temp indexer home at {}", + temp_indexer_dir.path().display() + ); + + let indexer_config = + integration_tests::config::indexer_config(bedrock_addr, temp_indexer_dir.path().to_owned()) + .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"); + let mut file = File::create(config_path.as_path())?; + file.write_all(&config_json)?; + file.flush()?; + + let res = + // SAFETY: lib function ensures validity of value. + unsafe { start_indexer(std::ptr::from_ref(runtime), CString::new(config_path.to_str().unwrap())?.as_ptr(), 0) }; + + if res.error.is_error() { + anyhow::bail!("Indexer FFI error {:?}", res.error); + } + + Ok(( + // SAFETY: lib function ensures validity of value. + unsafe { std::ptr::read(res.value) }, + temp_indexer_dir, + )) +} + +/// Prepare setup for tests. +fn setup() -> Result<(BlockingTestContext, IndexerServiceFFI, TempDir)> { + let ctx = TestContext::builder().disable_indexer().build_blocking()?; + // Safety: ctx runtime is valid for the lifetime of the returned Runtime + let runtime = unsafe { Runtime::from_borrowed(ctx.runtime()) }; + let (indexer_ffi, indexer_dir) = setup_indexer_ffi(&runtime, ctx.ctx().bedrock_addr())?; + + Ok((ctx, indexer_ffi, indexer_dir)) +} + +#[test] +fn indexer_test_run_ffi() -> Result<()> { + let (ctx, indexer_ffi, _indexer_dir) = setup()?; + + // RUN OBSERVATION + std::thread::sleep(std::time::Duration::from_millis(L2_TO_L1_TIMEOUT_MILLIS)); + + // Safety: ctx runtime is valid for the lifetime of the returned Runtime + let runtime = unsafe { Runtime::from_borrowed(ctx.runtime()) }; + let last_block_indexer_ffi_res = + unsafe { query_last_block(&raw const runtime, &raw const indexer_ffi) }; + + assert!(last_block_indexer_ffi_res.error.is_ok()); + + let last_block_indexer_ffi = unsafe { *last_block_indexer_ffi_res.value }; + + info!("Last block on indexer FFI now is {last_block_indexer_ffi}"); + + assert!(last_block_indexer_ffi > 0); + + Ok(()) +} + +#[test] +fn indexer_ffi_block_batching() -> Result<()> { + let (ctx, indexer_ffi, _indexer_dir) = setup()?; + + // WAIT + info!("Waiting for indexer to parse blocks"); + std::thread::sleep(std::time::Duration::from_millis(L2_TO_L1_TIMEOUT_MILLIS)); + + // Safety: ctx runtime is valid for the lifetime of the returned Runtime + let runtime = unsafe { Runtime::from_borrowed(ctx.runtime()) }; + let last_block_indexer_ffi_res = + unsafe { query_last_block(&raw const runtime, &raw const indexer_ffi) }; + + assert!(last_block_indexer_ffi_res.error.is_ok()); + + let last_block_indexer = unsafe { *last_block_indexer_ffi_res.value }; + + info!("Last block on indexer FFI now is {last_block_indexer}"); + + assert!(last_block_indexer > 0); + + let before_ffi = FfiOption::::from_none(); + let limit = 100; + + let block_batch_ffi_res = unsafe { + query_block_vec( + &raw const runtime, + &raw const indexer_ffi, + before_ffi, + limit, + ) + }; + + assert!(block_batch_ffi_res.error.is_ok()); + + let block_batch = unsafe { &*block_batch_ffi_res.value }; + + let mut last_block_prev_hash = unsafe { block_batch.get(0) }.header.prev_block_hash.data; + + for i in 1..block_batch.len { + let block = unsafe { block_batch.get(i) }; + + assert_eq!(last_block_prev_hash, block.header.hash.data); + + info!("Block {} chain-consistent", block.header.block_id); + + last_block_prev_hash = block.header.prev_block_hash.data; + } + + Ok(()) +} + +#[test] +fn indexer_ffi_state_consistency() -> Result<()> { + let (mut ctx, indexer_ffi, _indexer_dir) = setup()?; + + let command = Command::AuthTransfer(AuthTransferSubcommand::Send { + from: public_mention(ctx.ctx().existing_public_accounts()[0]), + to: Some(public_mention(ctx.ctx().existing_public_accounts()[1])), + to_npk: None, + to_vpk: None, + amount: 100, + to_identifier: Some(0), + }); + + ctx.block_on_mut(|ctx| wallet::cli::execute_subcommand(ctx.wallet_mut(), command))?; + + info!("Waiting for next block creation"); + std::thread::sleep(std::time::Duration::from_secs( + TIME_TO_WAIT_FOR_BLOCK_SECONDS, + )); + + info!("Checking correct balance move"); + let acc_1_balance = ctx.block_on(|ctx| { + sequencer_service_rpc::RpcClient::get_account_balance( + ctx.sequencer_client(), + ctx.existing_public_accounts()[0], + ) + })?; + let acc_2_balance = ctx.block_on(|ctx| { + sequencer_service_rpc::RpcClient::get_account_balance( + ctx.sequencer_client(), + ctx.existing_public_accounts()[1], + ) + })?; + + info!("Balance of sender: {acc_1_balance:#?}"); + info!("Balance of receiver: {acc_2_balance:#?}"); + + assert_eq!(acc_1_balance, 9900); + assert_eq!(acc_2_balance, 20100); + + let from: AccountId = ctx.ctx().existing_private_accounts()[0]; + let to: AccountId = ctx.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, + amount: 100, + to_identifier: Some(0), + }); + + ctx.block_on_mut(|ctx| wallet::cli::execute_subcommand(ctx.wallet_mut(), command))?; + + info!("Waiting for next block creation"); + std::thread::sleep(std::time::Duration::from_secs( + TIME_TO_WAIT_FOR_BLOCK_SECONDS, + )); + + let new_commitment1 = ctx + .ctx() + .wallet() + .get_private_account_commitment(from) + .context("Failed to get private account commitment for sender")?; + let commitment_check1 = + ctx.block_on(|ctx| verify_commitment_is_in_state(new_commitment1, ctx.sequencer_client())); + assert!(commitment_check1); + + let new_commitment2 = ctx + .ctx() + .wallet() + .get_private_account_commitment(to) + .context("Failed to get private account commitment for receiver")?; + let commitment_check2 = + ctx.block_on(|ctx| verify_commitment_is_in_state(new_commitment2, ctx.sequencer_client())); + assert!(commitment_check2); + + info!("Successfully transferred privately to owned account"); + + // WAIT + info!("Waiting for indexer to parse blocks"); + std::thread::sleep(std::time::Duration::from_millis(L2_TO_L1_TIMEOUT_MILLIS)); + + // Safety: ctx runtime is valid for the lifetime of the returned Runtime + let runtime = unsafe { Runtime::from_borrowed(ctx.runtime()) }; + let acc1_ind_state_ffi = unsafe { + query_account( + &raw const runtime, + &raw const indexer_ffi, + (&ctx.ctx().existing_public_accounts()[0]).into(), + ) + }; + + assert!(acc1_ind_state_ffi.error.is_ok()); + + let acc1_ind_state_pre = unsafe { &*acc1_ind_state_ffi.value }; + let acc1_ind_state: indexer_service_protocol::Account = acc1_ind_state_pre.into(); + + let acc2_ind_state_ffi = unsafe { + query_account( + &raw const runtime, + &raw const indexer_ffi, + (&ctx.ctx().existing_public_accounts()[1]).into(), + ) + }; + + assert!(acc2_ind_state_ffi.error.is_ok()); + + let acc2_ind_state_pre = unsafe { &*acc2_ind_state_ffi.value }; + let acc2_ind_state: indexer_service_protocol::Account = acc2_ind_state_pre.into(); + + info!("Checking correct state transition"); + let acc1_seq_state = ctx.block_on(|ctx| { + sequencer_service_rpc::RpcClient::get_account( + ctx.sequencer_client(), + ctx.existing_public_accounts()[0], + ) + })?; + let acc2_seq_state = ctx.block_on(|ctx| { + sequencer_service_rpc::RpcClient::get_account( + ctx.sequencer_client(), + ctx.existing_public_accounts()[1], + ) + })?; + + assert_eq!(acc1_ind_state, acc1_seq_state.into()); + assert_eq!(acc2_ind_state, acc2_seq_state.into()); + + // ToDo: Check private state transition + + Ok(()) +} + +#[test] +fn indexer_ffi_state_consistency_with_labels() -> Result<()> { + let (mut ctx, indexer_ffi, _indexer_dir) = setup()?; + + // Assign labels to both accounts + let from_label = Label::new("idx-sender-label"); + let to_label = Label::new("idx-receiver-label"); + + let label_cmd = Command::Account(wallet::cli::account::AccountSubcommand::Label { + account_id: public_mention(ctx.ctx().existing_public_accounts()[0]), + label: from_label.clone(), + }); + ctx.block_on_mut(|ctx| wallet::cli::execute_subcommand(ctx.wallet_mut(), label_cmd))?; + + let label_cmd = Command::Account(wallet::cli::account::AccountSubcommand::Label { + account_id: public_mention(ctx.ctx().existing_public_accounts()[1]), + label: to_label.clone(), + }); + ctx.block_on_mut(|ctx| wallet::cli::execute_subcommand(ctx.wallet_mut(), label_cmd))?; + + // Send using labels instead of account IDs + let command = Command::AuthTransfer(AuthTransferSubcommand::Send { + from: from_label.into(), + to: Some(to_label.into()), + to_npk: None, + to_vpk: None, + amount: 100, + to_identifier: Some(0), + }); + + ctx.block_on_mut(|ctx| wallet::cli::execute_subcommand(ctx.wallet_mut(), command))?; + + info!("Waiting for next block creation"); + std::thread::sleep(std::time::Duration::from_secs( + TIME_TO_WAIT_FOR_BLOCK_SECONDS, + )); + + let acc_1_balance = ctx.block_on(|ctx| { + sequencer_service_rpc::RpcClient::get_account_balance( + ctx.sequencer_client(), + ctx.existing_public_accounts()[0], + ) + })?; + let acc_2_balance = ctx.block_on(|ctx| { + sequencer_service_rpc::RpcClient::get_account_balance( + ctx.sequencer_client(), + ctx.existing_public_accounts()[1], + ) + })?; + + assert_eq!(acc_1_balance, 9900); + assert_eq!(acc_2_balance, 20100); + + info!("Waiting for indexer to parse blocks"); + std::thread::sleep(std::time::Duration::from_millis(L2_TO_L1_TIMEOUT_MILLIS)); + + // Safety: ctx runtime is valid for the lifetime of the returned Runtime + let runtime = unsafe { Runtime::from_borrowed(ctx.runtime()) }; + let acc1_ind_state_ffi = unsafe { + query_account( + &raw const runtime, + &raw const indexer_ffi, + (&ctx.ctx().existing_public_accounts()[0]).into(), + ) + }; + + assert!(acc1_ind_state_ffi.error.is_ok()); + + let acc1_ind_state_pre = unsafe { &*acc1_ind_state_ffi.value }; + let acc1_ind_state: indexer_service_protocol::Account = acc1_ind_state_pre.into(); + + let acc1_seq_state = ctx.block_on(|ctx| { + sequencer_service_rpc::RpcClient::get_account( + ctx.sequencer_client(), + ctx.existing_public_accounts()[0], + ) + })?; + + assert_eq!(acc1_ind_state, acc1_seq_state.into()); + + info!("Indexer state is consistent after label-based transfer"); + + Ok(()) +} diff --git a/integration_tests/tests/keys_restoration.rs b/integration_tests/tests/keys.rs similarity index 84% rename from integration_tests/tests/keys_restoration.rs rename to integration_tests/tests/keys.rs index cdbe2e6b..0cc3c187 100644 --- a/integration_tests/tests/keys_restoration.rs +++ b/integration_tests/tests/keys.rs @@ -8,8 +8,8 @@ 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, - format_private_account_id, format_public_account_id, verify_commitment_is_in_state, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, fetch_privacy_preserving_tx, private_mention, + public_mention, verify_commitment_is_in_state, }; use key_protocol::key_management::key_tree::chain_index::ChainIndex; use log::info; @@ -59,20 +59,20 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> { }; // Get the keys for the newly created account - let (to_keys, _) = ctx + let to_account = ctx .wallet() .storage() - .user_data - .get_private_account(to_account_id) - .cloned() + .key_chain() + .private_account(to_account_id) .context("Failed to get private account")?; // Send to this account using claiming path (using npk and vpk instead of account ID) let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_private_account_id(from), + from: private_mention(from), to: None, - to_npk: Some(hex::encode(to_keys.nullifier_public_key.0)), - to_vpk: Some(hex::encode(to_keys.viewing_public_key.0)), + to_npk: Some(hex::encode(to_account.key_chain.nullifier_public_key.0)), + to_vpk: Some(hex::encode(&to_account.key_chain.viewing_public_key.0)), + to_identifier: Some(to_account.kind.identifier()), amount: 100, }); @@ -143,20 +143,22 @@ async fn restore_keys_from_seed() -> Result<()> { // Send to first private account let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_private_account_id(from), - to: Some(format_private_account_id(to_account_id1)), + from: private_mention(from), + to: Some(private_mention(to_account_id1)), to_npk: None, to_vpk: 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: format_private_account_id(from), - to: Some(format_private_account_id(to_account_id2)), + from: private_mention(from), + to: Some(private_mention(to_account_id2)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: 101, }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; @@ -191,20 +193,22 @@ async fn restore_keys_from_seed() -> Result<()> { // Send to first public account let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_public_account_id(from), - to: Some(format_public_account_id(to_account_id3)), + from: public_mention(from), + to: Some(public_mention(to_account_id3)), to_npk: None, to_vpk: 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: format_public_account_id(from), - to: Some(format_public_account_id(to_account_id4)), + from: public_mention(from), + to: Some(public_mention(to_account_id4)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: 103, }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; @@ -218,65 +222,63 @@ async fn restore_keys_from_seed() -> Result<()> { let acc1 = ctx .wallet() .storage() - .user_data - .private_key_tree - .get_node(to_account_id1) + .key_chain() + .private_account(to_account_id1) .expect("Acc 1 should be restored"); let acc2 = ctx .wallet() .storage() - .user_data - .private_key_tree - .get_node(to_account_id2) + .key_chain() + .private_account(to_account_id2) .expect("Acc 2 should be restored"); // Verify restored public accounts let _acc3 = ctx .wallet() .storage() - .user_data - .public_key_tree - .get_node(to_account_id3) + .key_chain() + .pub_account_signing_key(to_account_id3) .expect("Acc 3 should be restored"); let _acc4 = ctx .wallet() .storage() - .user_data - .public_key_tree - .get_node(to_account_id4) + .key_chain() + .pub_account_signing_key(to_account_id4) .expect("Acc 4 should be restored"); assert_eq!( - acc1.value.1.program_owner, + acc1.account.program_owner, Program::authenticated_transfer_program().id() ); assert_eq!( - acc2.value.1.program_owner, + acc2.account.program_owner, Program::authenticated_transfer_program().id() ); - assert_eq!(acc1.value.1.balance, 100); - assert_eq!(acc2.value.1.balance, 101); + assert_eq!(acc1.account.balance, 100); + assert_eq!(acc2.account.balance, 101); info!("Tree checks passed, testing restored accounts can transact"); // Test that restored accounts can send transactions let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_private_account_id(to_account_id1), - to: Some(format_private_account_id(to_account_id2)), + from: private_mention(to_account_id1), + to: Some(private_mention(to_account_id2)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: 10, }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; let command = Command::AuthTransfer(AuthTransferSubcommand::Send { - from: format_public_account_id(to_account_id3), - to: Some(format_public_account_id(to_account_id4)), + from: public_mention(to_account_id3), + to: Some(public_mention(to_account_id4)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: 11, }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; diff --git a/integration_tests/tests/pinata.rs b/integration_tests/tests/pinata.rs index 3285c216..9beb5b1f 100644 --- a/integration_tests/tests/pinata.rs +++ b/integration_tests/tests/pinata.rs @@ -9,8 +9,8 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use common::PINATA_BASE58; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, format_private_account_id, - format_public_account_id, verify_commitment_is_in_state, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention, + verify_commitment_is_in_state, }; use log::info; use sequencer_service_rpc::RpcClient as _; @@ -42,8 +42,6 @@ async fn claim_pinata_to_uninitialized_public_account_fails_fast() -> Result<()> anyhow::bail!("Expected RegisterAccount return value"); }; - let winner_account_id_formatted = format_public_account_id(winner_account_id); - let pinata_balance_pre = ctx .sequencer_client() .get_account_balance(PINATA_BASE58.parse().unwrap()) @@ -52,7 +50,7 @@ async fn claim_pinata_to_uninitialized_public_account_fails_fast() -> Result<()> let claim_result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Pinata(PinataProgramAgnosticSubcommand::Claim { - to: winner_account_id_formatted, + to: public_mention(winner_account_id), }), ) .await; @@ -96,8 +94,6 @@ async fn claim_pinata_to_uninitialized_private_account_fails_fast() -> Result<() anyhow::bail!("Expected RegisterAccount return value"); }; - let winner_account_id_formatted = format_private_account_id(winner_account_id); - let pinata_balance_pre = ctx .sequencer_client() .get_account_balance(PINATA_BASE58.parse().unwrap()) @@ -106,7 +102,7 @@ async fn claim_pinata_to_uninitialized_private_account_fails_fast() -> Result<() let claim_result = wallet::cli::execute_subcommand( ctx.wallet_mut(), Command::Pinata(PinataProgramAgnosticSubcommand::Claim { - to: winner_account_id_formatted, + to: private_mention(winner_account_id), }), ) .await; @@ -137,7 +133,7 @@ async fn claim_pinata_to_existing_public_account() -> Result<()> { let pinata_prize = 150; let command = Command::Pinata(PinataProgramAgnosticSubcommand::Claim { - to: format_public_account_id(ctx.existing_public_accounts()[0]), + to: public_mention(ctx.existing_public_accounts()[0]), }); let pinata_balance_pre = ctx @@ -175,7 +171,7 @@ async fn claim_pinata_to_existing_private_account() -> Result<()> { let pinata_prize = 150; let command = Command::Pinata(PinataProgramAgnosticSubcommand::Claim { - to: format_private_account_id(ctx.existing_private_accounts()[0]), + to: private_mention(ctx.existing_private_accounts()[0]), }); let pinata_balance_pre = ctx @@ -235,11 +231,9 @@ async fn claim_pinata_to_new_private_account() -> Result<()> { anyhow::bail!("Expected RegisterAccount return value"); }; - let winner_account_id_formatted = format_private_account_id(winner_account_id); - // Initialize account under auth transfer program let command = Command::AuthTransfer(AuthTransferSubcommand::Init { - account_id: winner_account_id_formatted.clone(), + account_id: private_mention(winner_account_id), }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; @@ -254,7 +248,7 @@ async fn claim_pinata_to_new_private_account() -> Result<()> { // Claim pinata to the new private account let command = Command::Pinata(PinataProgramAgnosticSubcommand::Claim { - to: winner_account_id_formatted, + to: private_mention(winner_account_id), }); let pinata_balance_pre = ctx diff --git a/integration_tests/tests/private_pda.rs b/integration_tests/tests/private_pda.rs new file mode 100644 index 00000000..84bfcb2f --- /dev/null +++ b/integration_tests/tests/private_pda.rs @@ -0,0 +1,352 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "We don't care about these in tests" +)] + +use std::{path::PathBuf, time::Duration}; + +use anyhow::{Context as _, Result}; +use authenticated_transfer_core::Instruction as AuthTransferInstruction; +use common::transaction::NSSATransaction; +use integration_tests::{ + NSSA_PROGRAM_FOR_TEST_PDA_SPEND_PROXY, TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, + verify_commitment_is_in_state, +}; +use key_protocol::key_management::ephemeral_key_holder::EphemeralKeyHolder; +use log::info; +use nssa::{ + AccountId, PrivacyPreservingTransaction, ProgramId, + privacy_preserving_transaction::{ + circuit::{ProgramWithDependencies, execute_and_prove}, + message::Message, + witness_set::WitnessSet, + }, + program::Program, +}; +use nssa_core::{ + InputAccountIdentity, NullifierPublicKey, + account::{Account, AccountWithMetadata}, + encryption::ViewingPublicKey, + program::PdaSeed, +}; +use sequencer_service_rpc::RpcClient as _; +use tokio::test; +use wallet::{ + PrivacyPreservingAccount, WalletCore, + cli::{Command, account::AccountSubcommand}, +}; + +/// Funds a private PDA by calling `auth_transfer` directly. +#[expect( + clippy::too_many_arguments, + reason = "test helper — grouping args would obscure intent" +)] +async fn fund_private_pda( + wallet: &WalletCore, + sender: AccountId, + npk: NullifierPublicKey, + vpk: ViewingPublicKey, + identifier: u128, + seed: PdaSeed, + authority_program_id: ProgramId, + amount: u128, + auth_transfer: &ProgramWithDependencies, +) -> Result<()> { + let pda_account_id = AccountId::for_private_pda(&authority_program_id, &seed, &npk, identifier); + let sender_account = wallet + .get_account_public(sender) + .await + .map_err(|e| anyhow::anyhow!("failed to get sender account: {e}"))?; + let sender_sk = wallet + .get_account_public_signing_key(sender) + .context("sender signing key not found")?; + + 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(&npk); + let ssk = eph_holder.calculate_shared_secret_sender(&vpk); + let epk = eph_holder.generate_ephemeral_public_key(); + + let instruction = Program::serialize_instruction(AuthTransferInstruction::Transfer { amount }) + .context("failed to serialize auth_transfer instruction")?; + + let account_identities = vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivatePdaInit { + npk, + ssk, + identifier, + seed: Some((seed, authority_program_id)), + }, + ]; + + let (output, proof) = execute_and_prove( + vec![sender_pre, pda_pre], + instruction, + account_identities, + auth_transfer, + ) + .map_err(|e| anyhow::anyhow!("circuit proving failed: {e}"))?; + + let message = Message::try_from_circuit_output( + vec![sender], + vec![sender_account.nonce], + vec![(npk, vpk, epk)], + output, + ) + .map_err(|e| anyhow::anyhow!("message build failed: {e}"))?; + + let witness_set = WitnessSet::for_message(&message, proof, &[sender_sk]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + + wallet + .sequencer_client + .send_transaction(NSSATransaction::PrivacyPreserving(tx)) + .await + .map_err(|e| anyhow::anyhow!("send transaction failed: {e}"))?; + + Ok(()) +} + +/// Spends from an owned private PDA to a fresh private-foreign recipient. +/// +/// Alice must own the PDA in the wallet (i.e. it must have been synced after a receive). +#[expect( + clippy::too_many_arguments, + reason = "test helper — grouping args would obscure intent" +)] +async fn spend_private_pda( + wallet: &WalletCore, + pda_account_id: AccountId, + recipient_npk: NullifierPublicKey, + recipient_vpk: ViewingPublicKey, + seed: PdaSeed, + amount: u128, + spend_program: &ProgramWithDependencies, + auth_transfer_id: ProgramId, +) -> Result<()> { + wallet + .send_privacy_preserving_tx( + vec![ + PrivacyPreservingAccount::PrivatePdaOwned(pda_account_id), + PrivacyPreservingAccount::PrivateForeign { + npk: recipient_npk, + vpk: recipient_vpk, + identifier: 0, + }, + ], + Program::serialize_instruction((seed, amount, auth_transfer_id)) + .context("failed to serialize pda_spend_proxy instruction")?, + spend_program, + ) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(()) +} + +/// Two private transfers go to distinct members of the same PDA family (same seed and npk, +/// but identifier=0 and identifier=1). Alice then spends from both PDAs. +/// +/// This exercises the full identifier-diversified private PDA lifecycle: +/// receive(id=0), receive(id=1) → sync → spend(id=0), spend(id=1) → sync → assert. +#[test] +async fn private_pda_family_members_receive_and_spend() -> Result<()> { + let mut ctx = TestContext::new().await?; + + // ── Build alice's key chain ────────────────────────────────────────────────────────────────── + let (alice_id, _alice_chain_index) = ctx.wallet_mut().create_new_account_private(None); + let (alice_npk, alice_vpk) = { + let account = ctx + .wallet() + .storage() + .key_chain() + .private_account(alice_id) + .expect("Account was just created, should be present"); + let kc = account.key_chain; + (kc.nullifier_public_key, kc.viewing_public_key.clone()) + }; + + let proxy = { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../artifacts/test_program_methods") + .join(NSSA_PROGRAM_FOR_TEST_PDA_SPEND_PROXY); + Program::new(std::fs::read(&path).with_context(|| format!("reading {path:?}"))?) + .context("invalid pda_spend_proxy binary")? + }; + let auth_transfer = Program::authenticated_transfer_program(); + let proxy_id = proxy.id(); + let auth_transfer_id = auth_transfer.id(); + let seed = PdaSeed::new([42; 32]); + let amount: u128 = 100; + + let auth_transfer_program = ProgramWithDependencies::new(auth_transfer.clone(), [].into()); + 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); + + // Use two different public senders to avoid nonce conflicts between the back-to-back txs. + let senders = ctx.existing_public_accounts(); + let sender_0 = senders[0]; + let sender_1 = senders[1]; + + // ── Receive ────────────────────────────────────────────────────────────────────────────────── + + info!("Sending to alice_pda_0 (identifier=0)"); + fund_private_pda( + ctx.wallet(), + sender_0, + alice_npk, + alice_vpk.clone(), + 0, + seed, + proxy_id, + amount, + &auth_transfer_program, + ) + .await?; + + info!("Sending to alice_pda_1 (identifier=1)"); + fund_private_pda( + ctx.wallet(), + sender_1, + alice_npk, + alice_vpk.clone(), + 1, + seed, + proxy_id, + amount, + &auth_transfer_program, + ) + .await?; + + info!("Waiting for block"); + 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?; + + // Both PDAs must be discoverable and have the correct balance. + let pda_0_account = ctx + .wallet() + .get_account_private(alice_pda_0_id) + .context("alice_pda_0 not found after sync")?; + assert_eq!(pda_0_account.balance, amount); + + let pda_1_account = ctx + .wallet() + .get_account_private(alice_pda_1_id) + .context("alice_pda_1 not found after sync")?; + assert_eq!(pda_1_account.balance, amount); + + // Commitments for both PDAs must be in the sequencer's state. + let commitment_0 = ctx + .wallet() + .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, + "alice_pda_0 commitment not in state after receive" + ); + + let commitment_1 = ctx + .wallet() + .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, + "alice_pda_1 commitment not in state after receive" + ); + assert_ne!( + commitment_0, commitment_1, + "distinct identifiers must yield distinct commitments" + ); + + // ── Spend ───────────────────────────────────────────────────────────────────────────────────── + + // Fresh recipients — hardcoded npks not in any wallet. + let recipient_npk_0 = NullifierPublicKey([0xAA; 32]); + let recipient_vpk_0 = ViewingPublicKey::from_scalar(recipient_npk_0.0); + + let recipient_npk_1 = NullifierPublicKey([0xBB; 32]); + let recipient_vpk_1 = ViewingPublicKey::from_scalar(recipient_npk_1.0); + + let amount_spend_0: u128 = 13; + let amount_spend_1: u128 = 37; + + info!("Alice spending from alice_pda_0"); + spend_private_pda( + ctx.wallet(), + alice_pda_0_id, + recipient_npk_0, + recipient_vpk_0, + seed, + amount_spend_0, + &spend_program, + auth_transfer_id, + ) + .await?; + + info!("Alice spending from alice_pda_1"); + spend_private_pda( + ctx.wallet(), + alice_pda_1_id, + recipient_npk_1, + recipient_vpk_1, + seed, + amount_spend_1, + &spend_program, + auth_transfer_id, + ) + .await?; + + 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?; + + // After spending, PDAs should have the remaining balance. + let pda_0_spent = ctx + .wallet() + .get_account_private(alice_pda_0_id) + .context("alice_pda_0 not found after spend sync")?; + assert_eq!(pda_0_spent.balance, amount - amount_spend_0); + + let pda_1_spent = ctx + .wallet() + .get_account_private(alice_pda_1_id) + .context("alice_pda_1 not found after spend sync")?; + assert_eq!(pda_1_spent.balance, amount - amount_spend_1); + + // Post-spend commitments must be in state. + let post_spend_commitment_0 = ctx + .wallet() + .get_private_account_commitment(alice_pda_0_id) + .context("post-spend commitment for alice_pda_0 missing")?; + assert!( + verify_commitment_is_in_state(post_spend_commitment_0, ctx.sequencer_client()).await, + "alice_pda_0 post-spend commitment not in state" + ); + + let post_spend_commitment_1 = ctx + .wallet() + .get_private_account_commitment(alice_pda_1_id) + .context("post-spend commitment for alice_pda_1 missing")?; + assert!( + verify_commitment_is_in_state(post_spend_commitment_1, ctx.sequencer_client()).await, + "alice_pda_1 post-spend commitment not in state" + ); + + info!("Private PDA family member receive-and-spend test passed"); + Ok(()) +} diff --git a/integration_tests/tests/shared_accounts.rs b/integration_tests/tests/shared_accounts.rs new file mode 100644 index 00000000..aa1077ff --- /dev/null +++ b/integration_tests/tests/shared_accounts.rs @@ -0,0 +1,236 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "Integration test file, not inside a #[cfg(test)] module" +)] +#![expect( + clippy::shadow_unrelated, + reason = "Sequential wallet commands naturally reuse the `command` binding" +)] + +//! Shared account integration tests. +//! +//! Demonstrates: +//! 1. Group creation and GMS distribution via seal/unseal. +//! 2. Shared regular private account creation via `--for-gms`. +//! 3. Funding a shared account from a public account. +//! 4. Syncing discovers the funded shared account state. + +use std::time::Duration; + +use anyhow::{Context as _, Result}; +use integration_tests::{ + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention, +}; +use log::info; +use tokio::test; +use wallet::{ + account::Label, + cli::{ + Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + group::GroupSubcommand, + programs::native_token_transfer::AuthTransferSubcommand, + }, +}; + +/// Create a group, create a shared account from it, and verify registration. +#[test] +async fn group_create_and_shared_account_registration() -> Result<()> { + let mut ctx = TestContext::new().await?; + + // Create a group + let command = Command::Group(GroupSubcommand::New { + name: "test-group".into(), + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + // Verify group exists + assert!( + ctx.wallet() + .storage() + .key_chain() + .group_key_holder(&Label::new("test-group")) + .is_some() + ); + + // Create a shared regular private account from the group + let command = Command::Account(AccountSubcommand::New(NewSubcommand::PrivateGms { + group: "test-group".into(), + label: Some("shared-acc".into()), + pda: false, + seed: None, + program_id: None, + identifier: None, + })); + + let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + let SubcommandReturnValue::RegisterAccount { + account_id: shared_account_id, + } = result + else { + anyhow::bail!("Expected RegisterAccount return value"); + }; + + // Verify shared account is registered in storage + let entry = ctx + .wallet() + .storage() + .key_chain() + .shared_private_account(shared_account_id) + .context("Shared account not found in storage")?; + assert_eq!(entry.group_label, Label::new("test-group")); + assert!(entry.pda_seed.is_none()); + + info!("Shared account registered: {shared_account_id}"); + Ok(()) +} + +/// GMS seal/unseal round-trip via invite/join, verify key agreement. +#[test] +async fn group_invite_join_key_agreement() -> Result<()> { + let mut ctx = TestContext::new().await?; + + // Generate a sealing key + let command = Command::Group(GroupSubcommand::NewSealingKey); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + // Create a group + let command = Command::Group(GroupSubcommand::New { + name: "alice-group".into(), + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + // Seal GMS for ourselves (simulating invite to another wallet) + let sealing_sk = ctx + .wallet() + .storage() + .key_chain() + .sealing_secret_key() + .context("Sealing key not found")?; + let sealing_pk = + key_protocol::key_management::group_key_holder::SealingPublicKey::from_scalar(sealing_sk); + + let holder = ctx + .wallet() + .storage() + .key_chain() + .group_key_holder(&Label::new("alice-group")) + .context("Group not found")?; + let sealed = holder.seal_for(&sealing_pk); + let sealed_hex = hex::encode(&sealed); + + // Join under a different name (simulating Bob receiving the sealed GMS) + let command = Command::Group(GroupSubcommand::Join { + name: "bob-copy".into(), + sealed: sealed_hex, + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + // Both derive the same keys for the same derivation seed + let alice_holder = ctx + .wallet() + .storage() + .key_chain() + .group_key_holder(&Label::new("alice-group")) + .unwrap(); + let bob_holder = ctx + .wallet() + .storage() + .key_chain() + .group_key_holder(&Label::new("bob-copy")) + .unwrap(); + + let seed = [42_u8; 32]; + let alice_npk = alice_holder + .derive_keys_for_shared_account(&seed) + .generate_nullifier_public_key(); + let bob_npk = bob_holder + .derive_keys_for_shared_account(&seed) + .generate_nullifier_public_key(); + + assert_eq!( + alice_npk, bob_npk, + "Key agreement: same GMS produces same keys" + ); + + info!("Key agreement verified via invite/join"); + Ok(()) +} + +/// Fund a shared account from a public account via auth-transfer, then sync. +/// TODO: Requires auth-transfer init to work with shared accounts (authorization flow). +#[test] +async fn fund_shared_account_from_public() -> Result<()> { + let mut ctx = TestContext::new().await?; + + // Create group and shared account + let command = Command::Group(GroupSubcommand::New { + name: "fund-group".into(), + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + let command = Command::Account(AccountSubcommand::New(NewSubcommand::PrivateGms { + group: "fund-group".into(), + label: None, + pda: false, + seed: None, + program_id: None, + identifier: None, + })); + let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + let SubcommandReturnValue::RegisterAccount { + account_id: shared_id, + } = result + else { + anyhow::bail!("Expected RegisterAccount return value"); + }; + + // Initialize the shared account under auth-transfer + let command = Command::AuthTransfer(AuthTransferSubcommand::Init { + account_id: private_mention(shared_id), + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + 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?; + + // Fund from a public account + let from_public = ctx.existing_public_accounts()[0]; + let command = Command::AuthTransfer(AuthTransferSubcommand::Send { + from: public_mention(from_public), + to: Some(private_mention(shared_id)), + to_npk: None, + to_vpk: None, + to_identifier: None, + amount: 100, + }); + wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; + + 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?; + + // Verify the shared account was updated + let entry = ctx + .wallet() + .storage() + .key_chain() + .shared_private_account(shared_id) + .context("Shared account not found after sync")?; + + info!( + "Shared account balance after funding: {}", + entry.account.balance + ); + assert_eq!( + entry.account.balance, 100, + "Shared account should have received 100" + ); + + Ok(()) +} diff --git a/integration_tests/tests/token.rs b/integration_tests/tests/token.rs index b638b6c9..65011976 100644 --- a/integration_tests/tests/token.rs +++ b/integration_tests/tests/token.rs @@ -8,8 +8,8 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, format_private_account_id, - format_public_account_id, verify_commitment_is_in_state, + TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention, + verify_commitment_is_in_state, }; use key_protocol::key_management::key_tree::chain_index::ChainIndex; use log::info; @@ -17,10 +17,13 @@ use nssa::program::Program; use sequencer_service_rpc::RpcClient as _; use token_core::{TokenDefinition, TokenHolding}; use tokio::test; -use wallet::cli::{ - Command, SubcommandReturnValue, - account::{AccountSubcommand, NewSubcommand}, - programs::token::TokenProgramAgnosticSubcommand, +use wallet::{ + account::Label, + cli::{ + Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + programs::token::TokenProgramAgnosticSubcommand, + }, }; #[test] @@ -79,8 +82,8 @@ async fn create_and_transfer_public_token() -> Result<()> { let name = "A NAME".to_owned(); let total_supply = 37; let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: format_public_account_id(definition_account_id), - supply_account_id: format_public_account_id(supply_account_id), + definition_account_id: public_mention(definition_account_id), + supply_account_id: public_mention(supply_account_id), name: name.clone(), total_supply, }; @@ -126,10 +129,11 @@ async fn create_and_transfer_public_token() -> Result<()> { // Transfer 7 tokens from supply_acc to recipient_account_id let transfer_amount = 7; let subcommand = TokenProgramAgnosticSubcommand::Send { - from: format_public_account_id(supply_account_id), - to: Some(format_public_account_id(recipient_account_id)), + from: public_mention(supply_account_id), + to: Some(public_mention(recipient_account_id)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: transfer_amount, }; @@ -171,8 +175,8 @@ async fn create_and_transfer_public_token() -> Result<()> { // Burn 3 tokens from recipient_acc let burn_amount = 3; let subcommand = TokenProgramAgnosticSubcommand::Burn { - definition: format_public_account_id(definition_account_id), - holder: format_public_account_id(recipient_account_id), + definition: public_mention(definition_account_id), + holder: public_mention(recipient_account_id), amount: burn_amount, }; @@ -215,10 +219,11 @@ async fn create_and_transfer_public_token() -> Result<()> { // Mint 10 tokens at recipient_acc let mint_amount = 10; let subcommand = TokenProgramAgnosticSubcommand::Mint { - definition: format_public_account_id(definition_account_id), - holder: Some(format_public_account_id(recipient_account_id)), + definition: public_mention(definition_account_id), + holder: Some(public_mention(recipient_account_id)), holder_npk: None, holder_vpk: None, + holder_identifier: None, amount: mint_amount, }; @@ -319,8 +324,8 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> { let name = "A NAME".to_owned(); let total_supply = 37; let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: format_public_account_id(definition_account_id), - supply_account_id: format_private_account_id(supply_account_id), + definition_account_id: public_mention(definition_account_id), + supply_account_id: private_mention(supply_account_id), name: name.clone(), total_supply, }; @@ -356,10 +361,11 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> { // Transfer 7 tokens from supply_acc to recipient_account_id let transfer_amount = 7; let subcommand = TokenProgramAgnosticSubcommand::Send { - from: format_private_account_id(supply_account_id), - to: Some(format_private_account_id(recipient_account_id)), + from: private_mention(supply_account_id), + to: Some(private_mention(recipient_account_id)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: transfer_amount, }; @@ -383,8 +389,8 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> { // Burn 3 tokens from recipient_acc let burn_amount = 3; let subcommand = TokenProgramAgnosticSubcommand::Burn { - definition: format_public_account_id(definition_account_id), - holder: format_private_account_id(recipient_account_id), + definition: public_mention(definition_account_id), + holder: private_mention(recipient_account_id), amount: burn_amount, }; @@ -475,8 +481,8 @@ async fn create_token_with_private_definition() -> Result<()> { let name = "A NAME".to_owned(); let total_supply = 37; let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: format_private_account_id(definition_account_id), - supply_account_id: format_public_account_id(supply_account_id), + definition_account_id: private_mention(definition_account_id), + supply_account_id: public_mention(supply_account_id), name: name.clone(), total_supply, }; @@ -544,10 +550,11 @@ async fn create_token_with_private_definition() -> Result<()> { // Mint to public account let mint_amount_public = 10; let subcommand = TokenProgramAgnosticSubcommand::Mint { - definition: format_private_account_id(definition_account_id), - holder: Some(format_public_account_id(recipient_account_id_public)), + definition: private_mention(definition_account_id), + holder: Some(public_mention(recipient_account_id_public)), holder_npk: None, holder_vpk: None, + holder_identifier: None, amount: mint_amount_public, }; @@ -590,10 +597,11 @@ async fn create_token_with_private_definition() -> Result<()> { // Mint to private account let mint_amount_private = 5; let subcommand = TokenProgramAgnosticSubcommand::Mint { - definition: format_private_account_id(definition_account_id), - holder: Some(format_private_account_id(recipient_account_id_private)), + definition: private_mention(definition_account_id), + holder: Some(private_mention(recipient_account_id_private)), holder_npk: None, holder_vpk: None, + holder_identifier: None, amount: mint_amount_private, }; @@ -669,8 +677,8 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> { let name = "A NAME".to_owned(); let total_supply = 37; let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: format_private_account_id(definition_account_id), - supply_account_id: format_private_account_id(supply_account_id), + definition_account_id: private_mention(definition_account_id), + supply_account_id: private_mention(supply_account_id), name, total_supply, }; @@ -728,10 +736,11 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> { // Transfer tokens let transfer_amount = 7; let subcommand = TokenProgramAgnosticSubcommand::Send { - from: format_private_account_id(supply_account_id), - to: Some(format_private_account_id(recipient_account_id)), + from: private_mention(supply_account_id), + to: Some(private_mention(recipient_account_id)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: transfer_amount, }; @@ -841,8 +850,8 @@ async fn shielded_token_transfer() -> Result<()> { let name = "A NAME".to_owned(); let total_supply = 37; let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: format_public_account_id(definition_account_id), - supply_account_id: format_public_account_id(supply_account_id), + definition_account_id: public_mention(definition_account_id), + supply_account_id: public_mention(supply_account_id), name, total_supply, }; @@ -855,10 +864,11 @@ async fn shielded_token_transfer() -> Result<()> { // Perform shielded transfer: public supply -> private recipient let transfer_amount = 7; let subcommand = TokenProgramAgnosticSubcommand::Send { - from: format_public_account_id(supply_account_id), - to: Some(format_private_account_id(recipient_account_id)), + from: public_mention(supply_account_id), + to: Some(private_mention(recipient_account_id)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: transfer_amount, }; @@ -963,8 +973,8 @@ async fn deshielded_token_transfer() -> Result<()> { let name = "A NAME".to_owned(); let total_supply = 37; let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: format_public_account_id(definition_account_id), - supply_account_id: format_private_account_id(supply_account_id), + definition_account_id: public_mention(definition_account_id), + supply_account_id: private_mention(supply_account_id), name, total_supply, }; @@ -977,10 +987,11 @@ async fn deshielded_token_transfer() -> Result<()> { // Perform deshielded transfer: private supply -> public recipient let transfer_amount = 7; let subcommand = TokenProgramAgnosticSubcommand::Send { - from: format_private_account_id(supply_account_id), - to: Some(format_public_account_id(recipient_account_id)), + from: private_mention(supply_account_id), + to: Some(public_mention(recipient_account_id)), to_npk: None, to_vpk: None, + to_identifier: Some(0), amount: transfer_amount, }; @@ -1069,8 +1080,8 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> { let name = "A NAME".to_owned(); let total_supply = 37; let subcommand = TokenProgramAgnosticSubcommand::New { - definition_account_id: format_private_account_id(definition_account_id), - supply_account_id: format_private_account_id(supply_account_id), + definition_account_id: private_mention(definition_account_id), + supply_account_id: private_mention(supply_account_id), name, total_supply, }; @@ -1097,21 +1108,24 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> { }; // Get keys for foreign mint (claiming path) - let (holder_keys, _) = ctx + let holder = ctx .wallet() .storage() - .user_data - .get_private_account(recipient_account_id) - .cloned() + .key_chain() + .private_account(recipient_account_id) .context("Failed to get private account keys")?; + let holder_keys = holder.key_chain; + let holder_identifier = holder.kind.identifier(); + // Mint using claiming path (foreign account) let mint_amount = 9; let subcommand = TokenProgramAgnosticSubcommand::Mint { - definition: format_private_account_id(definition_account_id), + definition: private_mention(definition_account_id), holder: None, holder_npk: Some(hex::encode(holder_keys.nullifier_public_key.0)), - holder_vpk: Some(hex::encode(holder_keys.viewing_public_key.0)), + holder_vpk: Some(hex::encode(&holder_keys.viewing_public_key.0)), + holder_identifier: Some(holder_identifier), amount: mint_amount, }; @@ -1149,3 +1163,188 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> { Ok(()) } + +#[test] +async fn create_token_using_labels() -> Result<()> { + let mut ctx = TestContext::new().await?; + + // Create definition and supply accounts with labels + let def_label = Label::new("token-definition-label"); + let supply_label = Label::new("token-supply-label"); + + let result = wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Public { + cci: None, + label: Some(def_label.clone()), + })), + ) + .await?; + let SubcommandReturnValue::RegisterAccount { + account_id: definition_account_id, + } = result + else { + anyhow::bail!("Expected RegisterAccount return value"); + }; + + let result = wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Public { + cci: None, + label: Some(Label::new(supply_label.clone())), + })), + ) + .await?; + let SubcommandReturnValue::RegisterAccount { + account_id: supply_account_id, + } = result + else { + anyhow::bail!("Expected RegisterAccount return value"); + }; + + // Create token using account labels instead of IDs + let name = "LABELED TOKEN".to_owned(); + let total_supply = 100; + let subcommand = TokenProgramAgnosticSubcommand::New { + definition_account_id: def_label.into(), + supply_account_id: supply_label.into(), + name: name.clone(), + 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; + + let definition_acc = ctx + .sequencer_client() + .get_account(definition_account_id) + .await?; + let token_definition = TokenDefinition::try_from(&definition_acc.data)?; + + assert_eq!(definition_acc.program_owner, Program::token().id()); + assert_eq!( + token_definition, + TokenDefinition::Fungible { + name, + total_supply, + metadata_id: None + } + ); + + let supply_acc = ctx + .sequencer_client() + .get_account(supply_account_id) + .await?; + let token_holding = TokenHolding::try_from(&supply_acc.data)?; + assert_eq!( + token_holding, + TokenHolding::Fungible { + definition_id: definition_account_id, + balance: total_supply + } + ); + + info!("Successfully created token using definition and supply account labels"); + + Ok(()) +} + +#[test] +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"); + }; + + // Create supply account with a label + let supply_label = Label::new("token-supply-sender"); + let result = wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Public { + cci: None, + label: Some(supply_label.clone()), + })), + ) + .await?; + let SubcommandReturnValue::RegisterAccount { + account_id: supply_account_id, + } = result + else { + anyhow::bail!("Expected RegisterAccount return value"); + }; + + // 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"); + }; + + // Create token + let total_supply = 50; + let subcommand = TokenProgramAgnosticSubcommand::New { + definition_account_id: public_mention(definition_account_id), + supply_account_id: public_mention(supply_account_id), + name: "LABEL TEST TOKEN".to_owned(), + 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; + + // Transfer token using from_label instead of from + 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_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; + + let recipient_acc = ctx + .sequencer_client() + .get_account(recipient_account_id) + .await?; + let token_holding = TokenHolding::try_from(&recipient_acc.data)?; + assert_eq!( + token_holding, + TokenHolding::Fungible { + definition_id: definition_account_id, + balance: transfer_amount + } + ); + + info!("Successfully transferred token using from_label"); + + Ok(()) +} diff --git a/integration_tests/tests/tps.rs b/integration_tests/tests/tps.rs index bd46849e..0a6a9038 100644 --- a/integration_tests/tests/tps.rs +++ b/integration_tests/tests/tps.rs @@ -11,14 +11,11 @@ use std::time::{Duration, Instant}; -use anyhow::Result; +use anyhow::{Context as _, Result}; use bytesize::ByteSize; use common::transaction::NSSATransaction; -use integration_tests::{ - TestContext, - config::{InitialData, SequencerPartialConfig}, -}; -use key_protocol::key_management::{KeyChain, ephemeral_key_holder::EphemeralKeyHolder}; +use integration_tests::{TestContext, config::SequencerPartialConfig}; +use key_protocol::key_management::ephemeral_key_holder::EphemeralKeyHolder; use log::info; use nssa::{ Account, AccountId, PrivacyPreservingTransaction, PrivateKey, PublicKey, PublicTransaction, @@ -27,10 +24,11 @@ use nssa::{ public_transaction as putx, }; use nssa_core::{ - MembershipProof, NullifierPublicKey, + InputAccountIdentity, MembershipProof, NullifierPublicKey, account::{AccountWithMetadata, Nonce, data::Data}, encryption::ViewingPublicKey, }; +use sequencer_core::config::GenesisAction; use sequencer_service_rpc::RpcClient as _; use tokio::test; @@ -68,7 +66,64 @@ impl TpsTestManager { Duration::from_secs_f64(number_transactions as f64 / self.target_tps as f64) } + /// Claim funds from each account's vault PDA into the account itself. + /// + /// `GenesisAction::SupplyAccount` funds vault PDAs (not accounts directly), so this step is + /// required before sending `authenticated_transfer` transactions from these accounts. + /// All claim transactions are submitted at once and then confirmed sequentially. + /// After this call every account has nonce 1, so `build_public_txs` must be called after it. + pub async fn claim_vault_funds( + &self, + sequencer_client: &sequencer_service_rpc::SequencerClient, + ) -> Result<()> { + let vault_program_id = Program::vault().id(); + + let mut tx_hashes = Vec::with_capacity(self.public_keypairs.len()); + for (private_key, account_id) in &self.public_keypairs { + let owner_vault_id = + vault_core::compute_vault_account_id(vault_program_id, *account_id); + let message = putx::Message::try_new( + vault_program_id, + vec![*account_id, owner_vault_id], + vec![Nonce(0_u128)], + vault_core::Instruction::Claim { amount: 10 }, + ) + .context("Failed to build vault claim message")?; + let witness_set = + nssa::public_transaction::WitnessSet::for_message(&message, &[private_key]); + let tx = PublicTransaction::new(message, witness_set); + let hash = sequencer_client + .send_transaction(NSSATransaction::Public(tx)) + .await + .context("Failed to submit vault claim")?; + tx_hashes.push(hash); + } + + let deadline = Instant::now() + Duration::from_secs(300); + for (i, tx_hash) in tx_hashes.iter().enumerate() { + loop { + anyhow::ensure!( + Instant::now() < deadline, + "Vault claims timed out after 5 minutes ({i}/{} confirmed)", + tx_hashes.len() + ); + let found = sequencer_client + .get_transaction(*tx_hash) + .await + .ok() + .flatten() + .is_some(); + if found { + break; + } + } + } + Ok(()) + } + /// Build a batch of public transactions to submit to the node. + /// + /// Must be called after `claim_vault_funds`, which sets each account's nonce to 1. pub fn build_public_txs(&self) -> Vec { // Create valid public transactions let program = Program::authenticated_transfer_program(); @@ -80,8 +135,8 @@ impl TpsTestManager { let message = putx::Message::try_new( program.id(), [pair[0].1, pair[1].1].to_vec(), - [Nonce(0_u128)].to_vec(), - amount, + [Nonce(1_u128)].to_vec(), + authenticated_transfer_core::Instruction::Transfer { amount }, ) .unwrap(); let witness_set = @@ -96,28 +151,14 @@ impl TpsTestManager { /// Generates a sequencer configuration with initial balance in a number of public accounts. /// The transactions generated with the function `build_public_txs` will be valid in a node /// started with the config from this method. - fn generate_initial_data(&self) -> InitialData { - // Create public public keypairs - let public_accounts = self - .public_keypairs + fn generate_genesis(&self) -> Vec { + self.public_keypairs .iter() - .map(|(key, _)| (key.clone(), 10)) - .collect(); - - // Generate an initial commitment to be used with the privacy preserving transaction - // created with the `build_privacy_transaction` function. - let key_chain = KeyChain::new_os_random(); - let account = Account { - balance: 100, - nonce: Nonce(0xdead_beef), - program_owner: Program::authenticated_transfer_program().id(), - data: Data::default(), - }; - - InitialData { - public_accounts, - private_accounts: vec![(key_chain, account)], - } + .map(|(_, account_id)| GenesisAction::SupplyAccount { + account_id: *account_id, + balance: 10, + }) + .collect() } const fn generate_sequencer_partial_config() -> SequencerPartialConfig { @@ -139,10 +180,16 @@ pub async fn tps_test() -> Result<()> { let tps_test = TpsTestManager::new(target_tps, num_transactions); let ctx = TestContext::builder() .with_sequencer_partial_config(TpsTestManager::generate_sequencer_partial_config()) - .with_initial_data(tps_test.generate_initial_data()) + .with_genesis(tps_test.generate_genesis()) .build() .await?; + // Genesis funds vault PDAs, not accounts directly. Claim into accounts before measuring. + tps_test + .claim_vault_funds(ctx.sequencer_client()) + .await + .context("Failed to claim vault funds for TPS accounts")?; + let target_time = tps_test.target_time(); info!( "TPS test begin. Target time is {target_time:?} for {num_transactions} transactions ({target_tps} TPS)" @@ -166,7 +213,7 @@ pub async fn tps_test() -> Result<()> { loop { assert!( now.elapsed().as_millis() <= target_time.as_millis(), - "TPS test failed by timeout" + "TPS test failed by timeout, transactions processed {i}/{num_transactions}" ); let tx_obj = ctx @@ -220,14 +267,17 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction { data: Data::default(), }, true, - AccountId::from(&sender_npk), + AccountId::for_regular_private_account(&sender_npk, 0), ); let recipient_nsk = [2; 32]; let recipient_vsk = [99; 32]; let recipient_vpk = ViewingPublicKey::from_scalar(recipient_vsk); let recipient_npk = NullifierPublicKey::from(&recipient_nsk); - let recipient_pre = - AccountWithMetadata::new(Account::default(), false, AccountId::from(&recipient_npk)); + let recipient_pre = AccountWithMetadata::new( + Account::default(), + false, + AccountId::for_regular_private_account(&recipient_npk, 0), + ); let eph_holder_from = EphemeralKeyHolder::new(&sender_npk); let sender_ss = eph_holder_from.calculate_shared_secret_sender(&sender_vpk); @@ -247,14 +297,23 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction { ); let (output, proof) = circuit::execute_and_prove( vec![sender_pre, recipient_pre], - Program::serialize_instruction(balance_to_move).unwrap(), - vec![1, 2], + Program::serialize_instruction(authenticated_transfer_core::Instruction::Transfer { + amount: balance_to_move, + }) + .unwrap(), vec![ - (sender_npk.clone(), sender_ss), - (recipient_npk.clone(), recipient_ss), + InputAccountIdentity::PrivateAuthorizedUpdate { + ssk: sender_ss, + nsk: sender_nsk, + membership_proof: proof, + identifier: 0, + }, + InputAccountIdentity::PrivateUnauthorized { + npk: recipient_npk, + ssk: recipient_ss, + identifier: 0, + }, ], - vec![sender_nsk], - vec![Some(proof)], &program.into(), ) .unwrap(); diff --git a/integration_tests/tests/wallet_ffi.rs b/integration_tests/tests/wallet_ffi.rs index 6e6b190c..2677e10e 100644 --- a/integration_tests/tests/wallet_ffi.rs +++ b/integration_tests/tests/wallet_ffi.rs @@ -24,10 +24,10 @@ use log::info; use nssa::{Account, AccountId, PrivateKey, PublicKey, program::Program}; use nssa_core::program::DEFAULT_PROGRAM_ID; use tempfile::tempdir; -use wallet::WalletCore; +use wallet::account::HumanReadableAccount; use wallet_ffi::{ FfiAccount, FfiAccountList, FfiBytes32, FfiPrivateAccountKeys, FfiPublicAccountKey, - FfiTransferResult, WalletHandle, error, + FfiTransferResult, FfiU128, WalletHandle, error, }; unsafe extern "C" { @@ -54,6 +54,24 @@ unsafe extern "C" { out_account_id: *mut FfiBytes32, ) -> error::WalletFfiError; + fn wallet_ffi_import_public_account( + handle: *mut WalletHandle, + private_key_hex: *const c_char, + ) -> error::WalletFfiError; + + fn wallet_ffi_create_private_accounts_key( + handle: *mut WalletHandle, + out_keys: *mut FfiPrivateAccountKeys, + ) -> error::WalletFfiError; + + fn wallet_ffi_import_private_account( + handle: *mut WalletHandle, + key_chain_json: *const c_char, + chain_index: *const c_char, + identifier: *const FfiU128, + account_state_json: *const c_char, + ) -> error::WalletFfiError; + fn wallet_ffi_list_accounts( handle: *mut WalletHandle, out_list: *mut FfiAccountList, @@ -117,6 +135,7 @@ unsafe extern "C" { handle: *mut WalletHandle, from: *const FfiBytes32, to_keys: *const FfiPrivateAccountKeys, + to_identifier: *const FfiU128, amount: *const [u8; 16], out_result: *mut FfiTransferResult, ) -> error::WalletFfiError; @@ -133,6 +152,7 @@ unsafe extern "C" { handle: *mut WalletHandle, from: *const FfiBytes32, to_keys: *const FfiPrivateAccountKeys, + to_identifier: *const FfiU128, amount: *const [u8; 16], out_result: *mut FfiTransferResult, ) -> error::WalletFfiError; @@ -185,13 +205,59 @@ fn new_wallet_ffi_with_test_context_config( let storage_path = CString::new(storage_path.to_str().unwrap())?; let password = CString::new(ctx.ctx().wallet_password())?; - Ok(unsafe { + let wallet_ffi_handle = unsafe { wallet_ffi_create_new( config_path.as_ptr(), storage_path.as_ptr(), password.as_ptr(), ) - }) + }; + + // Import accounts from source wallet + let source_wallet = ctx.ctx().wallet(); + let source_key_chain = source_wallet.storage().key_chain(); + + for (account_id, _chain_index) in source_key_chain.public_account_ids() { + let private_key_hex = source_wallet + .get_account_public_signing_key(account_id) + .unwrap() + .to_string(); + let private_key_hex = CString::new(private_key_hex)?; + unsafe { wallet_ffi_import_public_account(wallet_ffi_handle, private_key_hex.as_ptr()) } + .unwrap(); + } + + for (account_id, _chain_index) in source_key_chain.private_account_ids() { + let account = source_key_chain.private_account(account_id).unwrap(); + let key_chain_json = CString::new(serde_json::to_string(account.key_chain)?)?; + let account_state_json = CString::new(serde_json::to_string( + &HumanReadableAccount::from(account.account.clone()), + )?)?; + + let chain_index = account + .chain_index + .map(|chain_index| CString::new(chain_index.to_string())) + .transpose()?; + let chain_index_ptr = chain_index + .as_ref() + .map_or(std::ptr::null(), |value| value.as_ptr()); + let identifier = FfiU128 { + data: account.kind.identifier().to_le_bytes(), + }; + + unsafe { + wallet_ffi_import_private_account( + wallet_ffi_handle, + key_chain_json.as_ptr(), + chain_index_ptr, + &raw const identifier, + account_state_json.as_ptr(), + ) + } + .unwrap(); + } + + Ok(wallet_ffi_handle) } fn new_wallet_ffi_with_default_config(password: &str) -> Result<*mut WalletHandle> { @@ -211,14 +277,6 @@ fn new_wallet_ffi_with_default_config(password: &str) -> Result<*mut WalletHandl }) } -fn new_wallet_rust_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"); - - WalletCore::new_init_storage(config_path, storage_path, None, password.to_owned()) -} - 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"); @@ -232,33 +290,35 @@ fn load_existing_ffi_wallet(home: &Path) -> Result<*mut WalletHandle> { fn wallet_ffi_create_public_accounts() -> Result<()> { let password = "password_for_tests"; let n_accounts = 10; - // First `n_accounts` public accounts created with Rust wallet - let new_public_account_ids_rust = { - let mut account_ids = Vec::new(); - let mut wallet_rust = new_wallet_rust_with_default_config(password)?; - for _ in 0..n_accounts { - let account_id = wallet_rust.create_new_account_public(None).0; - account_ids.push(*account_id.value()); - } - account_ids - }; - - // First `n_accounts` public accounts created with wallet FFI + // Create `n_accounts` public accounts with wallet FFI let new_public_account_ids_ffi = unsafe { let mut account_ids = Vec::new(); let wallet_ffi_handle = new_wallet_ffi_with_default_config(password)?; 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); + wallet_ffi_create_account_public(wallet_ffi_handle, &raw mut out_account_id).unwrap(); account_ids.push(out_account_id.data); } wallet_ffi_destroy(wallet_ffi_handle); account_ids }; - assert_eq!(new_public_account_ids_ffi, new_public_account_ids_rust); + // All returned IDs must be unique and non-zero + assert_eq!(new_public_account_ids_ffi.len(), n_accounts); + let unique: HashSet<_> = new_public_account_ids_ffi.iter().collect(); + assert_eq!( + unique.len(), + n_accounts, + "Duplicate public account IDs returned" + ); + assert!( + new_public_account_ids_ffi + .iter() + .all(|id| *id != [0_u8; 32]), + "Zero account ID returned" + ); Ok(()) } @@ -267,79 +327,65 @@ fn wallet_ffi_create_public_accounts() -> Result<()> { fn wallet_ffi_create_private_accounts() -> Result<()> { let password = "password_for_tests"; let n_accounts = 10; - // First `n_accounts` private accounts created with Rust wallet - let new_private_account_ids_rust = { - let mut account_ids = Vec::new(); - - let mut wallet_rust = new_wallet_rust_with_default_config(password)?; - for _ in 0..n_accounts { - let account_id = wallet_rust.create_new_account_private(None).0; - account_ids.push(*account_id.value()); - } - account_ids - }; - - // First `n_accounts` private accounts created with wallet FFI - let new_private_account_ids_ffi = unsafe { - let mut account_ids = Vec::new(); + // Create `n_accounts` receiving keys with wallet FFI + let new_npks_ffi = unsafe { + let mut npks = Vec::new(); let wallet_ffi_handle = new_wallet_ffi_with_default_config(password)?; for _ in 0..n_accounts { - let mut out_account_id = FfiBytes32::from_bytes([0; 32]); - wallet_ffi_create_account_private(wallet_ffi_handle, &raw mut out_account_id); - account_ids.push(out_account_id.data); + let mut out_keys = FfiPrivateAccountKeys::default(); + wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys).unwrap(); + npks.push(out_keys.nullifier_public_key.data); + wallet_ffi_free_private_account_keys(&raw mut out_keys); } wallet_ffi_destroy(wallet_ffi_handle); - account_ids + npks }; - assert_eq!(new_private_account_ids_ffi, new_private_account_ids_rust); + // All returned NPKs must be unique and non-zero + assert_eq!(new_npks_ffi.len(), n_accounts); + let unique: HashSet<_> = new_npks_ffi.iter().collect(); + assert_eq!(unique.len(), n_accounts, "Duplicate NPKs returned"); + assert!( + new_npks_ffi.iter().all(|id| *id != [0_u8; 32]), + "Zero NPK returned" + ); Ok(()) } + #[test] fn wallet_ffi_save_and_load_persistent_storage() -> Result<()> { let ctx = BlockingTestContext::new()?; - let mut out_private_account_id = FfiBytes32::from_bytes([0; 32]); let home = tempfile::tempdir()?; - - // Create a private account with the wallet FFI and save it - unsafe { + // Create a receiving key and save + let first_npk = unsafe { let wallet_ffi_handle = new_wallet_ffi_with_test_context_config(&ctx, home.path())?; - wallet_ffi_create_account_private(wallet_ffi_handle, &raw mut out_private_account_id); - - wallet_ffi_save(wallet_ffi_handle); - wallet_ffi_destroy(wallet_ffi_handle); - } - - let private_account_keys = unsafe { - let wallet_ffi_handle = load_existing_ffi_wallet(home.path())?; - - let mut private_account = FfiAccount::default(); - - let result = wallet_ffi_get_account_private( - wallet_ffi_handle, - &raw const out_private_account_id, - &raw mut private_account, - ); - assert_eq!(result, error::WalletFfiError::Success); - let mut out_keys = FfiPrivateAccountKeys::default(); - let result = wallet_ffi_get_private_account_keys( - wallet_ffi_handle, - &raw const out_private_account_id, - &raw mut out_keys, - ); - assert_eq!(result, error::WalletFfiError::Success); - + wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys).unwrap(); + let npk = out_keys.nullifier_public_key.data; + wallet_ffi_free_private_account_keys(&raw mut out_keys); + wallet_ffi_save(wallet_ffi_handle).unwrap(); wallet_ffi_destroy(wallet_ffi_handle); - - out_keys + npk }; - assert_eq!( - nssa::AccountId::from(&private_account_keys.npk()), - out_private_account_id.into() + // After loading, creating a new key should yield a different NPK (state was persisted) + let second_npk = unsafe { + let wallet_ffi_handle = load_existing_ffi_wallet(home.path())?; + let mut out_keys = FfiPrivateAccountKeys::default(); + wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys).unwrap(); + let npk = out_keys.nullifier_public_key.data; + wallet_ffi_free_private_account_keys(&raw mut out_keys); + wallet_ffi_destroy(wallet_ffi_handle); + npk + }; + + assert_ne!(first_npk, [0_u8; 32], "First NPK should be non-zero"); + assert_ne!(second_npk, [0_u8; 32], "Second NPK should be non-zero"); + assert_ne!( + first_npk, second_npk, + "Keys should differ after state was persisted" ); Ok(()) @@ -349,46 +395,32 @@ fn wallet_ffi_save_and_load_persistent_storage() -> Result<()> { fn test_wallet_ffi_list_accounts() -> Result<()> { let password = "password_for_tests"; - // Create the wallet FFI - let wallet_ffi_handle = unsafe { + // Create the wallet FFI and track which account IDs were created as public/private + let (wallet_ffi_handle, created_public_ids) = unsafe { let handle = new_wallet_ffi_with_default_config(password)?; - // Create 5 public accounts and 5 private accounts + let mut public_ids: Vec<[u8; 32]> = Vec::new(); + + // Create 5 public accounts and 5 receiving keys for _ in 0..5 { let mut out_account_id = FfiBytes32::from_bytes([0; 32]); - wallet_ffi_create_account_public(handle, &raw mut out_account_id); - wallet_ffi_create_account_private(handle, &raw mut out_account_id); + wallet_ffi_create_account_public(handle, &raw mut out_account_id).unwrap(); + public_ids.push(out_account_id.data); + + let mut out_keys = FfiPrivateAccountKeys::default(); + wallet_ffi_create_private_accounts_key(handle, &raw mut out_keys).unwrap(); + wallet_ffi_free_private_account_keys(&raw mut out_keys); } - handle - }; - - // Create the wallet Rust - let wallet_rust = { - let mut wallet = new_wallet_rust_with_default_config(password)?; - // Create 5 public accounts and 5 private accounts - for _ in 0..5 { - wallet.create_new_account_public(None); - wallet.create_new_account_private(None); - } - wallet + (handle, public_ids) }; // Get the account list with FFI method let mut wallet_ffi_account_list = unsafe { let mut out_list = FfiAccountList::default(); - wallet_ffi_list_accounts(wallet_ffi_handle, &raw mut out_list); + wallet_ffi_list_accounts(wallet_ffi_handle, &raw mut out_list).unwrap(); out_list }; - let wallet_rust_account_ids = wallet_rust - .storage() - .user_data - .account_ids() - .collect::>(); - - // Assert same number of elements between Rust and FFI result - assert_eq!(wallet_rust_account_ids.len(), wallet_ffi_account_list.count); - let wallet_ffi_account_list_slice = unsafe { core::slice::from_raw_parts( wallet_ffi_account_list.entries, @@ -396,37 +428,26 @@ fn test_wallet_ffi_list_accounts() -> Result<()> { ) }; - // Assert same account ids between Rust and FFI result - assert_eq!( - wallet_rust_account_ids - .iter() - .map(nssa::AccountId::value) - .collect::>(), - wallet_ffi_account_list_slice - .iter() - .map(|entry| &entry.account_id.data) - .collect::>() - ); - - // Assert `is_pub` flag is correct in the FFI result - for entry in wallet_ffi_account_list_slice { - let account_id = AccountId::new(entry.account_id.data); - let is_pub_default_in_rust_wallet = wallet_rust - .storage() - .user_data - .default_pub_account_signing_keys - .contains_key(&account_id); - let is_pub_key_tree_wallet_rust = wallet_rust - .storage() - .user_data - .public_key_tree - .account_id_map - .contains_key(&account_id); - - let is_public_in_rust_wallet = is_pub_default_in_rust_wallet || is_pub_key_tree_wallet_rust; - - assert_eq!(entry.is_public, is_public_in_rust_wallet); + // All created accounts must appear in the list + let listed_public_ids: HashSet<[u8; 32]> = wallet_ffi_account_list_slice + .iter() + .filter(|e| e.is_public) + .map(|e| e.account_id.data) + .collect(); + for id in &created_public_ids { + assert!( + listed_public_ids.contains(id), + "Created public account not found in list with is_public=true" + ); } + // Total listed accounts must be at least the number of public accounts created + // (receiving keys without synced accounts don't appear in the list) + assert!( + wallet_ffi_account_list.count >= created_public_ids.len(), + "Listed account count ({}) is less than the number of created public accounts ({})", + wallet_ffi_account_list.count, + created_public_ids.len() + ); unsafe { wallet_ffi_free_account_list(&raw mut wallet_ffi_account_list); @@ -445,7 +466,7 @@ fn test_wallet_ffi_get_balance_public() -> Result<()> { let balance = unsafe { let mut out_balance: [u8; 16] = [0; 16]; - let ffi_account_id = FfiBytes32::from(&account_id); + let ffi_account_id = FfiBytes32::from(account_id); wallet_ffi_get_balance( wallet_ffi_handle, &raw const ffi_account_id, @@ -475,7 +496,7 @@ fn test_wallet_ffi_get_account_public() -> Result<()> { let mut out_account = FfiAccount::default(); let account: Account = unsafe { - let ffi_account_id = FfiBytes32::from(&account_id); + let ffi_account_id = FfiBytes32::from(account_id); wallet_ffi_get_account_public( wallet_ffi_handle, &raw const ffi_account_id, @@ -491,7 +512,7 @@ fn test_wallet_ffi_get_account_public() -> Result<()> { ); assert_eq!(account.balance, 10000); assert!(account.data.is_empty()); - assert_eq!(account.nonce.0, 0); + assert_eq!(account.nonce.0, 1); unsafe { wallet_ffi_free_account_data(&raw mut out_account); @@ -512,7 +533,7 @@ fn test_wallet_ffi_get_account_private() -> Result<()> { let mut out_account = FfiAccount::default(); let account: Account = unsafe { - let ffi_account_id = FfiBytes32::from(&account_id); + let ffi_account_id = FfiBytes32::from(account_id); wallet_ffi_get_account_private( wallet_ffi_handle, &raw const ffi_account_id, @@ -528,7 +549,6 @@ fn test_wallet_ffi_get_account_private() -> Result<()> { ); assert_eq!(account.balance, 10000); assert!(account.data.is_empty()); - assert_eq!(account.nonce, 0_u128.into()); unsafe { wallet_ffi_free_account_data(&raw mut out_account); @@ -549,7 +569,7 @@ fn test_wallet_ffi_get_public_account_keys() -> Result<()> { let mut out_key = FfiPublicAccountKey::default(); let key: PublicKey = unsafe { - let ffi_account_id = FfiBytes32::from(&account_id); + let ffi_account_id = FfiBytes32::from(account_id); wallet_ffi_get_public_account_key( wallet_ffi_handle, &raw const ffi_account_id, @@ -588,7 +608,7 @@ fn test_wallet_ffi_get_private_account_keys() -> Result<()> { let mut keys = FfiPrivateAccountKeys::default(); unsafe { - let ffi_account_id = FfiBytes32::from(&account_id); + let ffi_account_id = FfiBytes32::from(account_id); wallet_ffi_get_private_account_keys( wallet_ffi_handle, &raw const ffi_account_id, @@ -597,15 +617,15 @@ fn test_wallet_ffi_get_private_account_keys() -> Result<()> { .unwrap(); }; - let key_chain = &ctx + let account = &ctx .ctx() .wallet() .storage() - .user_data - .get_private_account(account_id) - .unwrap() - .0; + .key_chain() + .private_account(account_id) + .unwrap(); + let key_chain = account.key_chain; let expected_npk = &key_chain.nullifier_public_key; let expected_vpk = &key_chain.viewing_public_key; @@ -627,7 +647,7 @@ fn test_wallet_ffi_account_id_to_base58() -> Result<()> { let private_key = PrivateKey::new_os_random(); let public_key = PublicKey::new_from_private_key(&private_key); let account_id = AccountId::from(&public_key); - let ffi_bytes: FfiBytes32 = (&account_id).into(); + let ffi_bytes: FfiBytes32 = account_id.into(); let ptr = unsafe { wallet_ffi_account_id_to_base58(&raw const ffi_bytes) }; let ffi_result = unsafe { CStr::from_ptr(ptr).to_str()? }; @@ -650,7 +670,8 @@ fn wallet_ffi_base58_to_account_id() -> Result<()> { let account_id_c_str = CString::new(account_id_str.clone())?; let account_id: AccountId = unsafe { let mut out_account_id_bytes = FfiBytes32::default(); - wallet_ffi_account_id_from_base58(account_id_c_str.as_ptr(), &raw mut out_account_id_bytes); + wallet_ffi_account_id_from_base58(account_id_c_str.as_ptr(), &raw mut out_account_id_bytes) + .unwrap(); out_account_id_bytes.into() }; @@ -670,7 +691,7 @@ fn wallet_ffi_init_public_account_auth_transfer() -> Result<()> { // Create a new uninitialized public account let mut out_account_id = FfiBytes32::from_bytes([0; 32]); unsafe { - wallet_ffi_create_account_public(wallet_ffi_handle, &raw mut out_account_id); + wallet_ffi_create_account_public(wallet_ffi_handle, &raw mut out_account_id).unwrap(); } // Check its program owner is the default program id @@ -693,7 +714,8 @@ fn wallet_ffi_init_public_account_auth_transfer() -> Result<()> { wallet_ffi_handle, &raw const out_account_id, &raw mut transfer_result, - ); + ) + .unwrap(); } info!("Waiting for next block creation"); @@ -729,32 +751,21 @@ fn wallet_ffi_init_private_account_auth_transfer() -> Result<()> { let home = tempfile::tempdir()?; let wallet_ffi_handle = new_wallet_ffi_with_test_context_config(&ctx, home.path())?; - // Create a new uninitialized public account - let mut out_account_id = FfiBytes32::from_bytes([0; 32]); + // Create a new private account + let mut out_account_id = FfiBytes32::default(); unsafe { - wallet_ffi_create_account_private(wallet_ffi_handle, &raw mut out_account_id); + wallet_ffi_create_account_private(wallet_ffi_handle, &raw mut out_account_id).unwrap(); } - // Check its program owner is the default program id - let account: Account = unsafe { - let mut out_account = FfiAccount::default(); - wallet_ffi_get_account_private( - wallet_ffi_handle, - &raw const out_account_id, - &raw mut out_account, - ); - (&out_account).try_into().unwrap() - }; - assert_eq!(account.program_owner, DEFAULT_PROGRAM_ID); - - // Call the init funciton + // Call the init function let mut transfer_result = FfiTransferResult::default(); unsafe { wallet_ffi_register_private_account( wallet_ffi_handle, &raw const out_account_id, &raw mut transfer_result, - ); + ) + .unwrap(); } info!("Waiting for next block creation"); @@ -763,8 +774,8 @@ fn wallet_ffi_init_private_account_auth_transfer() -> Result<()> { // Sync private account local storage with onchain encrypted state unsafe { let mut current_height = 0; - wallet_ffi_get_current_block_height(wallet_ffi_handle, &raw mut current_height); - wallet_ffi_sync_to_block(wallet_ffi_handle, current_height); + wallet_ffi_get_current_block_height(wallet_ffi_handle, &raw mut current_height).unwrap(); + wallet_ffi_sync_to_block(wallet_ffi_handle, current_height).unwrap(); }; // Check that the program owner is now the authenticated transfer program @@ -796,8 +807,8 @@ fn test_wallet_ffi_transfer_public() -> Result<()> { let ctx = BlockingTestContext::new()?; let home = tempfile::tempdir()?; let wallet_ffi_handle = new_wallet_ffi_with_test_context_config(&ctx, home.path())?; - let from: FfiBytes32 = (&ctx.ctx().existing_public_accounts()[0]).into(); - let to: FfiBytes32 = (&ctx.ctx().existing_public_accounts()[1]).into(); + let from: FfiBytes32 = ctx.ctx().existing_public_accounts()[0].into(); + let to: FfiBytes32 = ctx.ctx().existing_public_accounts()[1].into(); let amount: [u8; 16] = 100_u128.to_le_bytes(); let mut transfer_result = FfiTransferResult::default(); @@ -808,7 +819,8 @@ fn test_wallet_ffi_transfer_public() -> Result<()> { &raw const to, &raw const amount, &raw mut transfer_result, - ); + ) + .unwrap(); } info!("Waiting for next block creation"); @@ -849,29 +861,30 @@ fn test_wallet_ffi_transfer_shielded() -> Result<()> { let ctx = BlockingTestContext::new()?; let home = tempfile::tempdir()?; let wallet_ffi_handle = new_wallet_ffi_with_test_context_config(&ctx, home.path())?; - let from: FfiBytes32 = (&ctx.ctx().existing_public_accounts()[0]).into(); + let from: FfiBytes32 = ctx.ctx().existing_public_accounts()[0].into(); let (to, to_keys) = unsafe { - let mut out_account_id = FfiBytes32::default(); let mut out_keys = FfiPrivateAccountKeys::default(); - wallet_ffi_create_account_private(wallet_ffi_handle, &raw mut out_account_id); - wallet_ffi_get_private_account_keys( - wallet_ffi_handle, - &raw const out_account_id, - &raw mut out_keys, - ); - (out_account_id, out_keys) + wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys).unwrap(); + let account_id = nssa::AccountId::for_regular_private_account(&out_keys.npk(), 0_u128); + let to: FfiBytes32 = account_id.into(); + (to, out_keys) }; let amount: [u8; 16] = 100_u128.to_le_bytes(); let mut transfer_result = FfiTransferResult::default(); unsafe { + let to_identifier = FfiU128 { + data: 0_u128.to_le_bytes(), + }; wallet_ffi_transfer_shielded( wallet_ffi_handle, &raw const from, &raw const to_keys, + &raw const to_identifier, &raw const amount, &raw mut transfer_result, - ); + ) + .unwrap(); } info!("Waiting for next block creation"); @@ -880,8 +893,8 @@ fn test_wallet_ffi_transfer_shielded() -> Result<()> { // Sync private account local storage with onchain encrypted state unsafe { let mut current_height = 0; - wallet_ffi_get_current_block_height(wallet_ffi_handle, &raw mut current_height); - wallet_ffi_sync_to_block(wallet_ffi_handle, current_height); + wallet_ffi_get_current_block_height(wallet_ffi_handle, &raw mut current_height).unwrap(); + wallet_ffi_sync_to_block(wallet_ffi_handle, current_height).unwrap(); }; let from_balance = unsafe { @@ -923,8 +936,8 @@ fn test_wallet_ffi_transfer_deshielded() -> Result<()> { let ctx = BlockingTestContext::new()?; let home = tempfile::tempdir()?; let wallet_ffi_handle = new_wallet_ffi_with_test_context_config(&ctx, home.path())?; - let from: FfiBytes32 = (&ctx.ctx().existing_private_accounts()[0]).into(); - let to: FfiBytes32 = (&ctx.ctx().existing_public_accounts()[0]).into(); + let from: FfiBytes32 = ctx.ctx().existing_private_accounts()[0].into(); + let to: FfiBytes32 = ctx.ctx().existing_public_accounts()[0].into(); let amount: [u8; 16] = 100_u128.to_le_bytes(); let mut transfer_result = FfiTransferResult::default(); @@ -935,8 +948,9 @@ fn test_wallet_ffi_transfer_deshielded() -> Result<()> { &raw const to, &raw const amount, &raw mut transfer_result, - ); + ) } + .unwrap(); info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); @@ -944,9 +958,9 @@ fn test_wallet_ffi_transfer_deshielded() -> Result<()> { // Sync private account local storage with onchain encrypted state unsafe { let mut current_height = 0; - wallet_ffi_get_current_block_height(wallet_ffi_handle, &raw mut current_height); - wallet_ffi_sync_to_block(wallet_ffi_handle, current_height); - }; + wallet_ffi_get_current_block_height(wallet_ffi_handle, &raw mut current_height).unwrap(); + wallet_ffi_sync_to_block(wallet_ffi_handle, current_height).unwrap(); + } let from_balance = unsafe { let mut out_balance: [u8; 16] = [0; 16]; @@ -983,30 +997,31 @@ fn test_wallet_ffi_transfer_private() -> Result<()> { let home = tempfile::tempdir()?; let wallet_ffi_handle = new_wallet_ffi_with_test_context_config(&ctx, home.path())?; - let from: FfiBytes32 = (&ctx.ctx().existing_private_accounts()[0]).into(); + let from: FfiBytes32 = ctx.ctx().existing_private_accounts()[0].into(); let (to, to_keys) = unsafe { - let mut out_account_id = FfiBytes32::default(); let mut out_keys = FfiPrivateAccountKeys::default(); - wallet_ffi_create_account_private(wallet_ffi_handle, &raw mut out_account_id); - wallet_ffi_get_private_account_keys( - wallet_ffi_handle, - &raw const out_account_id, - &raw mut out_keys, - ); - (out_account_id, out_keys) + wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys).unwrap(); + let account_id = nssa::AccountId::for_regular_private_account(&out_keys.npk(), 0_u128); + let to: FfiBytes32 = account_id.into(); + (to, out_keys) }; let amount: [u8; 16] = 100_u128.to_le_bytes(); let mut transfer_result = FfiTransferResult::default(); unsafe { + let to_identifier = FfiU128 { + data: 0_u128.to_le_bytes(), + }; wallet_ffi_transfer_private( wallet_ffi_handle, &raw const from, &raw const to_keys, + &raw const to_identifier, &raw const amount, &raw mut transfer_result, - ); + ) + .unwrap(); } info!("Waiting for next block creation"); @@ -1015,8 +1030,8 @@ fn test_wallet_ffi_transfer_private() -> Result<()> { // Sync private account local storage with onchain encrypted state unsafe { let mut current_height = 0; - wallet_ffi_get_current_block_height(wallet_ffi_handle, &raw mut current_height); - wallet_ffi_sync_to_block(wallet_ffi_handle, current_height); + wallet_ffi_get_current_block_height(wallet_ffi_handle, &raw mut current_height).unwrap(); + wallet_ffi_sync_to_block(wallet_ffi_handle, current_height).unwrap(); }; let from_balance = unsafe { diff --git a/key_protocol/Cargo.toml b/key_protocol/Cargo.toml index 022f3ccd..a0b5c397 100644 --- a/key_protocol/Cargo.toml +++ b/key_protocol/Cargo.toml @@ -7,6 +7,10 @@ license = { workspace = true } [lints] workspace = true +[features] +default = [] +test_utils = [] + [dependencies] nssa.workspace = true nssa_core.workspace = true @@ -26,3 +30,4 @@ itertools.workspace = true [dev-dependencies] base58.workspace = true +bincode.workspace = true diff --git a/key_protocol/src/key_management/ephemeral_key_holder.rs b/key_protocol/src/key_management/ephemeral_key_holder.rs index 6ef9e305..7a6dc7d0 100644 --- a/key_protocol/src/key_management/ephemeral_key_holder.rs +++ b/key_protocol/src/key_management/ephemeral_key_holder.rs @@ -36,7 +36,7 @@ impl EphemeralKeyHolder { &self, receiver_viewing_public_key: &ViewingPublicKey, ) -> SharedSecretKey { - SharedSecretKey::new(&self.ephemeral_secret_key, receiver_viewing_public_key) + SharedSecretKey::new(self.ephemeral_secret_key, receiver_viewing_public_key) } } @@ -47,7 +47,7 @@ pub fn produce_one_sided_shared_secret_receiver( let mut esk = [0; 32]; OsRng.fill_bytes(&mut esk); ( - SharedSecretKey::new(&esk, vpk), + SharedSecretKey::new(esk, vpk), EphemeralPublicKey::from_scalar(esk), ) } diff --git a/key_protocol/src/key_management/group_key_holder.rs b/key_protocol/src/key_management/group_key_holder.rs new file mode 100644 index 00000000..39a3fd19 --- /dev/null +++ b/key_protocol/src/key_management/group_key_holder.rs @@ -0,0 +1,601 @@ +use aes_gcm::{Aes256Gcm, KeyInit as _, aead::Aead as _}; +use nssa_core::{ + SharedSecretKey, + encryption::{Scalar, shared_key_derivation::Secp256k1Point}, + program::{PdaSeed, ProgramId}, +}; +use rand::{RngCore as _, rngs::OsRng}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, digest::FixedOutput as _}; + +use super::secret_holders::{PrivateKeyHolder, SecretSpendingKey}; + +/// Public key used to seal a `GroupKeyHolder` for distribution to a recipient. +/// +/// Wraps a secp256k1 point but is a distinct type from `ViewingPublicKey` to enforce +/// key separation: viewing keys encrypt account state, sealing keys encrypt the GMS +/// for off-chain distribution. +pub struct SealingPublicKey(Secp256k1Point); + +impl SealingPublicKey { + /// Derive the sealing public key from a secret scalar. + #[must_use] + pub fn from_scalar(scalar: Scalar) -> Self { + Self(Secp256k1Point::from_scalar(scalar)) + } + + /// Construct from raw serialized bytes (e.g. received from another wallet). + #[must_use] + pub const fn from_bytes(bytes: Vec) -> Self { + Self(Secp256k1Point(bytes)) + } + + /// Returns the raw bytes for display or transmission. + #[must_use] + pub fn to_bytes(&self) -> &[u8] { + &self.0.0 + } +} + +/// Secret key used to unseal a `GroupKeyHolder` received from another member. +pub type SealingSecretKey = Scalar; + +/// Manages shared viewing keys for a group of controllers owning private PDAs. +/// +/// The Group Master Secret (GMS) is a 32-byte random value shared among controllers. +/// Each private PDA owned by the group gets a unique [`SecretSpendingKey`] derived from +/// the GMS by mixing the PDA seed into the SHA-256 input (see `secret_spending_key_for_pda`). +/// +/// # Distribution +/// +/// The GMS is a long-term secret and must never cross a trust boundary in raw form. +/// Controllers share it off-chain by sealing it under each recipient's [`SealingPublicKey`] +/// (see `seal_for` / `unseal`). Wallets persisting a `GroupKeyHolder` must encrypt it at +/// rest; the raw bytes are exposed only via [`GroupKeyHolder::dangerous_raw_gms`], which +/// is intended for the sealing path exclusively. +/// +/// # Logging safety +/// +/// `Debug` is implemented manually to redact the GMS; formatting this value with `{:?}` +/// will not leak the secret. Code that formats through `{:#?}` on containing types is +/// safe for the same reason. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GroupKeyHolder { + gms: [u8; 32], +} + +impl std::fmt::Debug for GroupKeyHolder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GroupKeyHolder") + .field("gms", &"") + .finish() + } +} + +impl Default for GroupKeyHolder { + fn default() -> Self { + Self::new() + } +} + +impl GroupKeyHolder { + /// Create a new group with a fresh random GMS. + #[must_use] + pub fn new() -> Self { + let mut gms = [0_u8; 32]; + OsRng.fill_bytes(&mut gms); + Self { gms } + } + + /// Restore from an existing GMS (received via `unseal`). + #[must_use] + pub const fn from_gms(gms: [u8; 32]) -> Self { + Self { gms } + } + + /// Returns the raw 32-byte GMS. The name reflects intent: only the sealed-distribution + /// path (`seal_for`) and sealed-at-rest persistence should ever need the raw bytes. Do + /// not log the result, do not pass it across an untrusted channel. + #[must_use] + pub const fn dangerous_raw_gms(&self) -> &[u8; 32] { + &self.gms + } + + /// Derive a per-PDA [`SecretSpendingKey`] by mixing the seed into the SHA-256 input. + /// + /// Each distinct `(program_id, pda_seed)` pair produces a distinct SSK in the full 256-bit + /// space, so adversarial seed-grinding cannot collide two PDAs' derived keys under the same + /// group. Uses the codebase's 32-byte protocol-versioned domain-separation convention. + fn secret_spending_key_for_pda( + &self, + program_id: &ProgramId, + pda_seed: &PdaSeed, + ) -> SecretSpendingKey { + const PREFIX: &[u8; 32] = b"/LEE/v0.3/GroupKeyDerivation/SSK"; + let mut hasher = sha2::Sha256::new(); + hasher.update(PREFIX); + hasher.update(self.gms); + for word in program_id { + hasher.update(word.to_le_bytes()); + } + hasher.update(pda_seed.as_ref()); + SecretSpendingKey(hasher.finalize_fixed().into()) + } + + /// Derive keys for a specific PDA under a given program. + /// + /// All controllers holding the same GMS independently derive the same keys for the + /// same `(program_id, seed)` because the derivation is deterministic. + #[must_use] + pub fn derive_keys_for_pda( + &self, + program_id: &ProgramId, + pda_seed: &PdaSeed, + ) -> PrivateKeyHolder { + self.secret_spending_key_for_pda(program_id, pda_seed) + .produce_private_key_holder(None) + } + + /// Derive keys for a shared regular (non-PDA) private account. + /// + /// Uses a distinct domain separator from `derive_keys_for_pda` to prevent cross-domain + /// key collisions. The `derivation_seed` should be a stable, unique 32-byte value + /// (e.g. derived deterministically from the account's identifier). + #[must_use] + pub fn derive_keys_for_shared_account(&self, derivation_seed: &[u8; 32]) -> PrivateKeyHolder { + const PREFIX: &[u8; 32] = b"/LEE/v0.3/GroupKeyDerivation/SHA"; + let mut hasher = sha2::Sha256::new(); + hasher.update(PREFIX); + hasher.update(self.gms); + hasher.update(derivation_seed); + SecretSpendingKey(hasher.finalize_fixed().into()).produce_private_key_holder(None) + } + + /// Encrypts this holder's GMS under the recipient's [`SealingPublicKey`]. + /// + /// Uses an ephemeral ECDH key exchange to derive a shared secret, then AES-256-GCM + /// to encrypt the payload. The returned bytes are + /// `ephemeral_pubkey (33) || nonce (12) || ciphertext+tag (48)` = 93 bytes. + /// + /// Each call generates a fresh ephemeral key, so two seals of the same holder produce + /// different ciphertexts. + #[must_use] + pub fn seal_for(&self, recipient_key: &SealingPublicKey) -> Vec { + let mut ephemeral_scalar: Scalar = [0_u8; 32]; + OsRng.fill_bytes(&mut ephemeral_scalar); + let ephemeral_pubkey = Secp256k1Point::from_scalar(ephemeral_scalar); + let shared = SharedSecretKey::new(ephemeral_scalar, &recipient_key.0); + let aes_key = Self::seal_kdf(&shared); + let cipher = Aes256Gcm::new(&aes_key.into()); + + let mut nonce_bytes = [0_u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = aes_gcm::Nonce::from(nonce_bytes); + + let ciphertext = cipher + .encrypt(&nonce, self.gms.as_ref()) + .expect("AES-GCM encryption should not fail with valid key/nonce"); + + let capacity = 33_usize + .checked_add(12) + .and_then(|n| n.checked_add(ciphertext.len())) + .expect("seal capacity overflow"); + let mut out = Vec::with_capacity(capacity); + out.extend_from_slice(&ephemeral_pubkey.0); + out.extend_from_slice(&nonce_bytes); + out.extend_from_slice(&ciphertext); + out + } + + /// Decrypts a sealed `GroupKeyHolder` using the recipient's [`SealingSecretKey`]. + /// + /// Returns `Err` if the ciphertext is too short, the ECDH point is invalid, or the + /// AES-GCM authentication tag doesn't verify (wrong key or tampered data). + pub fn unseal(sealed: &[u8], own_key: SealingSecretKey) -> Result { + const HEADER_LEN: usize = 33 + 12; + const MIN_LEN: usize = HEADER_LEN + 16; + if sealed.len() < MIN_LEN { + return Err(SealError::TooShort); + } + // MIN_LEN (61) > HEADER_LEN (45), so all slicing below is in bounds. + let ephemeral_pubkey = Secp256k1Point(sealed[..33].to_vec()); + let nonce = aes_gcm::Nonce::from_slice(&sealed[33..HEADER_LEN]); + let ciphertext = &sealed[HEADER_LEN..]; + + let shared = SharedSecretKey::new(own_key, &ephemeral_pubkey); + let aes_key = Self::seal_kdf(&shared); + let cipher = Aes256Gcm::new(&aes_key.into()); + + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|_err| SealError::DecryptionFailed)?; + + if plaintext.len() != 32 { + return Err(SealError::DecryptionFailed); + } + + let mut gms = [0_u8; 32]; + gms.copy_from_slice(&plaintext); + Ok(Self::from_gms(gms)) + } + + /// Derives an AES-256 key from the ECDH shared secret via SHA-256 with a domain prefix. + fn seal_kdf(shared: &SharedSecretKey) -> [u8; 32] { + const PREFIX: &[u8; 32] = b"/LEE/v0.3/GroupKeySeal/AES\x00\x00\x00\x00\x00\x00"; + let mut hasher = sha2::Sha256::new(); + hasher.update(PREFIX); + hasher.update(shared.0); + hasher.finalize_fixed().into() + } +} + +#[derive(Debug)] +pub enum SealError { + TooShort, + DecryptionFailed, +} + +#[cfg(test)] +mod tests { + use nssa_core::NullifierPublicKey; + + use super::*; + + const TEST_PROGRAM_ID: ProgramId = [9; 8]; + + /// Two holders from the same GMS derive identical keys for the same PDA seed. + #[test] + fn same_gms_same_seed_produces_same_keys() { + let gms = [42_u8; 32]; + let holder_a = GroupKeyHolder::from_gms(gms); + let holder_b = GroupKeyHolder::from_gms(gms); + let seed = PdaSeed::new([1; 32]); + + let keys_a = holder_a.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed); + let keys_b = holder_b.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed); + + assert_eq!( + keys_a.generate_nullifier_public_key().to_byte_array(), + keys_b.generate_nullifier_public_key().to_byte_array(), + ); + } + + /// Different PDA seeds produce different keys from the same GMS. + #[test] + fn same_gms_different_seed_produces_different_keys() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + let seed_a = PdaSeed::new([1; 32]); + let seed_b = PdaSeed::new([2; 32]); + + let npk_a = holder + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed_a) + .generate_nullifier_public_key(); + let npk_b = holder + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed_b) + .generate_nullifier_public_key(); + + assert_ne!(npk_a.to_byte_array(), npk_b.to_byte_array()); + } + + /// Different GMS produce different keys for the same PDA seed. + #[test] + fn different_gms_same_seed_produces_different_keys() { + let holder_a = GroupKeyHolder::from_gms([42_u8; 32]); + let holder_b = GroupKeyHolder::from_gms([99_u8; 32]); + let seed = PdaSeed::new([1; 32]); + + let npk_a = holder_a + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) + .generate_nullifier_public_key(); + let npk_b = holder_b + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) + .generate_nullifier_public_key(); + + assert_ne!(npk_a.to_byte_array(), npk_b.to_byte_array()); + } + + /// GMS round-trip: export and restore produces the same keys. + #[test] + fn gms_round_trip() { + let original = GroupKeyHolder::from_gms([7_u8; 32]); + let restored = GroupKeyHolder::from_gms(*original.dangerous_raw_gms()); + let seed = PdaSeed::new([1; 32]); + + let npk_original = original + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) + .generate_nullifier_public_key(); + let npk_restored = restored + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) + .generate_nullifier_public_key(); + + assert_eq!(npk_original.to_byte_array(), npk_restored.to_byte_array()); + } + + /// The derived `NullifierPublicKey` is non-zero (sanity check). + #[test] + fn derived_npk_is_non_zero() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + let seed = PdaSeed::new([1; 32]); + let npk = holder + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) + .generate_nullifier_public_key(); + + assert_ne!(npk, NullifierPublicKey([0; 32])); + } + + /// Pins the end-to-end derivation for a fixed (GMS, `ProgramId`, `PdaSeed`). Any change + /// to `secret_spending_key_for_pda`, the `PrivateKeyHolder` nsk/npk chain, or the + /// `AccountId::for_private_pda` formula breaks this test. Mirrors the pinned-value + /// pattern from `for_private_pda_matches_pinned_value` in `nssa_core`. + #[test] + fn pinned_end_to_end_derivation_for_private_pda() { + use nssa_core::{account::AccountId, program::ProgramId}; + + let gms = [42_u8; 32]; + let seed = PdaSeed::new([1; 32]); + 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 expected_npk = NullifierPublicKey([ + 136, 176, 234, 71, 208, 8, 143, 142, 126, 155, 132, 18, 71, 27, 88, 56, 100, 90, 79, + 215, 76, 92, 60, 166, 104, 35, 51, 91, 16, 114, 188, 112, + ]); + // 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); + + assert_eq!(npk, expected_npk); + assert_eq!(account_id, expected_account_id); + } + + /// Wallets persist `GroupKeyHolder` to disk and reload it on startup. This test pins + /// the serde round-trip: serialize, deserialize, and assert the derived keys for a + /// sample seed match on both sides. A silent encoding drift would corrupt every + /// group-owned account. + #[test] + fn gms_serde_round_trip_preserves_derivation() { + let original = GroupKeyHolder::from_gms([7_u8; 32]); + let encoded = bincode::serialize(&original).expect("serialize"); + let restored: GroupKeyHolder = bincode::deserialize(&encoded).expect("deserialize"); + + let seed = PdaSeed::new([1; 32]); + let npk_original = original + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) + .generate_nullifier_public_key(); + let npk_restored = restored + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) + .generate_nullifier_public_key(); + + assert_eq!(npk_original, npk_restored); + assert_eq!(original.dangerous_raw_gms(), restored.dangerous_raw_gms()); + } + + /// A `GroupKeyHolder` constructed from the same 32 bytes as a personal + /// `SecretSpendingKey` must not derive the same `NullifierPublicKey` as the personal + /// path, so a private PDA cannot be spent by a personal nullifier even under + /// adversarial key-material reuse. The safety rests on the group path's distinct + /// domain-separation prefix plus the seed mix-in (see `secret_spending_key_for_pda`). + #[test] + fn group_derivation_does_not_collide_with_personal_path_at_shared_bytes() { + let shared_bytes = [13_u8; 32]; + let seed = PdaSeed::new([5; 32]); + + let group_npk = GroupKeyHolder::from_gms(shared_bytes) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) + .generate_nullifier_public_key(); + + let personal_npk = SecretSpendingKey(shared_bytes) + .produce_private_key_holder(None) + .generate_nullifier_public_key(); + + assert_ne!(group_npk, personal_npk); + } + + /// Seal then unseal recovers the same GMS and derived keys. + #[test] + fn seal_unseal_round_trip() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + + let recipient_ssk = SecretSpendingKey([7_u8; 32]); + let recipient_keys = recipient_ssk.produce_private_key_holder(None); + let recipient_vpk = recipient_keys.generate_viewing_public_key(); + let recipient_vsk = recipient_keys.viewing_secret_key; + + let sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0)); + let restored = GroupKeyHolder::unseal(&sealed, recipient_vsk).expect("unseal"); + + assert_eq!(restored.dangerous_raw_gms(), holder.dangerous_raw_gms()); + + let seed = PdaSeed::new([1; 32]); + assert_eq!( + holder + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) + .generate_nullifier_public_key(), + restored + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) + .generate_nullifier_public_key(), + ); + } + + /// Unsealing with a different VSK fails with `DecryptionFailed`. + #[test] + fn unseal_wrong_vsk_fails() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + + let recipient_ssk = SecretSpendingKey([7_u8; 32]); + let recipient_vpk = recipient_ssk + .produce_private_key_holder(None) + .generate_viewing_public_key(); + + let wrong_ssk = SecretSpendingKey([99_u8; 32]); + let wrong_vsk = wrong_ssk + .produce_private_key_holder(None) + .viewing_secret_key; + + let sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0)); + let result = GroupKeyHolder::unseal(&sealed, wrong_vsk); + assert!(matches!(result, Err(super::SealError::DecryptionFailed))); + } + + /// Tampered ciphertext fails authentication. + #[test] + fn unseal_tampered_ciphertext_fails() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + + let recipient_ssk = SecretSpendingKey([7_u8; 32]); + let recipient_keys = recipient_ssk.produce_private_key_holder(None); + let recipient_vpk = recipient_keys.generate_viewing_public_key(); + let recipient_vsk = recipient_keys.viewing_secret_key; + + let mut sealed = holder.seal_for(&SealingPublicKey::from_bytes(recipient_vpk.0)); + // Flip a byte in the ciphertext portion (after ephemeral_pubkey + nonce) + let last = sealed.len() - 1; + sealed[last] ^= 0xFF; + + let result = GroupKeyHolder::unseal(&sealed, recipient_vsk); + assert!(matches!(result, Err(super::SealError::DecryptionFailed))); + } + + /// Two seals of the same holder produce different ciphertexts (ephemeral randomness). + #[test] + fn two_seals_produce_different_ciphertexts() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + + let recipient_ssk = SecretSpendingKey([7_u8; 32]); + let recipient_vpk = recipient_ssk + .produce_private_key_holder(None) + .generate_viewing_public_key(); + + let sealing_key = SealingPublicKey::from_bytes(recipient_vpk.0); + let sealed_a = holder.seal_for(&sealing_key); + let sealed_b = holder.seal_for(&sealing_key); + assert_ne!(sealed_a, sealed_b); + } + + /// Sealed payload is too short. + #[test] + fn unseal_too_short_fails() { + let vsk: SealingSecretKey = [7_u8; 32]; + let result = GroupKeyHolder::unseal(&[0_u8; 10], vsk); + assert!(matches!(result, Err(super::SealError::TooShort))); + } + + /// Degenerate GMS values (all-zeros, all-ones, single-bit) must still produce valid, + /// non-zero, pairwise-distinct npks. Rules out accidental "if gms == default { return + /// default }" style shortcuts in the derivation. + #[test] + fn degenerate_gms_produces_distinct_non_zero_keys() { + let seed = PdaSeed::new([1; 32]); + let degenerate = [[0_u8; 32], [0xFF_u8; 32], { + let mut v = [0_u8; 32]; + v[0] = 1; + v + }]; + + let npks: Vec = degenerate + .iter() + .map(|gms| { + GroupKeyHolder::from_gms(*gms) + .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) + .generate_nullifier_public_key() + }) + .collect(); + + for npk in &npks { + assert_ne!(*npk, NullifierPublicKey([0; 32])); + } + for (i, a) in npks.iter().enumerate() { + for b in &npks[i + 1..] { + assert_ne!(a, b); + } + } + } + + /// Full lifecycle: create group, distribute GMS via seal/unseal, verify key agreement. + #[test] + fn group_pda_lifecycle() { + use nssa_core::account::AccountId; + + let alice_holder = GroupKeyHolder::new(); + let pda_seed = PdaSeed::new([42_u8; 32]); + let program_id: nssa_core::program::ProgramId = [1; 8]; + + // Derive Alice's keys + let alice_keys = alice_holder.derive_keys_for_pda(&TEST_PROGRAM_ID, &pda_seed); + let alice_npk = alice_keys.generate_nullifier_public_key(); + + // Seal GMS for Bob using Bob's viewing key, Bob unseals + let bob_ssk = SecretSpendingKey([77_u8; 32]); + let bob_keys = bob_ssk.produce_private_key_holder(None); + let bob_vpk = bob_keys.generate_viewing_public_key(); + let bob_vsk = bob_keys.viewing_secret_key; + + let sealed = alice_holder.seal_for(&SealingPublicKey::from_bytes(bob_vpk.0)); + let bob_holder = + GroupKeyHolder::unseal(&sealed, bob_vsk).expect("Bob should unseal the GMS"); + + // Key agreement: both derive identical NPK and AccountId + let bob_npk = bob_holder + .derive_keys_for_pda(&TEST_PROGRAM_ID, &pda_seed) + .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); + assert_eq!(alice_account_id, bob_account_id); + } + + /// Same GMS + same derivation seed produces same keys for shared accounts. + #[test] + fn shared_account_same_gms_same_seed_produces_same_keys() { + let gms = [42_u8; 32]; + let derivation_seed = [1_u8; 32]; + let holder_a = GroupKeyHolder::from_gms(gms); + let holder_b = GroupKeyHolder::from_gms(gms); + + let npk_a = holder_a + .derive_keys_for_shared_account(&derivation_seed) + .generate_nullifier_public_key(); + let npk_b = holder_b + .derive_keys_for_shared_account(&derivation_seed) + .generate_nullifier_public_key(); + + assert_eq!(npk_a, npk_b); + } + + /// Different derivation seeds produce different keys for shared accounts. + #[test] + fn shared_account_different_seeds_produce_different_keys() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + let npk_a = holder + .derive_keys_for_shared_account(&[1_u8; 32]) + .generate_nullifier_public_key(); + let npk_b = holder + .derive_keys_for_shared_account(&[2_u8; 32]) + .generate_nullifier_public_key(); + + assert_ne!(npk_a, npk_b); + } + + /// PDA and shared account derivations from the same GMS + same bytes never collide. + #[test] + fn pda_and_shared_derivations_do_not_collide() { + let holder = GroupKeyHolder::from_gms([42_u8; 32]); + let bytes = [1_u8; 32]; + + let pda_npk = holder + .derive_keys_for_pda(&TEST_PROGRAM_ID, &PdaSeed::new(bytes)) + .generate_nullifier_public_key(); + let shared_npk = holder + .derive_keys_for_shared_account(&bytes) + .generate_nullifier_public_key(); + + assert_ne!(pda_npk, shared_npk); + } +} diff --git a/key_protocol/src/key_management/key_tree/keys_private.rs b/key_protocol/src/key_management/key_tree/keys_private.rs index 42130b1f..ab4c5c29 100644 --- a/key_protocol/src/key_management/key_tree/keys_private.rs +++ b/key_protocol/src/key_management/key_tree/keys_private.rs @@ -1,23 +1,27 @@ +use std::collections::BTreeMap; + use k256::{Scalar, elliptic_curve::PrimeField as _}; -use nssa_core::{NullifierPublicKey, encryption::ViewingPublicKey}; +use nssa_core::{NullifierPublicKey, PrivateAccountKind, encryption::ViewingPublicKey}; use serde::{Deserialize, Serialize}; use crate::key_management::{ KeyChain, - key_tree::traits::KeyNode, + key_tree::traits::KeyTreeNode, secret_holders::{PrivateKeyHolder, SecretSpendingKey}, }; #[derive(Debug, Serialize, Deserialize, Clone)] +#[cfg_attr(any(test, feature = "test_utils"), derive(PartialEq, Eq))] pub struct ChildKeysPrivate { - pub value: (KeyChain, nssa::Account), + pub value: (KeyChain, BTreeMap), pub ccc: [u8; 32], /// Can be [`None`] if root. pub cci: Option, } -impl KeyNode for ChildKeysPrivate { - fn root(seed: [u8; 64]) -> Self { +impl ChildKeysPrivate { + #[must_use] + pub fn root(seed: [u8; 64]) -> Self { let hash_value = hmac_sha512::HMAC::mac(seed, b"LEE_master_priv"); let ssk = SecretSpendingKey( @@ -46,14 +50,15 @@ impl KeyNode for ChildKeysPrivate { viewing_secret_key: vsk, }, }, - nssa::Account::default(), + BTreeMap::from_iter([(PrivateAccountKind::Regular(0), nssa::Account::default())]), ), ccc, cci: None, } } - fn nth_child(&self, cci: u32) -> Self { + #[must_use] + pub fn nth_child(&self, cci: u32) -> Self { #[expect(clippy::arithmetic_side_effects, reason = "TODO: fix later")] let parent_pt = Scalar::from_repr(self.value.0.private_key_holder.nullifier_secret_key.into()) @@ -95,43 +100,29 @@ impl KeyNode for ChildKeysPrivate { viewing_secret_key: vsk, }, }, - nssa::Account::default(), + BTreeMap::from_iter([(PrivateAccountKind::Regular(0), nssa::Account::default())]), ), ccc, cci: Some(cci), } } - - fn chain_code(&self) -> &[u8; 32] { - &self.ccc - } - - fn child_index(&self) -> Option { - self.cci - } - - fn account_id(&self) -> nssa::AccountId { - nssa::AccountId::from(&self.value.0.nullifier_public_key) - } } -#[expect( - clippy::single_char_lifetime_names, - reason = "TODO add meaningful name" -)] -impl<'a> From<&'a ChildKeysPrivate> for &'a (KeyChain, nssa::Account) { - fn from(value: &'a ChildKeysPrivate) -> Self { - &value.value +impl KeyTreeNode for ChildKeysPrivate { + fn from_seed(seed: [u8; 64]) -> Self { + Self::root(seed) } -} -#[expect( - clippy::single_char_lifetime_names, - reason = "TODO add meaningful name" -)] -impl<'a> From<&'a mut ChildKeysPrivate> for &'a mut (KeyChain, nssa::Account) { - fn from(value: &'a mut ChildKeysPrivate) -> Self { - &mut value.value + fn derive_child(&self, cci: u32) -> Self { + self.nth_child(cci) + } + + fn account_ids(&self) -> impl Iterator { + let npk = self.value.0.nullifier_public_key; + self.value + .1 + .keys() + .map(move |kind| nssa::AccountId::for_private_account(&npk, kind)) } } diff --git a/key_protocol/src/key_management/key_tree/keys_public.rs b/key_protocol/src/key_management/key_tree/keys_public.rs index 40fc671d..8bcd0ee0 100644 --- a/key_protocol/src/key_management/key_tree/keys_public.rs +++ b/key_protocol/src/key_management/key_tree/keys_public.rs @@ -1,9 +1,10 @@ -use k256::elliptic_curve::{PrimeField as _, sec1::ToEncodedPoint as _}; +use k256::elliptic_curve::{PrimeField as _}; use serde::{Deserialize, Serialize}; -use crate::key_management::key_tree::traits::KeyNode; +use crate::key_management::key_tree::traits::KeyTreeNode; #[derive(Debug, Serialize, Deserialize, Clone)] +#[cfg_attr(any(test, feature = "test_utils"), derive(PartialEq, Eq))] pub struct ChildKeysPublic { pub cssk: nssa::PrivateKey, pub csk: nssa::PrivateKey, @@ -14,32 +15,8 @@ pub struct ChildKeysPublic { } impl ChildKeysPublic { - fn compute_hash_value(&self, cci: u32) -> [u8; 64] { - let mut hash_input = vec![]; - - if ((2_u32).pow(31)).cmp(&cci) == std::cmp::Ordering::Greater { - // Non-harden. - // BIP-032 compatibility requires 1-byte header from the public_key; - // Not stored in `self.cpk.value()`. - let sk = k256::SecretKey::from_bytes(self.cssk.value().into()) - .expect("32 bytes, within curve order"); - let pk = sk.public_key(); - hash_input.extend_from_slice(pk.to_encoded_point(true).as_bytes()); - } else { - // Harden. - hash_input.extend_from_slice(&[0_u8]); - hash_input.extend_from_slice(self.cssk.value()); - } - - #[expect(clippy::big_endian_bytes, reason = "BIP-032 uses big endian")] - hash_input.extend_from_slice(&cci.to_be_bytes()); - - hmac_sha512::HMAC::mac(hash_input, self.ccc) - } -} - -impl KeyNode for ChildKeysPublic { - fn root(seed: [u8; 64]) -> Self { + #[must_use] + pub fn root(seed: [u8; 64]) -> Self { let hash_value = hmac_sha512::HMAC::mac(seed, "LEE_master_pub"); let cssk = nssa::PrivateKey::try_new( @@ -62,7 +39,8 @@ impl KeyNode for ChildKeysPublic { } } - fn nth_child(&self, cci: u32) -> Self { + #[must_use] + pub fn nth_child(&self, cci: u32) -> Self { let hash_value = self.compute_hash_value(cci); let cssk = nssa::PrivateKey::try_new( @@ -99,17 +77,22 @@ impl KeyNode for ChildKeysPublic { } } - fn chain_code(&self) -> &[u8; 32] { - &self.ccc - } - - fn child_index(&self) -> Option { - self.cci - } - - fn account_id(&self) -> nssa::AccountId { + #[must_use] + pub fn account_id(&self) -> nssa::AccountId { nssa::AccountId::from(&self.cpk) } + + fn compute_hash_value(&self, cci: u32) -> [u8; 64] { + let mut hash_input = vec![]; + // Only support harden keys to maintain PQ resilience. + hash_input.extend_from_slice(&[0_u8]); + hash_input.extend_from_slice(self.cssk.value()); + + #[expect(clippy::big_endian_bytes, reason = "BIP-032 uses big endian")] + hash_input.extend_from_slice(&cci.to_be_bytes()); + + hmac_sha512::HMAC::mac(hash_input, self.ccc) + } } #[expect( @@ -122,6 +105,20 @@ impl<'a> From<&'a ChildKeysPublic> for &'a nssa::PrivateKey { } } +impl KeyTreeNode for ChildKeysPublic { + fn from_seed(seed: [u8; 64]) -> Self { + Self::root(seed) + } + + fn derive_child(&self, cci: u32) -> Self { + self.nth_child(cci) + } + + fn account_ids(&self) -> impl Iterator { + std::iter::once(self.account_id()) + } +} + #[cfg(test)] mod tests { use nssa::{PrivateKey, PublicKey}; @@ -168,7 +165,7 @@ mod tests { } #[test] - fn harden_child_keys_generation() { + 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, @@ -207,86 +204,4 @@ mod tests { assert!(expected_csk == child_keys.csk); assert!(expected_cpk == child_keys.cpk); } - - #[test] - fn nonharden_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 cci = 13; - let child_keys = ChildKeysPublic::nth_child(&root_keys, cci); - - let expected_ccc = [ - 79, 228, 242, 119, 211, 203, 198, 175, 95, 36, 4, 234, 139, 45, 137, 138, 54, 211, 187, - 16, 28, 79, 80, 232, 216, 101, 145, 19, 101, 220, 217, 141, - ]; - - let expected_cssk: PrivateKey = PrivateKey::try_new([ - 185, 147, 32, 242, 145, 91, 123, 77, 42, 33, 134, 84, 12, 165, 117, 70, 158, 201, 95, - 153, 14, 12, 92, 235, 128, 156, 194, 169, 68, 35, 165, 127, - ]) - .unwrap(); - - let expected_csk: PrivateKey = PrivateKey::try_new([ - 215, 157, 181, 165, 200, 92, 8, 103, 239, 104, 39, 41, 150, 199, 17, 205, 77, 179, 188, - 27, 168, 216, 198, 12, 94, 11, 72, 131, 148, 44, 166, 128, - ]) - .unwrap(); - - let expected_cpk: PublicKey = PublicKey::try_new([ - 210, 66, 25, 100, 233, 50, 82, 94, 139, 83, 39, 52, 196, 241, 123, 248, 177, 10, 249, - 206, 71, 167, 198, 5, 202, 184, 178, 148, 106, 231, 214, 235, - ]) - .unwrap(); - - assert!(expected_ccc == child_keys.ccc); - assert!(expected_cssk == child_keys.cssk); - assert!(expected_csk == child_keys.csk); - assert!(expected_cpk == child_keys.cpk); - } - - #[test] - fn edge_case_child_keys_generation_2_power_31() { - 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 cci = (2_u32).pow(31); //equivant to 0, thus non-harden. - let child_keys = ChildKeysPublic::nth_child(&root_keys, cci); - - let expected_ccc = [ - 221, 208, 47, 189, 174, 152, 33, 25, 151, 114, 233, 191, 57, 15, 40, 140, 46, 87, 126, - 58, 215, 40, 246, 111, 166, 113, 183, 145, 173, 11, 27, 182, - ]; - - let expected_cssk: PrivateKey = PrivateKey::try_new([ - 223, 29, 87, 189, 126, 24, 117, 225, 190, 57, 0, 143, 207, 168, 231, 139, 170, 192, 81, - 254, 126, 10, 115, 42, 141, 157, 70, 171, 199, 231, 198, 132, - ]) - .unwrap(); - - let expected_csk: PrivateKey = PrivateKey::try_new([ - 35, 70, 190, 115, 134, 106, 151, 84, 164, 16, 139, 204, 100, 203, 36, 219, 91, 200, 6, - 52, 120, 67, 35, 82, 14, 197, 163, 27, 248, 162, 129, 159, - ]) - .unwrap(); - - let expected_cpk: PublicKey = PublicKey::try_new([ - 61, 182, 68, 167, 177, 158, 173, 101, 79, 212, 191, 179, 169, 131, 220, 232, 123, 203, - 235, 244, 72, 251, 159, 98, 215, 85, 103, 49, 124, 137, 98, 39, - ]) - .unwrap(); - - assert!(expected_ccc == child_keys.ccc); - assert!(expected_cssk == child_keys.cssk); - assert!(expected_csk == child_keys.csk); - assert!(expected_cpk == child_keys.cpk); - } } diff --git a/key_protocol/src/key_management/key_tree/mod.rs b/key_protocol/src/key_management/key_tree/mod.rs index f6168f02..544c1a30 100644 --- a/key_protocol/src/key_management/key_tree/mod.rs +++ b/key_protocol/src/key_management/key_tree/mod.rs @@ -2,12 +2,13 @@ use std::collections::BTreeMap; use anyhow::Result; use nssa::{Account, AccountId}; +use nssa_core::Identifier; use serde::{Deserialize, Serialize}; use crate::key_management::{ key_tree::{ chain_index::ChainIndex, keys_private::ChildKeysPrivate, keys_public::ChildKeysPublic, - traits::KeyNode, + traits::KeyTreeNode, }, secret_holders::SeedHolder, }; @@ -20,7 +21,8 @@ pub mod traits; pub const DEPTH_SOFT_CAP: u32 = 20; #[derive(Debug, Serialize, Deserialize, Clone)] -pub struct KeyTree { +#[cfg_attr(any(test, feature = "test_utils"), derive(PartialEq, Eq))] +pub struct KeyTree { pub key_map: BTreeMap, pub account_id_map: BTreeMap, } @@ -28,7 +30,7 @@ pub struct KeyTree { pub type KeyTreePublic = KeyTree; pub type KeyTreePrivate = KeyTree; -impl KeyTree { +impl KeyTree { #[must_use] pub fn new(seed: &SeedHolder) -> Self { let seed_fit: [u8; 64] = seed @@ -37,29 +39,62 @@ impl KeyTree { .try_into() .expect("SeedHolder seed is 64 bytes long"); - let root_keys = N::root(seed_fit); - let account_id = root_keys.account_id(); - - let key_map = BTreeMap::from_iter([(ChainIndex::root(), root_keys)]); - let account_id_map = BTreeMap::from_iter([(account_id, ChainIndex::root())]); + 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, + key_map: BTreeMap::from_iter([(ChainIndex::root(), root_keys)]), account_id_map, } } pub fn new_from_root(root: N) -> Self { - let account_id_map = BTreeMap::from_iter([(root.account_id(), ChainIndex::root())]); - let key_map = BTreeMap::from_iter([(ChainIndex::root(), root)]); + let account_id_map = root + .account_ids() + .map(|id| (id, ChainIndex::root())) + .collect(); Self { - key_map, + key_map: BTreeMap::from_iter([(ChainIndex::root(), root)]), account_id_map, } } - // ToDo: Add function to create a tree from list of nodes with consistency check. + 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 + .find_next_last_child_of_id(parent_cci) + .expect("Can be None only if parent is not present"); + 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) + } + + pub fn fill_node(&mut self, chain_index: &ChainIndex) -> Option { + let parent_keys = self.key_map.get(&chain_index.parent()?)?; + 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()) + } #[must_use] pub fn find_next_last_child_of_id(&self, parent_id: &ChainIndex) -> Option { @@ -102,25 +137,6 @@ impl KeyTree { } } - pub fn generate_new_node( - &mut self, - parent_cci: &ChainIndex, - ) -> Option<(nssa::AccountId, ChainIndex)> { - let parent_keys = self.key_map.get(parent_cci)?; - let next_child_id = self - .find_next_last_child_of_id(parent_cci) - .expect("Can be None only if parent is not present"); - let next_cci = parent_cci.nth_child(next_child_id); - - let child_keys = parent_keys.nth_child(next_child_id); - let account_id = child_keys.account_id(); - - self.key_map.insert(next_cci.clone(), child_keys); - self.account_id_map.insert(account_id, next_cci.clone()); - - Some((account_id, next_cci)) - } - fn find_next_slot_layered(&self) -> ChainIndex { let mut depth = 1; @@ -134,44 +150,10 @@ impl KeyTree { } } - pub fn fill_node(&mut self, chain_index: &ChainIndex) -> Option<(nssa::AccountId, ChainIndex)> { - let parent_keys = self.key_map.get(&chain_index.parent()?)?; - let child_id = *chain_index.chain().last()?; - - let child_keys = parent_keys.nth_child(child_id); - let account_id = child_keys.account_id(); - - self.key_map.insert(chain_index.clone(), child_keys); - self.account_id_map.insert(account_id, chain_index.clone()); - - Some((account_id, chain_index.clone())) - } - - pub fn generate_new_node_layered(&mut self) -> Option<(nssa::AccountId, ChainIndex)> { + pub fn generate_new_node_layered(&mut self) -> Option { self.fill_node(&self.find_next_slot_layered()) } - #[must_use] - pub fn get_node(&self, account_id: nssa::AccountId) -> Option<&N> { - let chain_id = self.account_id_map.get(&account_id)?; - self.key_map.get(chain_id) - } - - pub fn get_node_mut(&mut self, account_id: nssa::AccountId) -> Option<&mut N> { - let chain_id = self.account_id_map.get(&account_id)?; - self.key_map.get_mut(chain_id) - } - - pub fn insert(&mut self, account_id: nssa::AccountId, chain_index: ChainIndex, node: N) { - self.account_id_map.insert(account_id, chain_index.clone()); - self.key_map.insert(chain_index, node); - } - - pub fn remove(&mut self, addr: nssa::AccountId) -> Option { - let chain_index = self.account_id_map.remove(&addr)?; - self.key_map.remove(&chain_index) - } - /// Populates tree with children. /// /// For given `depth` adds children to a tree such that their `ChainIndex::depth(&self) < @@ -194,37 +176,50 @@ impl KeyTree { } } } -} -impl KeyTree { - /// Cleanup of non-initialized accounts in a private tree. - /// - /// If account is default, removes them, stops at first non-default account. - /// - /// Walks through tree in lairs of same depth using `ChainIndex::chain_ids_at_depth()`. - /// - /// Chain must be parsed for accounts beforehand. - /// - /// Slow, maintains tree consistency. - pub fn cleanup_tree_remove_uninit_layered(&mut self, depth: u32) { - let depth = usize::try_from(depth).expect("Depth is expected to fit in usize"); - 'outer: for i in (1..depth).rev() { - println!("Cleanup of tree at depth {i}"); - for id in ChainIndex::chain_ids_at_depth(i) { - if let Some(node) = self.key_map.get(&id) { - if node.value.1 == nssa::Account::default() { - let addr = node.account_id(); - self.remove(addr); - } else { - break 'outer; - } - } - } - } + #[must_use] + pub fn get_node(&self, account_id: nssa::AccountId) -> Option<&N> { + let chain_id = self.account_id_map.get(&account_id)?; + self.key_map.get(chain_id) + } + + pub fn get_node_mut(&mut self, account_id: nssa::AccountId) -> Option<&mut N> { + let chain_id = self.account_id_map.get(&account_id)?; + self.key_map.get_mut(chain_id) + } + + pub fn insert(&mut self, account_id: nssa::AccountId, chain_index: ChainIndex, node: N) { + self.account_id_map.insert(account_id, chain_index.clone()); + self.key_map.insert(chain_index, node); + } + + pub fn remove(&mut self, addr: nssa::AccountId) -> Option { + let chain_index = self.account_id_map.remove(&addr)?; + self.key_map.remove(&chain_index) } } impl KeyTree { + /// 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<(nssa::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)) + } + + /// 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<(nssa::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)) + } + /// Cleanup of non-initialized accounts in a public tree. /// /// If account is default, removes them, stops at first non-default account. @@ -259,6 +254,74 @@ impl KeyTree { } } +impl KeyTree { + pub fn create_private_accounts_key_node( + &mut self, + parent_cci: &ChainIndex, + ) -> Option { + self.generate_new_node(parent_cci) + } + + pub fn create_private_accounts_key_node_layered(&mut self) -> Option { + self.generate_new_node_layered() + } + + /// Register an additional identifier on an existing private key node, inserting the derived + /// `AccountId` into `account_id_map`. Returns `None` if the node does not exist or the + /// `AccountId` is already registered. + pub fn register_identifier_on_node( + &mut self, + cci: &ChainIndex, + identifier: Identifier, + ) -> Option { + let node = self.key_map.get(cci)?; + let account_id = nssa::AccountId::for_regular_private_account( + &node.value.0.nullifier_public_key, + identifier, + ); + if self.account_id_map.contains_key(&account_id) { + return None; + } + self.account_id_map.insert(account_id, cci.clone()); + Some(account_id) + } + + /// Cleanup of non-initialized accounts in a private tree. + /// + /// If account has no synced entries, removes it, stops at first initialized account. + /// + /// Walks through tree in layers of same depth using `ChainIndex::chain_ids_at_depth()`. + /// + /// Chain must be parsed for accounts beforehand. + /// + /// Slow, maintains tree consistency. + pub fn cleanup_tree_remove_uninit_layered(&mut self, depth: u32) { + let depth = usize::try_from(depth).expect("Depth is expected to fit in usize"); + 'outer: for i in (1..depth).rev() { + println!("Cleanup of tree at depth {i}"); + for id in ChainIndex::chain_ids_at_depth(i) { + if let Some(node) = self.key_map.get(&id).cloned() { + if node.value.1.is_empty() + || node + .value + .1 + .iter() + .all(|(_, acc)| acc == &nssa::Account::default()) + { + let account_ids = node.account_ids(); + self.key_map.remove(&id); + for addr in account_ids { + self.account_id_map.remove(&addr); + } + } else { + break 'outer; + } + } + } + } + } +} + #[cfg(test)] mod tests { #![expect(clippy::shadow_unrelated, reason = "We don't care about this in tests")] @@ -266,6 +329,7 @@ mod tests { use std::{collections::HashSet, str::FromStr as _}; use nssa::AccountId; + use nssa_core::PrivateAccountKind; use super::*; @@ -478,25 +542,59 @@ mod tests { .key_map .get_mut(&ChainIndex::from_str("/1").unwrap()) .unwrap(); - acc.value.1.balance = 2; + acc.value.1.insert( + PrivateAccountKind::Regular(0), + nssa::Account { + balance: 2, + ..nssa::Account::default() + }, + ); let acc = tree .key_map .get_mut(&ChainIndex::from_str("/2").unwrap()) .unwrap(); - acc.value.1.balance = 3; + acc.value.1.insert( + PrivateAccountKind::Regular(0), + nssa::Account { + balance: 3, + ..nssa::Account::default() + }, + ); let acc = tree .key_map .get_mut(&ChainIndex::from_str("/0/1").unwrap()) .unwrap(); - acc.value.1.balance = 5; + acc.value.1.insert( + PrivateAccountKind::Regular(0), + nssa::Account { + balance: 5, + ..nssa::Account::default() + }, + ); let acc = tree .key_map .get_mut(&ChainIndex::from_str("/1/0").unwrap()) .unwrap(); - acc.value.1.balance = 6; + acc.value.1.insert( + PrivateAccountKind::Regular(0), + nssa::Account { + balance: 6, + ..nssa::Account::default() + }, + ); + + // Update account_id_map for nodes that now have entries + for chain_index_str in ["/1", "/2", "/0/1", "/1/0"] { + let id = ChainIndex::from_str(chain_index_str).unwrap(); + if let Some(node) = tree.key_map.get(&id) { + for account_id in node.account_ids() { + tree.account_id_map.insert(account_id, id.clone()); + } + } + } tree.cleanup_tree_remove_uninit_layered(10); @@ -518,15 +616,15 @@ mod tests { assert_eq!(key_set, key_set_res); let acc = &tree.key_map[&ChainIndex::from_str("/1").unwrap()]; - assert_eq!(acc.value.1.balance, 2); + assert_eq!(acc.value.1[&PrivateAccountKind::Regular(0)].balance, 2); let acc = &tree.key_map[&ChainIndex::from_str("/2").unwrap()]; - assert_eq!(acc.value.1.balance, 3); + assert_eq!(acc.value.1[&PrivateAccountKind::Regular(0)].balance, 3); let acc = &tree.key_map[&ChainIndex::from_str("/0/1").unwrap()]; - assert_eq!(acc.value.1.balance, 5); + assert_eq!(acc.value.1[&PrivateAccountKind::Regular(0)].balance, 5); let acc = &tree.key_map[&ChainIndex::from_str("/1/0").unwrap()]; - assert_eq!(acc.value.1.balance, 6); + assert_eq!(acc.value.1[&PrivateAccountKind::Regular(0)].balance, 6); } } diff --git a/key_protocol/src/key_management/key_tree/traits.rs b/key_protocol/src/key_management/key_tree/traits.rs index 65e8fae0..71ca4743 100644 --- a/key_protocol/src/key_management/key_tree/traits.rs +++ b/key_protocol/src/key_management/key_tree/traits.rs @@ -1,15 +1,8 @@ -/// Trait, that reperesents a Node in hierarchical key tree. -pub trait KeyNode { - /// Tree root node. - fn root(seed: [u8; 64]) -> Self; - - /// `cci`'s child of node. +pub trait KeyTreeNode: Sized { #[must_use] - fn nth_child(&self, cci: u32) -> Self; - - fn chain_code(&self) -> &[u8; 32]; - - fn child_index(&self) -> Option; - - fn account_id(&self) -> nssa::AccountId; + fn from_seed(seed: [u8; 64]) -> Self; + #[must_use] + fn derive_child(&self, cci: u32) -> Self; + #[must_use] + fn account_ids(&self) -> impl Iterator; } diff --git a/key_protocol/src/key_management/mod.rs b/key_protocol/src/key_management/mod.rs index dcdaff45..ad98d7e2 100644 --- a/key_protocol/src/key_management/mod.rs +++ b/key_protocol/src/key_management/mod.rs @@ -6,13 +6,14 @@ use secret_holders::{PrivateKeyHolder, SecretSpendingKey, SeedHolder}; use serde::{Deserialize, Serialize}; pub mod ephemeral_key_holder; +pub mod group_key_holder; pub mod key_tree; pub mod secret_holders; pub type PublicAccountSigningKey = [u8; 32]; -#[derive(Serialize, Deserialize, Clone, Debug)] -/// Entrypoint to key management. +/// Private account keychain. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct KeyChain { pub secret_spending_key: SecretSpendingKey, pub private_key_holder: PrivateKeyHolder, @@ -42,10 +43,10 @@ impl KeyChain { } #[must_use] - pub fn new_mnemonic(passphrase: String) -> Self { + 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 = SeedHolder::new_mnemonic(passphrase); + 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); @@ -53,12 +54,15 @@ impl KeyChain { 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, - } + ( + Self { + secret_spending_key, + private_key_holder, + nullifier_public_key, + viewing_public_key, + }, + mnemonic, + ) } #[must_use] @@ -68,7 +72,7 @@ impl KeyChain { index: Option, ) -> SharedSecretKey { SharedSecretKey::new( - &self.secret_spending_key.generate_viewing_secret_key(index), + self.secret_spending_key.generate_viewing_secret_key(index), ephemeral_public_key_sender, ) } @@ -169,11 +173,12 @@ mod tests { // /0/0 key_tree_private.generate_new_node_layered().unwrap(); // /2 - let (second_child_id, _) = key_tree_private.generate_new_node_layered().unwrap(); + let second_chain_index = key_tree_private.generate_new_node_layered().unwrap(); key_tree_private - .get_node(second_child_id) - .unwrap() + .key_map + .get(&second_chain_index) + .expect("Node was just inserted") .value .0 .clone() diff --git a/key_protocol/src/key_management/secret_holders.rs b/key_protocol/src/key_management/secret_holders.rs index 02890631..f5e71ca8 100644 --- a/key_protocol/src/key_management/secret_holders.rs +++ b/key_protocol/src/key_management/secret_holders.rs @@ -8,8 +8,6 @@ use rand::{RngCore as _, rngs::OsRng}; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, digest::FixedOutput as _}; -const NSSA_ENTROPY_BYTES: [u8; 32] = [0; 32]; - /// Seed holder. Non-clonable to ensure that different holders use different seeds. /// Produces `TopSecretKeyHolder` objects. #[derive(Debug)] @@ -19,14 +17,14 @@ pub struct SeedHolder { } /// Secret spending key object. Can produce `PrivateKeyHolder` objects. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct SecretSpendingKey(pub [u8; 32]); pub type ViewingSecretKey = Scalar; -#[derive(Serialize, Deserialize, Debug, Clone)] /// Private key holder. Produces public keys. Can produce `account_id`. Can produce shared secret /// for recepient. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct PrivateKeyHolder { pub nullifier_secret_key: NullifierSecretKey, pub viewing_secret_key: ViewingSecretKey, @@ -48,9 +46,24 @@ impl SeedHolder { } #[must_use] - pub fn new_mnemonic(passphrase: String) -> Self { - let mnemonic = Mnemonic::from_entropy(&NSSA_ENTROPY_BYTES) - .expect("Enthropy must be a multiple of 32 bytes"); + pub fn new_mnemonic(passphrase: &str) -> (Self, Mnemonic) { + let mut entropy_bytes: [u8; 32] = [0; 32]; + OsRng.fill_bytes(&mut entropy_bytes); + + 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, + ) + } + + #[must_use] + pub fn from_mnemonic(mnemonic: &Mnemonic, passphrase: &str) -> Self { let seed_wide = mnemonic.to_seed(passphrase); Self { @@ -175,12 +188,63 @@ mod tests { } #[test] - fn two_seeds_generated_same_from_same_mnemonic() { - let mnemonic = "test_pass"; + fn two_seeds_recovered_same_from_same_mnemonic() { + let passphrase = "test_pass"; - let seed_holder1 = SeedHolder::new_mnemonic(mnemonic.to_owned()); - let seed_holder2 = SeedHolder::new_mnemonic(mnemonic.to_owned()); + // Generate a mnemonic with random entropy + let (original_seed_holder, mnemonic) = SeedHolder::new_mnemonic(passphrase); - assert_eq!(seed_holder1.seed, seed_holder2.seed); + // Recover from the same mnemonic + let recovered_seed_holder = SeedHolder::from_mnemonic(&mnemonic, passphrase); + + assert_eq!(original_seed_holder.seed, recovered_seed_holder.seed); + } + + #[test] + fn new_mnemonic_generates_different_seeds_each_time() { + let (seed_holder1, mnemonic1) = SeedHolder::new_mnemonic(""); + let (seed_holder2, mnemonic2) = SeedHolder::new_mnemonic(""); + + // Different entropy should produce different mnemonics and seeds + assert_ne!(mnemonic1.to_string(), mnemonic2.to_string()); + assert_ne!(seed_holder1.seed, seed_holder2.seed); + } + + #[test] + fn new_mnemonic_generates_24_word_phrase() { + let (_seed_holder, mnemonic) = SeedHolder::new_mnemonic(""); + + // 256 bits of entropy produces a 24-word mnemonic + let word_count = mnemonic.to_string().split_whitespace().count(); + assert_eq!(word_count, 24); + } + + #[test] + fn new_mnemonic_produces_valid_seed_length() { + let (seed_holder, _mnemonic) = SeedHolder::new_mnemonic(""); + + assert_eq!(seed_holder.seed.len(), 64); + } + + #[test] + fn different_passphrases_produce_different_seeds() { + let (_seed_holder, mnemonic) = SeedHolder::new_mnemonic(""); + + let seed_with_pass_a = SeedHolder::from_mnemonic(&mnemonic, "password_a"); + let seed_with_pass_b = SeedHolder::from_mnemonic(&mnemonic, "password_b"); + + // Same mnemonic but different passphrases should produce different seeds + assert_ne!(seed_with_pass_a.seed, seed_with_pass_b.seed); + } + + #[test] + fn empty_passphrase_is_deterministic() { + let (_seed_holder, mnemonic) = SeedHolder::new_mnemonic(""); + + let seed1 = SeedHolder::from_mnemonic(&mnemonic, ""); + let seed2 = SeedHolder::from_mnemonic(&mnemonic, ""); + + // Same mnemonic and passphrase should always produce the same seed + assert_eq!(seed1.seed, seed2.seed); } } diff --git a/key_protocol/src/key_protocol_core/mod.rs b/key_protocol/src/key_protocol_core/mod.rs deleted file mode 100644 index 8232d9f4..00000000 --- a/key_protocol/src/key_protocol_core/mod.rs +++ /dev/null @@ -1,214 +0,0 @@ -use std::collections::BTreeMap; - -use anyhow::Result; -use k256::AffinePoint; -use serde::{Deserialize, Serialize}; - -use crate::key_management::{ - KeyChain, - key_tree::{KeyTreePrivate, KeyTreePublic, chain_index::ChainIndex}, - secret_holders::SeedHolder, -}; - -pub type PublicKey = AffinePoint; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct NSSAUserData { - /// Default public accounts. - pub default_pub_account_signing_keys: BTreeMap, - /// Default private accounts. - pub default_user_private_accounts: - BTreeMap, - /// Tree of public keys. - pub public_key_tree: KeyTreePublic, - /// Tree of private keys. - pub private_key_tree: KeyTreePrivate, -} - -impl NSSAUserData { - fn valid_public_key_transaction_pairing_check( - accounts_keys_map: &BTreeMap, - ) -> bool { - let mut check_res = true; - for (account_id, key) in accounts_keys_map { - let expected_account_id = - nssa::AccountId::from(&nssa::PublicKey::new_from_private_key(key)); - if &expected_account_id != account_id { - println!("{expected_account_id}, {account_id}"); - check_res = false; - } - } - check_res - } - - fn valid_private_key_transaction_pairing_check( - accounts_keys_map: &BTreeMap, - ) -> bool { - let mut check_res = true; - for (account_id, (key, _)) in accounts_keys_map { - let expected_account_id = nssa::AccountId::from(&key.nullifier_public_key); - if expected_account_id != *account_id { - println!("{expected_account_id}, {account_id}"); - check_res = false; - } - } - check_res - } - - pub fn new_with_accounts( - default_accounts_keys: BTreeMap, - default_accounts_key_chains: BTreeMap< - nssa::AccountId, - (KeyChain, nssa_core::account::Account), - >, - public_key_tree: KeyTreePublic, - private_key_tree: KeyTreePrivate, - ) -> Result { - if !Self::valid_public_key_transaction_pairing_check(&default_accounts_keys) { - anyhow::bail!( - "Key transaction pairing check not satisfied, there are public account_ids, which are not derived from keys" - ); - } - - if !Self::valid_private_key_transaction_pairing_check(&default_accounts_key_chains) { - anyhow::bail!( - "Key transaction pairing check not satisfied, there are private account_ids, which are not derived from keys" - ); - } - - Ok(Self { - default_pub_account_signing_keys: default_accounts_keys, - default_user_private_accounts: default_accounts_key_chains, - public_key_tree, - private_key_tree, - }) - } - - /// Generated new private key for public transaction signatures. - /// - /// Returns the `account_id` of new account. - pub fn generate_new_public_transaction_private_key( - &mut self, - parent_cci: Option, - ) -> (nssa::AccountId, ChainIndex) { - match parent_cci { - Some(parent_cci) => self - .public_key_tree - .generate_new_node(&parent_cci) - .expect("Parent must be present in a tree"), - None => self - .public_key_tree - .generate_new_node_layered() - .expect("Search for new node slot failed"), - } - } - - /// Returns the signing key for public transaction signatures. - #[must_use] - pub fn get_pub_account_signing_key( - &self, - account_id: nssa::AccountId, - ) -> Option<&nssa::PrivateKey> { - self.default_pub_account_signing_keys - .get(&account_id) - .or_else(|| self.public_key_tree.get_node(account_id).map(Into::into)) - } - - /// Generated new private key for privacy preserving transactions. - /// - /// Returns the `account_id` of new account. - pub fn generate_new_privacy_preserving_transaction_key_chain( - &mut self, - parent_cci: Option, - ) -> (nssa::AccountId, ChainIndex) { - match parent_cci { - Some(parent_cci) => self - .private_key_tree - .generate_new_node(&parent_cci) - .expect("Parent must be present in a tree"), - None => self - .private_key_tree - .generate_new_node_layered() - .expect("Search for new node slot failed"), - } - } - - /// Returns the signing key for public transaction signatures. - #[must_use] - pub fn get_private_account( - &self, - account_id: nssa::AccountId, - ) -> Option<&(KeyChain, nssa_core::account::Account)> { - self.default_user_private_accounts - .get(&account_id) - .or_else(|| self.private_key_tree.get_node(account_id).map(Into::into)) - } - - /// Returns the signing key for public transaction signatures. - pub fn get_private_account_mut( - &mut self, - account_id: &nssa::AccountId, - ) -> Option<&mut (KeyChain, nssa_core::account::Account)> { - // First seek in defaults - if let Some(key) = self.default_user_private_accounts.get_mut(account_id) { - Some(key) - // Then seek in tree - } else { - self.private_key_tree - .get_node_mut(*account_id) - .map(Into::into) - } - } - - pub fn account_ids(&self) -> impl Iterator { - self.public_account_ids().chain(self.private_account_ids()) - } - - pub fn public_account_ids(&self) -> impl Iterator { - self.default_pub_account_signing_keys - .keys() - .copied() - .chain(self.public_key_tree.account_id_map.keys().copied()) - } - - pub fn private_account_ids(&self) -> impl Iterator { - self.default_user_private_accounts - .keys() - .copied() - .chain(self.private_key_tree.account_id_map.keys().copied()) - } -} - -impl Default for NSSAUserData { - fn default() -> Self { - Self::new_with_accounts( - BTreeMap::new(), - BTreeMap::new(), - KeyTreePublic::new(&SeedHolder::new_mnemonic("default".to_owned())), - KeyTreePrivate::new(&SeedHolder::new_mnemonic("default".to_owned())), - ) - .unwrap() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn new_account() { - let mut user_data = NSSAUserData::default(); - - let (account_id_private, _) = user_data - .generate_new_privacy_preserving_transaction_key_chain(Some(ChainIndex::root())); - - let is_key_chain_generated = user_data.get_private_account(account_id_private).is_some(); - - assert!(is_key_chain_generated); - - let account_id_private_str = account_id_private.to_string(); - println!("{account_id_private_str:#?}"); - let key_chain = &user_data.get_private_account(account_id_private).unwrap().0; - println!("{key_chain:#?}"); - } -} diff --git a/key_protocol/src/lib.rs b/key_protocol/src/lib.rs index e3fe31cf..a8c333e4 100644 --- a/key_protocol/src/lib.rs +++ b/key_protocol/src/lib.rs @@ -1,4 +1,3 @@ #![expect(clippy::print_stdout, reason = "TODO: fix later")] pub mod key_management; -pub mod key_protocol_core; diff --git a/keycard_wallet/Cargo.toml b/keycard_wallet/Cargo.toml new file mode 100644 index 00000000..4f44d4ad --- /dev/null +++ b/keycard_wallet/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "keycard_wallet" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +nssa.workspace = true +pyo3.workspace = true +log.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true diff --git a/keycard_wallet/keycard_applets/LEE_keycard.cap b/keycard_wallet/keycard_applets/LEE_keycard.cap new file mode 100644 index 00000000..b44835c4 Binary files /dev/null and b/keycard_wallet/keycard_applets/LEE_keycard.cap differ diff --git a/keycard_wallet/keycard_applets/math.cap b/keycard_wallet/keycard_applets/math.cap new file mode 100644 index 00000000..b9c0e99f Binary files /dev/null and b/keycard_wallet/keycard_applets/math.cap differ diff --git a/keycard_wallet/python/keycard_wallet.py b/keycard_wallet/python/keycard_wallet.py new file mode 100644 index 00000000..7e18636a --- /dev/null +++ b/keycard_wallet/python/keycard_wallet.py @@ -0,0 +1,164 @@ +from smartcard.System import readers +from keycard.exceptions import APDUError, TransportError +from ecdsa import VerifyingKey, SECP256k1 + +from keycard.keycard import KeyCard + +from mnemonic import Mnemonic +from keycard import constants + +import keycard +import secrets + +DEFAULT_PAIRING_PASSWORD = "KeycardDefaultPairing" + +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) -> 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, DEFAULT_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 setup_communication(self, pin: str, password = DEFAULT_PAIRING_PASSWORD) -> bool: + 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 setting up communication: {e}") from e + + return True + + def get_pairing_data(self) -> tuple[int, bytes]: + return (self.pairing_index, self.pairing_key) + + 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 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 \ No newline at end of file diff --git a/keycard_wallet/src/lib.rs b/keycard_wallet/src/lib.rs new file mode 100644 index 00000000..134b6538 --- /dev/null +++ b/keycard_wallet/src/lib.rs @@ -0,0 +1,230 @@ +use std::path::PathBuf; + +use nssa::{AccountId, PublicKey, Signature}; +use pyo3::{prelude::*, types::PyAny}; +use serde::{Deserialize, Serialize}; + +pub mod python_path; + +// TODO: encrypt at rest alongside broader wallet storage encryption work. +#[derive(Serialize, Deserialize)] +pub struct KeycardPairingData { + pub index: u8, + pub key: Vec, +} + +impl KeycardPairingData { + const fn is_valid(&self) -> bool { + self.key.len() == 32 && self.index <= 4 + } +} + +/// Rust wrapper around the Python `KeycardWallet` class. +pub struct KeycardWallet { + instance: Py, +} + +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()?; + + Ok(Self { + instance: instance.into(), + }) + } + + pub fn is_unpaired_keycard_available(&self, py: Python) -> PyResult { + self.instance + .bind(py) + .call_method0("is_unpaired_keycard_available")? + .extract() + } + + pub fn initialize(&self, py: Python<'_>, pin: &str) -> PyResult { + self.instance + .bind(py) + .call_method1("initialize", (pin,))? + .extract() + } + + pub fn get_pairing_data(&self, py: Python<'_>) -> PyResult<(u8, Vec)> { + self.instance + .bind(py) + .call_method0("get_pairing_data")? + .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(()); + } + self.setup_communication(py, pin)?; + if let Ok((index, key)) = self.get_pairing_data(py) { + save_pairing(&KeycardPairingData { index, key }); + } + Ok(()) + } + + pub fn setup_communication(&self, py: Python<'_>, pin: &str) -> PyResult { + self.instance + .bind(py) + .call_method1("setup_communication", (pin,))? + .extract() + } + + pub fn disconnect(&self, py: Python) -> PyResult { + self.instance.bind(py).call_method0("disconnect")?.extract() + } + + 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())) + } + + pub fn get_public_key_for_path_with_connect(pin: &str, path: &str) -> PyResult { + Python::with_gil(|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 + }) + } + + 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()?; + + 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 + )) + })?; + + let sig = Signature { value: signature }; + let pub_key = self.get_public_key_for_path(py, 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", + )); + } + Ok((sig, pub_key)) + } + + pub fn sign_message_for_path_with_connect( + pin: &str, + path: &str, + message: &[u8; 32], + ) -> PyResult<(Signature, PublicKey)> { + Python::with_gil(|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 + }) + } + + pub fn load_mnemonic(&self, py: Python, mnemonic: &str) -> PyResult<()> { + self.instance + .bind(py) + .call_method1("load_mnemonic", (mnemonic,))?; + Ok(()) + } + + pub fn get_account_id_for_path_with_connect(pin: &str, key_path: &str) -> PyResult { + let public_key = Self::get_public_key_for_path_with_connect(pin, key_path)?; + + Ok(format!("Public/{}", AccountId::from(&public_key))) + } +} + +fn pairing_file_path() -> Option { + let home = std::env::var("NSSA_WALLET_HOME_DIR") + .map(PathBuf::from) + .or_else(|_| { + std::env::home_dir() + .map(|h| h.join(".nssa").join("wallet")) + .ok_or(()) + }) + .ok()?; + Some(home.join("keycard_pairing.json")) +} + +fn load_pairing() -> Option { + let path = pairing_file_path()?; + let file = std::fs::File::open(path).ok()?; + serde_json::from_reader(file).ok() +} + +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)); + } +} + +pub fn clear_pairing() { + if let Some(path) = pairing_file_path() { + drop(std::fs::remove_file(path)); + } +} diff --git a/keycard_wallet/src/python_path.rs b/keycard_wallet/src/python_path.rs new file mode 100644 index 00000000..8251f7a3 --- /dev/null +++ b/keycard_wallet/src/python_path.rs @@ -0,0 +1,63 @@ +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("keycard_wallet").join("python"), + python_base + .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.downcast::()?; + + 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>().map(|s| s == path_str).unwrap_or(false)); + + if !already_present { + sys_path.insert(0, path_str)?; + } + } + + Ok(()) +} diff --git a/keycard_wallet/tests/keycard_tests.sh b/keycard_wallet/tests/keycard_tests.sh new file mode 100755 index 00000000..e5ac2f2c --- /dev/null +++ b/keycard_wallet/tests/keycard_tests.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Run wallet_with_keycard.sh first + +source venv/bin/activate # Load the appropriate virtual environment + +export KEYCARD_PIN=111111 + +# Tests wallet keycard available +# - Checks whether smart reader and keycard are both available. +echo "Test: wallet keycard available" +wallet keycard available + +# Install a new mnemonic phrase to keycard +echo "Test: wallet keycard load" +export KEYCARD_MNEMONIC="fashion degree mountain wool question damp current pond grow dolphin chronic then" +wallet keycard load +unset KEYCARD_MNEMONIC + +echo "Test: wallet auth-transfer init --account-id \"m/44'/60'/0'/0/0\"" +wallet auth-transfer init --account-id "m/44'/60'/0'/0/0" + +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\"" +wallet account get --account-id "m/44'/60'/0'/0/0" + +echo "Test: wallet pinata claim --to \"m/44'/60'/0'/0/0\"" +wallet pinata claim --to "m/44'/60'/0'/0/0" + +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\"" +wallet account get --account-id "m/44'/60'/0'/0/0" + +echo "Test: wallet auth-transfer init and send between two keycard accounts" +wallet auth-transfer init --account-id "m/44'/60'/0'/0/1" +wallet auth-transfer send --amount 40 --from "m/44'/60'/0'/0/0" --to "m/44'/60'/0'/0/1" + +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\"" +wallet account get --account-id "m/44'/60'/0'/0/0" + +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/1\"" +wallet account get --account-id "m/44'/60'/0'/0/1" + +# Send from keycard account to a local wallet account +echo "Test: create local wallet account" +LOCAL_ACCOUNT_ID=$(wallet account new public 2>&1 | grep -oP '(?<=Public/)\S+') +echo "Created local account: Public/${LOCAL_ACCOUNT_ID}" + +echo "Test: wallet auth-transfer init local account" +wallet auth-transfer init --account-id "Public/${LOCAL_ACCOUNT_ID}" + + +echo "Test: wallet auth-transfer send from keycard to local account" +wallet auth-transfer send --amount 10 --from "m/44'/60'/0'/0/0" --to "Public/${LOCAL_ACCOUNT_ID}" + +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\"" +wallet account get --account-id "m/44'/60'/0'/0/0" + +echo "Test: wallet account get --account-id \"Public/${LOCAL_ACCOUNT_ID}\"" +wallet account get --account-id "Public/${LOCAL_ACCOUNT_ID}" + +# Create a local wallet account, fund it, and send to keycard account (co-signed: local key + keycard) + +echo "Test: wallet auth-transfer send from local account to keycard account" +wallet auth-transfer send --amount 10 --from "Public/${LOCAL_ACCOUNT_ID}" --to "m/44'/60'/0'/0/1" + +echo "Test: wallet account get --account-id \"Public/${LOCAL_ACCOUNT_ID}\"" +wallet account get --account-id "Public/${LOCAL_ACCOUNT_ID}" + +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/1\"" +wallet account get --account-id "m/44'/60'/0'/0/1" + +# Send from keycard account to a local wallet account (foreign recipient — no signature needed) +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\"" +wallet account get --account-id "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo" + +echo "Test: wallet auth-transfer send from keycard to local account" +wallet auth-transfer send --amount 10 --from "m/44'/60'/0'/0/0" --to "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo" + +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\"" +wallet account get --account-id "m/44'/60'/0'/0/0" + +echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\"" +wallet account get --account-id "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo" diff --git a/keycard_wallet/wallet_with_keycard.sh b/keycard_wallet/wallet_with_keycard.sh new file mode 100755 index 00000000..bcd87bd3 --- /dev/null +++ b/keycard_wallet/wallet_with_keycard.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +cargo install --path wallet --force + +# Install appropriate version of `keycard-py`. +git clone --branch lee-schnorr --single-branch https://github.com/bitgamma/keycard-py.git 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 keycard_wallet/python/keycard-py \ No newline at end of file diff --git a/nssa/Cargo.toml b/nssa/Cargo.toml index 07f5fe53..80542f16 100644 --- a/nssa/Cargo.toml +++ b/nssa/Cargo.toml @@ -9,10 +9,12 @@ workspace = true [dependencies] nssa_core = { workspace = true, features = ["host"] } +clock_core.workspace = true +faucet_core.workspace = true anyhow.workspace = true thiserror.workspace = true -risc0-zkvm.workspace = true +risc0-zkvm = { workspace = true, features = ["client"] } serde.workspace = true serde_with.workspace = true sha2.workspace = true @@ -29,6 +31,7 @@ risc0-binfmt = "3.0.2" [dev-dependencies] token_core.workspace = true +authenticated_transfer_core.workspace = true test_program_methods.workspace = true env_logger.workspace = true diff --git a/nssa/core/src/account.rs b/nssa/core/src/account.rs index 0f9248e3..dc8a49a9 100644 --- a/nssa/core/src/account.rs +++ b/nssa/core/src/account.rs @@ -10,7 +10,7 @@ use risc0_zkvm::sha::{Impl, Sha256 as _}; use serde::{Deserialize, Serialize}; use serde_with::{DeserializeFromStr, SerializeDisplay}; -use crate::{NullifierPublicKey, NullifierSecretKey, program::ProgramId}; +use crate::{NullifierSecretKey, program::ProgramId}; pub mod data; @@ -26,9 +26,9 @@ impl Nonce { } #[must_use] - pub fn private_account_nonce_init(npk: &NullifierPublicKey) -> Self { + pub fn private_account_nonce_init(account_id: &AccountId) -> Self { let mut bytes: [u8; 64] = [0_u8; 64]; - bytes[..32].copy_from_slice(&npk.0); + bytes[..32].copy_from_slice(account_id.value()); let result: [u8; 32] = Impl::hash_bytes(&bytes).as_bytes().try_into().unwrap(); let result = result.first_chunk::<16>().unwrap(); @@ -306,8 +306,8 @@ mod tests { #[test] fn initialize_private_nonce() { - let npk = NullifierPublicKey([42; 32]); - let nonce = Nonce::private_account_nonce_init(&npk); + let account_id = AccountId::new([42; 32]); + let nonce = Nonce::private_account_nonce_init(&account_id); let expected_nonce = Nonce(37_937_661_125_547_691_021_612_781_941_709_513_486); assert_eq!(nonce, expected_nonce); } diff --git a/nssa/core/src/circuit_io.rs b/nssa/core/src/circuit_io.rs index 998f6d71..b1c2e44f 100644 --- a/nssa/core/src/circuit_io.rs +++ b/nssa/core/src/circuit_io.rs @@ -1,33 +1,123 @@ use serde::{Deserialize, Serialize}; use crate::{ - Commitment, CommitmentSetDigest, MembershipProof, Nullifier, NullifierPublicKey, + Commitment, CommitmentSetDigest, Identifier, MembershipProof, Nullifier, NullifierPublicKey, NullifierSecretKey, SharedSecretKey, account::{Account, AccountWithMetadata}, encryption::Ciphertext, - program::{BlockValidityWindow, ProgramId, ProgramOutput, TimestampValidityWindow}, + program::{BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow}, }; #[derive(Serialize, Deserialize)] pub struct PrivacyPreservingCircuitInput { /// Outputs of the program execution. pub program_outputs: Vec, - /// Visibility mask for accounts. - /// - /// - `0` - public account - /// - `1` - private account with authentication - /// - `2` - private account without authentication - pub visibility_mask: Vec, - /// Public keys of private accounts. - pub private_account_keys: Vec<(NullifierPublicKey, SharedSecretKey)>, - /// Nullifier secret keys for authorized private accounts. - pub private_account_nsks: Vec, - /// Membership proofs for private accounts. Can be [`None`] for uninitialized accounts. - pub private_account_membership_proofs: 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` + /// rely on this position alignment. + pub account_identities: Vec, /// Program ID. pub program_id: ProgramId, } +/// 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 + /// commitment, ciphertext, or nullifier. + 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`. + PrivateAuthorizedInit { + ssk: SharedSecretKey, + nsk: NullifierSecretKey, + identifier: Identifier, + }, + /// Update of an authorized standalone private account: existing on-chain commitment, with + /// membership proof. + PrivateAuthorizedUpdate { + 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 { + npk: NullifierPublicKey, + ssk: SharedSecretKey, + identifier: Identifier, + }, + /// 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 { + npk: NullifierPublicKey, + ssk: SharedSecretKey, + 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) == + /// 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`. + seed: Option<(PdaSeed, ProgramId)>, + }, + /// Update of an existing private PDA, with membership proof. `npk` is derived + /// from `nsk`. Authorization may be established upstream by a caller `pda_seeds` match or a + /// previously-seen authorization in a chained call. + PrivatePdaUpdate { + 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) == + /// 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)>, + }, +} + +impl InputAccountIdentity { + #[must_use] + pub const fn is_public(&self) -> bool { + matches!(self, Self::Public) + } + + #[must_use] + pub const fn is_private_pda(&self) -> bool { + matches!( + self, + Self::PrivatePdaInit { .. } | Self::PrivatePdaUpdate { .. } + ) + } + + /// 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)> { + match self { + Self::PrivatePdaInit { + npk, identifier, .. + } => Some((*npk, *identifier)), + Self::PrivatePdaUpdate { + nsk, identifier, .. + } => Some((NullifierPublicKey::from(nsk), *identifier)), + Self::Public + | Self::PrivateAuthorizedInit { .. } + | Self::PrivateAuthorizedUpdate { .. } + | Self::PrivateUnauthorized { .. } => None, + } + } +} + #[derive(Serialize, Deserialize)] #[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq))] pub struct PrivacyPreservingCircuitOutput { @@ -56,7 +146,7 @@ mod tests { use super::*; use crate::{ - Commitment, Nullifier, NullifierPublicKey, + Commitment, Nullifier, account::{Account, AccountId, AccountWithMetadata, Nonce}, }; @@ -93,12 +183,12 @@ mod tests { }], ciphertexts: vec![Ciphertext(vec![255, 255, 1, 1, 2, 2])], new_commitments: vec![Commitment::new( - &NullifierPublicKey::from(&[1; 32]), + &AccountId::new([1; 32]), &Account::default(), )], new_nullifiers: vec![( Nullifier::for_account_update( - &Commitment::new(&NullifierPublicKey::from(&[2; 32]), &Account::default()), + &Commitment::new(&AccountId::new([2; 32]), &Account::default()), &[1; 32], ), [0xab; 32], diff --git a/nssa/core/src/commitment.rs b/nssa/core/src/commitment.rs index 24d5de87..73ccd703 100644 --- a/nssa/core/src/commitment.rs +++ b/nssa/core/src/commitment.rs @@ -2,7 +2,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; use risc0_zkvm::sha::{Impl, Sha256 as _}; use serde::{Deserialize, Serialize}; -use crate::{NullifierPublicKey, account::Account}; +use crate::account::{Account, AccountId}; /// A commitment to all zero data. /// ```python @@ -49,16 +49,16 @@ impl std::fmt::Debug for Commitment { } impl Commitment { - /// Generates the commitment to a private account owned by user for npk: - /// SHA256( `Comm_DS` || npk || `program_owner` || balance || nonce || SHA256(data)). + /// Generates the commitment to a private account owned by user for `account_id`: + /// SHA256( `Comm_DS` || `account_id` || `program_owner` || balance || nonce || SHA256(data)). #[must_use] - pub fn new(npk: &NullifierPublicKey, account: &Account) -> Self { + pub fn new(account_id: &AccountId, account: &Account) -> Self { const COMMITMENT_PREFIX: &[u8; 32] = b"/LEE/v0.3/Commitment/\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"; let mut bytes = Vec::new(); bytes.extend_from_slice(COMMITMENT_PREFIX); - bytes.extend_from_slice(&npk.to_byte_array()); + bytes.extend_from_slice(account_id.value()); let account_bytes_with_hashed_data = { let mut this = Vec::new(); for word in &account.program_owner { @@ -115,14 +115,15 @@ mod tests { use risc0_zkvm::sha::{Impl, Sha256 as _}; use crate::{ - Commitment, DUMMY_COMMITMENT, DUMMY_COMMITMENT_HASH, NullifierPublicKey, account::Account, + Commitment, DUMMY_COMMITMENT, DUMMY_COMMITMENT_HASH, + account::{Account, AccountId}, }; #[test] fn nothing_up_my_sleeve_dummy_commitment() { let default_account = Account::default(); - let npk_null = NullifierPublicKey([0; 32]); - let expected_dummy_commitment = Commitment::new(&npk_null, &default_account); + let account_id_null = AccountId::new([0; 32]); + let expected_dummy_commitment = Commitment::new(&account_id_null, &default_account); assert_eq!(DUMMY_COMMITMENT, expected_dummy_commitment); } diff --git a/nssa/core/src/encryption/mod.rs b/nssa/core/src/encryption/mod.rs index 400fb331..4b675d0e 100644 --- a/nssa/core/src/encryption/mod.rs +++ b/nssa/core/src/encryption/mod.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "host")] pub use shared_key_derivation::{EphemeralPublicKey, EphemeralSecretKey, ViewingPublicKey}; -use crate::{Commitment, account::Account}; +use crate::{Commitment, account::Account, program::PrivateAccountKind}; #[cfg(feature = "host")] pub mod shared_key_derivation; @@ -40,11 +40,15 @@ impl EncryptionScheme { #[must_use] pub fn encrypt( account: &Account, + kind: &PrivateAccountKind, shared_secret: &SharedSecretKey, commitment: &Commitment, output_index: u32, ) -> Ciphertext { - let mut buffer = account.to_bytes(); + // 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); Ciphertext(buffer) } @@ -86,12 +90,19 @@ impl EncryptionScheme { shared_secret: &SharedSecretKey, commitment: &Commitment, output_index: u32, - ) -> Option { + ) -> Option<(PrivateAccountKind, Account)> { use std::io::Cursor; let mut buffer = ciphertext.0.clone(); Self::symmetric_transform(&mut buffer, shared_secret, commitment, output_index); - let mut cursor = Cursor::new(buffer.as_slice()); + if buffer.len() < PrivateAccountKind::HEADER_LEN { + return None; + } + let header: &[u8; PrivateAccountKind::HEADER_LEN] = + buffer[..PrivateAccountKind::HEADER_LEN].try_into().unwrap(); + let kind = PrivateAccountKind::from_header_bytes(header)?; + + let mut cursor = Cursor::new(&buffer[PrivateAccountKind::HEADER_LEN..]); Account::from_cursor(&mut cursor) .inspect_err(|err| { println!( @@ -104,5 +115,43 @@ impl EncryptionScheme { ); }) .ok() + .map(|account| (kind, account)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + account::{Account, AccountId}, + program::PdaSeed, + }; + + #[test] + 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 account_ct = EncryptionScheme::encrypt( + &account, + &PrivateAccountKind::Regular(42), + &secret, + &commitment, + 0, + ); + let pda_ct = EncryptionScheme::encrypt( + &account, + &PrivateAccountKind::Pda { + program_id: [1_u32; 8], + seed: PdaSeed::new([2_u8; 32]), + identifier: 42, + }, + &secret, + &commitment, + 0, + ); + + assert_eq!(account_ct.0.len(), pda_ct.0.len()); } } diff --git a/nssa/core/src/encryption/shared_key_derivation.rs b/nssa/core/src/encryption/shared_key_derivation.rs index 8169e8f9..8ea5aac8 100644 --- a/nssa/core/src/encryption/shared_key_derivation.rs +++ b/nssa/core/src/encryption/shared_key_derivation.rs @@ -17,7 +17,9 @@ use serde::{Deserialize, Serialize}; use crate::{SharedSecretKey, encryption::Scalar}; -#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[derive( + Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, BorshSerialize, BorshDeserialize, +)] pub struct Secp256k1Point(pub Vec); impl std::fmt::Debug for Secp256k1Point { @@ -56,8 +58,8 @@ impl From<&EphemeralSecretKey> for EphemeralPublicKey { impl SharedSecretKey { /// Creates a new shared secret key from a scalar and a point. #[must_use] - pub fn new(scalar: &Scalar, point: &Secp256k1Point) -> Self { - let scalar = k256::Scalar::from_repr((*scalar).into()).unwrap(); + pub fn new(scalar: Scalar, point: &Secp256k1Point) -> Self { + let scalar = k256::Scalar::from_repr(scalar.into()).unwrap(); let point: [u8; 33] = point.0.clone().try_into().unwrap(); let encoded = EncodedPoint::from_bytes(point).unwrap(); diff --git a/nssa/core/src/lib.rs b/nssa/core/src/lib.rs index a4fcdee1..466e1f5d 100644 --- a/nssa/core/src/lib.rs +++ b/nssa/core/src/lib.rs @@ -3,13 +3,16 @@ reason = "We prefer to group methods by functionality rather than by type for encoding" )] -pub use circuit_io::{PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput}; +pub use circuit_io::{ + InputAccountIdentity, PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput, +}; pub use commitment::{ Commitment, CommitmentSetDigest, DUMMY_COMMITMENT, DUMMY_COMMITMENT_HASH, MembershipProof, compute_digest_for_path, }; pub use encryption::{EncryptionScheme, SharedSecretKey}; -pub use nullifier::{Nullifier, NullifierPublicKey, NullifierSecretKey}; +pub use nullifier::{Identifier, Nullifier, NullifierPublicKey, NullifierSecretKey}; +pub use program::PrivateAccountKind; pub mod account; mod circuit_io; @@ -22,6 +25,8 @@ pub mod program; #[cfg(feature = "host")] pub mod error; +pub const GENESIS_BLOCK_ID: BlockId = 1; + pub type BlockId = u64; /// Unix timestamp in milliseconds. pub type Timestamp = u64; diff --git a/nssa/core/src/nullifier.rs b/nssa/core/src/nullifier.rs index bb11cb4b..d1fbae42 100644 --- a/nssa/core/src/nullifier.rs +++ b/nssa/core/src/nullifier.rs @@ -4,18 +4,25 @@ use serde::{Deserialize, Serialize}; use crate::{Commitment, account::AccountId}; -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(any(feature = "host", test), derive(Clone, Hash))] +const PRIVATE_ACCOUNT_ID_PREFIX: &[u8; 32] = b"/LEE/v0.3/AccountId/Private/\x00\x00\x00\x00"; + +pub type Identifier = u128; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[cfg_attr(any(feature = "host", test), derive(Hash))] pub struct NullifierPublicKey(pub [u8; 32]); -impl From<&NullifierPublicKey> for AccountId { - fn from(value: &NullifierPublicKey) -> Self { - const PRIVATE_ACCOUNT_ID_PREFIX: &[u8; 32] = - b"/LEE/v0.3/AccountId/Private/\x00\x00\x00\x00"; - - let mut bytes = [0; 64]; +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]; bytes[0..32].copy_from_slice(PRIVATE_ACCOUNT_ID_PREFIX); - bytes[32..].copy_from_slice(&value.0); + bytes[32..64].copy_from_slice(&npk.0); + bytes[64..80].copy_from_slice(&identifier.to_le_bytes()); + Self::new( Impl::hash_bytes(&bytes) .as_bytes() @@ -25,6 +32,12 @@ impl From<&NullifierPublicKey> for AccountId { } } +impl From<(&NullifierPublicKey, Identifier)> for AccountId { + fn from((npk, identifier): (&NullifierPublicKey, Identifier)) -> Self { + Self::for_regular_private_account(npk, identifier) + } +} + impl AsRef<[u8]> for NullifierPublicKey { fn as_ref(&self) -> &[u8] { self.0.as_slice() @@ -55,7 +68,7 @@ pub type NullifierSecretKey = [u8; 32]; #[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize)] #[cfg_attr( any(feature = "host", test), - derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash) + derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash) )] pub struct Nullifier(pub(super) [u8; 32]); @@ -85,10 +98,10 @@ impl Nullifier { /// Computes a nullifier for an account initialization. #[must_use] - pub fn for_account_initialization(npk: &NullifierPublicKey) -> Self { + pub fn for_account_initialization(account_id: &AccountId) -> Self { const INIT_PREFIX: &[u8; 32] = b"/LEE/v0.3/Nullifier/Initialize/\x00"; let mut bytes = INIT_PREFIX.to_vec(); - bytes.extend_from_slice(&npk.to_byte_array()); + bytes.extend_from_slice(account_id.value()); Self(Impl::hash_bytes(&bytes).as_bytes().try_into().unwrap()) } } @@ -111,7 +124,7 @@ mod tests { #[test] fn constructor_for_account_initialization() { - let npk = NullifierPublicKey([ + let account_id = AccountId::new([ 112, 188, 193, 129, 150, 55, 228, 67, 88, 168, 29, 151, 5, 92, 23, 190, 17, 162, 164, 255, 29, 105, 42, 186, 43, 11, 157, 168, 132, 225, 17, 163, ]); @@ -119,7 +132,7 @@ mod tests { 149, 59, 95, 181, 2, 194, 20, 143, 72, 233, 104, 243, 59, 70, 67, 243, 110, 77, 109, 132, 139, 111, 51, 125, 128, 92, 107, 46, 252, 4, 20, 149, ]); - let nullifier = Nullifier::for_account_initialization(&npk); + let nullifier = Nullifier::for_account_initialization(&account_id); assert_eq!(nullifier, expected_nullifier); } @@ -145,11 +158,46 @@ mod tests { ]; let npk = NullifierPublicKey::from(&nsk); let expected_account_id = AccountId::new([ - 139, 72, 194, 222, 215, 187, 147, 56, 55, 35, 222, 205, 156, 12, 204, 227, 166, 44, 30, - 81, 186, 14, 167, 234, 28, 236, 32, 213, 125, 251, 193, 233, + 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, ]); - let account_id = AccountId::from(&npk); + let account_id = AccountId::for_regular_private_account(&npk, 0); + + assert_eq!(account_id, expected_account_id); + } + + #[test] + fn account_id_from_nullifier_public_key_identifier_1() { + let nsk = [ + 57, 5, 64, 115, 153, 56, 184, 51, 207, 238, 99, 165, 147, 214, 213, 151, 30, 251, 30, + 196, 134, 22, 224, 211, 237, 120, 136, 225, 188, 220, 249, 28, + ]; + let npk = NullifierPublicKey::from(&nsk); + 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, + ]); + + let account_id = AccountId::for_regular_private_account(&npk, 1); + + assert_eq!(account_id, expected_account_id); + } + + #[test] + fn account_id_from_nullifier_public_key_byte_asymmetric_identifier() { + let identifier: u128 = 0x0123_4567_89AB_CDEF_FEDC_BA98_7654_3210; + let nsk = [ + 57, 5, 64, 115, 153, 56, 184, 51, 207, 238, 99, 165, 147, 214, 213, 151, 30, 251, 30, + 196, 134, 22, 224, 211, 237, 120, 136, 225, 188, 220, 249, 28, + ]; + let npk = NullifierPublicKey::from(&nsk); + 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, + ]); + + let account_id = AccountId::for_regular_private_account(&npk, identifier); assert_eq!(account_id, expected_account_id); } diff --git a/nssa/core/src/program.rs b/nssa/core/src/program.rs index 673e09b3..27ad9b8b 100644 --- a/nssa/core/src/program.rs +++ b/nssa/core/src/program.rs @@ -1,12 +1,11 @@ use std::collections::HashSet; -#[cfg(any(feature = "host", test))] use borsh::{BorshDeserialize, BorshSerialize}; use risc0_zkvm::{DeserializeOwned, guest::env, serde::Deserializer}; use serde::{Deserialize, Serialize}; use crate::{ - BlockId, Timestamp, + BlockId, Identifier, NullifierPublicKey, Timestamp, account::{Account, AccountId, AccountWithMetadata}, }; @@ -16,6 +15,8 @@ pub const MAX_NUMBER_CHAINED_CALLS: usize = 10; pub type ProgramId = [u32; 8]; pub type InstructionData = Vec; pub struct ProgramInput { + pub self_program_id: ProgramId, + pub caller_program_id: Option, pub pre_states: Vec, pub instruction: T, } @@ -25,7 +26,20 @@ pub struct ProgramInput { /// Each program can derive up to `2^256` unique account IDs by choosing different /// seeds. PDAs allow programs to control namespaced account identifiers without /// collisions between programs. -#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[derive( + Debug, + Clone, + Copy, + Eq, + PartialEq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + BorshSerialize, + BorshDeserialize, +)] pub struct PdaSeed([u8; 32]); impl PdaSeed { @@ -33,10 +47,83 @@ impl PdaSeed { pub const fn new(value: [u8; 32]) -> Self { Self(value) } + + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } } -impl From<(&ProgramId, &PdaSeed)> for AccountId { - fn from(value: (&ProgramId, &PdaSeed)) -> Self { +impl AsRef<[u8]> for PdaSeed { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +/// Discriminates the type of private account a ciphertext belongs to, carrying the data needed +/// to reconstruct the account's [`AccountId`] on the receiver side. +/// +/// [`AccountId`]: crate::account::AccountId +#[derive( + Debug, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Serialize, + Deserialize, + BorshSerialize, + BorshDeserialize, +)] +pub enum PrivateAccountKind { + Regular(Identifier), + Pda { + program_id: ProgramId, + seed: PdaSeed, + identifier: Identifier, + }, +} + +impl PrivateAccountKind { + /// Borsh layout (all integers little-endian, variant index is u8): + /// + /// ```text + /// Regular(ident): 0x00 || ident (16 LE) || [0u8; 64] + /// Pda { program_id, seed, ident }: 0x01 || program_id (32) || seed (32) || ident (16 LE) + /// ``` + /// + /// Both variants are zero-padded to the same length so all ciphertexts are the same size, + /// preventing observers from distinguishing `Regular` from `Pda` via ciphertext length. + /// `HEADER_LEN` equals the borsh size of the largest variant (`Pda`): 1 + 32 + 32 + 16 = 81. + pub const HEADER_LEN: usize = 81; + + #[must_use] + pub const fn identifier(&self) -> Identifier { + match self { + Self::Regular(identifier) | Self::Pda { identifier, .. } => *identifier, + } + } + + #[must_use] + pub fn to_header_bytes(&self) -> [u8; Self::HEADER_LEN] { + let mut bytes = [0_u8; Self::HEADER_LEN]; + let serialized = borsh::to_vec(self).expect("borsh serialization is infallible"); + bytes[..serialized.len()].copy_from_slice(&serialized); + bytes + } + + #[cfg(feature = "host")] + #[must_use] + pub fn from_header_bytes(bytes: &[u8; Self::HEADER_LEN]) -> Option { + BorshDeserialize::deserialize(&mut bytes.as_ref()).ok() + } +} + +impl AccountId { + /// Derives an [`AccountId`] for a public PDA from the program ID and seed. + #[must_use] + pub fn for_public_pda(program_id: &ProgramId, seed: &PdaSeed) -> Self { use risc0_zkvm::sha::{Impl, Sha256 as _}; const PROGRAM_DERIVED_ACCOUNT_ID_PREFIX: &[u8; 32] = b"/NSSA/v0.2/AccountId/PDA/\x00\x00\x00\x00\x00\x00\x00"; @@ -44,9 +131,9 @@ impl From<(&ProgramId, &PdaSeed)> for AccountId { let mut bytes = [0; 96]; bytes[0..32].copy_from_slice(PROGRAM_DERIVED_ACCOUNT_ID_PREFIX); let program_id_bytes: &[u8] = - bytemuck::try_cast_slice(value.0).expect("ProgramId should be castable to &[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..].copy_from_slice(&value.1.0); + bytes[64..].copy_from_slice(&seed.0); Self::new( Impl::hash_bytes(&bytes) .as_bytes() @@ -54,6 +141,54 @@ impl From<(&ProgramId, &PdaSeed)> for AccountId { .expect("Hash output must be exactly 32 bytes long"), ) } + + /// Derives an [`AccountId`] for a private PDA from the program ID, seed, nullifier public + /// key, and identifier. + /// + /// Unlike public PDAs ([`AccountId::for_public_pda`]), this includes the `npk` in the + /// derivation, making the address unique per group of controllers sharing viewing keys. + /// The `identifier` further diversifies the address, so a single `(program_id, seed, npk)` + /// tuple controls a family of 2^128 addresses. + #[must_use] + pub fn for_private_pda( + program_id: &ProgramId, + seed: &PdaSeed, + npk: &NullifierPublicKey, + 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]; + 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()); + Self::new( + Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .expect("Hash output must be exactly 32 bytes long"), + ) + } + + /// 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 { + match kind { + PrivateAccountKind::Regular(identifier) => { + Self::for_regular_private_account(npk, *identifier) + } + PrivateAccountKind::Pda { + program_id, + seed, + identifier, + } => Self::for_private_pda(program_id, seed, npk, *identifier), + } + } } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] @@ -63,6 +198,9 @@ pub struct ChainedCall { pub pre_states: Vec, /// The instruction data to pass. pub instruction_data: InstructionData, + /// PDA seeds authorized for the callee. For each seed, the callee is authorized to + /// mutate the `AccountId` derived from `(caller_program_id, seed)`, regardless of + /// whether the account is public or private. pub pda_seeds: Vec, } @@ -112,7 +250,9 @@ pub enum Claim { /// This will give no error if program had authorization in pre state and may be useful /// if program decides to give up authorization for a chained call. Authorized, - /// The program requests ownership of the account through a PDA. + /// The program requests ownership of the account through a PDA. The program emits the + /// seed; the `AccountId` is derived from `(program_id, seed)`, regardless of whether the + /// account is public or private. Pda(PdaSeed), } @@ -281,6 +421,11 @@ pub struct InvalidWindow; #[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq))] #[must_use = "ProgramOutput does nothing unless written"] pub struct ProgramOutput { + /// The program ID of the program that produced this output. + pub self_program_id: ProgramId, + /// The program ID of the caller that invoked this program via a chained call, + /// or `None` if this is a top-level call. + pub caller_program_id: Option, /// The instruction data the program received to produce this output. pub instruction_data: InstructionData, /// The account pre states the program received to produce this output. @@ -297,11 +442,15 @@ pub struct ProgramOutput { impl ProgramOutput { pub const fn new( + self_program_id: ProgramId, + caller_program_id: Option, instruction_data: InstructionData, pre_states: Vec, post_states: Vec, ) -> Self { Self { + self_program_id, + caller_program_id, instruction_data, pre_states, post_states, @@ -371,8 +520,8 @@ impl ProgramOutput { } /// Representation of a number as `lo + hi * 2^128`. -#[derive(PartialEq, Eq)] -struct WrappedBalanceSum { +#[derive(Debug, PartialEq, Eq)] +pub struct WrappedBalanceSum { lo: u128, hi: u128, } @@ -382,7 +531,7 @@ impl WrappedBalanceSum { /// /// Returns [`None`] if balance sum overflows `lo + hi * 2^128` representation, which is not /// expected in practical scenarios. - fn from_balances(balances: impl Iterator) -> Option { + pub fn from_balances(balances: impl Iterator) -> Option { let mut wrapped = Self { lo: 0, hi: 0 }; for balance in balances { @@ -397,29 +546,107 @@ impl WrappedBalanceSum { } } +impl std::fmt::Display for WrappedBalanceSum { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.hi == 0 { + write!(f, "{}", self.lo) + } else { + write!(f, "{} * 2^128 + {}", self.hi, self.lo) + } + } +} + +impl From for WrappedBalanceSum { + fn from(value: u128) -> Self { + Self { lo: value, hi: 0 } + } +} + +#[derive(thiserror::Error, Debug)] +pub enum ExecutionValidationError { + #[error("Pre-state account IDs are not unique")] + PreStateAccountIdsNotUnique, + + #[error( + "Pre-state and post-state lengths do not match: pre-state length {pre_state_length}, post-state length {post_state_length}" + )] + MismatchedPreStatePostStateLength { + pre_state_length: usize, + post_state_length: usize, + }, + + #[error("Unallowed modification of nonce for account {account_id}")] + ModifiedNonce { account_id: AccountId }, + + #[error("Unallowed modification of program owner for account {account_id}")] + ModifiedProgramOwner { account_id: AccountId }, + + #[error( + "Trying to decrease balance of account {account_id} owned by {owner_program_id:?} in a program {executing_program_id:?} which is not the owner" + )] + UnauthorizedBalanceDecrease { + account_id: AccountId, + owner_program_id: ProgramId, + executing_program_id: ProgramId, + }, + + #[error( + "Unauthorized modification of data for account {account_id} which is not default and not owned by executing program {executing_program_id:?}" + )] + UnauthorizedDataModification { + account_id: AccountId, + executing_program_id: ProgramId, + }, + + #[error( + "Post-state for account {account_id} has default program owner but pre-state was not default" + )] + NonDefaultAccountWithDefaultOwner { account_id: AccountId }, + + #[error("Total balance across accounts overflowed 2^256 - 1")] + BalanceSumOverflow, + + #[error( + "Total balance across accounts is not preserved: total balance in pre-states {total_balance_pre_states}, total balance in post-states {total_balance_post_states}" + )] + MismatchedTotalBalance { + total_balance_pre_states: WrappedBalanceSum, + total_balance_post_states: WrappedBalanceSum, + }, +} + +/// Computes the set of public-PDA `AccountId`s the callee is authorized to mutate. +/// +/// Returns only public-form derivations, suitable for contexts where all accounts are public +/// (e.g. the public-execution path). The privacy circuit must additionally check each mask-3 +/// `pre_state` against [`AccountId::for_private_pda`] with the supplied npk for that +/// `pre_state`. #[must_use] -pub fn compute_authorized_pdas( +pub fn compute_public_authorized_pdas( caller_program_id: Option, pda_seeds: &[PdaSeed], ) -> HashSet { - caller_program_id - .map(|caller_program_id| { - pda_seeds - .iter() - .map(|pda_seed| AccountId::from((&caller_program_id, pda_seed))) - .collect() - }) - .unwrap_or_default() + let Some(caller) = caller_program_id else { + return HashSet::new(); + }; + pda_seeds + .iter() + .map(|seed| AccountId::for_public_pda(&caller, seed)) + .collect() } /// Reads the NSSA inputs from the guest environment. #[must_use] pub fn read_nssa_inputs() -> (ProgramInput, InstructionData) { + let self_program_id: ProgramId = env::read(); + let caller_program_id: Option = env::read(); let pre_states: Vec = env::read(); let instruction_words: InstructionData = env::read(); let instruction = T::deserialize(&mut Deserializer::new(instruction_words.as_ref())).unwrap(); ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction, }, @@ -433,31 +660,39 @@ pub fn read_nssa_inputs() -> (ProgramInput, InstructionD /// - `pre_states`: The list of input accounts, each annotated with authorization metadata. /// - `post_states`: The list of resulting accounts after executing the program logic. /// - `executing_program_id`: The identifier of the program that was executed. -#[must_use] pub fn validate_execution( pre_states: &[AccountWithMetadata], post_states: &[AccountPostState], executing_program_id: ProgramId, -) -> bool { +) -> Result<(), ExecutionValidationError> { // 1. Check account ids are all different if !validate_uniqueness_of_account_ids(pre_states) { - return false; + return Err(ExecutionValidationError::PreStateAccountIdsNotUnique); } // 2. Lengths must match if pre_states.len() != post_states.len() { - return false; + return Err( + ExecutionValidationError::MismatchedPreStatePostStateLength { + pre_state_length: pre_states.len(), + post_state_length: post_states.len(), + }, + ); } for (pre, post) in pre_states.iter().zip(post_states) { // 3. Nonce must remain unchanged if pre.account.nonce != post.account.nonce { - return false; + return Err(ExecutionValidationError::ModifiedNonce { + account_id: pre.account_id, + }); } // 4. Program ownership changes are not allowed if pre.account.program_owner != post.account.program_owner { - return false; + return Err(ExecutionValidationError::ModifiedProgramOwner { + account_id: pre.account_id, + }); } let account_program_owner = pre.account.program_owner; @@ -466,7 +701,11 @@ pub fn validate_execution( if post.account.balance < pre.account.balance && account_program_owner != executing_program_id { - return false; + return Err(ExecutionValidationError::UnauthorizedBalanceDecrease { + account_id: pre.account_id, + owner_program_id: account_program_owner, + executing_program_id, + }); } // 6. Data changes only allowed if owned by executing program or if account pre state has @@ -475,35 +714,44 @@ pub fn validate_execution( && pre.account != Account::default() && account_program_owner != executing_program_id { - return false; + return Err(ExecutionValidationError::UnauthorizedDataModification { + account_id: pre.account_id, + executing_program_id, + }); } // 7. If a post state has default program owner, the pre state must have been a default // account if post.account.program_owner == DEFAULT_PROGRAM_ID && pre.account != Account::default() { - return false; + return Err( + ExecutionValidationError::NonDefaultAccountWithDefaultOwner { + account_id: pre.account_id, + }, + ); } } // 8. Total balance is preserved - let Some(total_balance_pre_states) = WrappedBalanceSum::from_balances(pre_states.iter().map(|pre| pre.account.balance)) else { - return false; + return Err(ExecutionValidationError::BalanceSumOverflow); }; let Some(total_balance_post_states) = WrappedBalanceSum::from_balances(post_states.iter().map(|post| post.account.balance)) else { - return false; + return Err(ExecutionValidationError::BalanceSumOverflow); }; if total_balance_pre_states != total_balance_post_states { - return false; + return Err(ExecutionValidationError::MismatchedTotalBalance { + total_balance_pre_states, + total_balance_post_states, + }); } - true + Ok(()) } fn validate_uniqueness_of_account_ids(pre_states: &[AccountWithMetadata]) -> bool { @@ -620,7 +868,7 @@ mod tests { #[test] fn program_output_try_with_block_validity_window_range() { - let output = ProgramOutput::new(vec![], vec![], vec![]) + 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)); @@ -629,24 +877,24 @@ mod tests { #[test] fn program_output_with_block_validity_window_range_from() { - let output = - ProgramOutput::new(vec![], vec![], vec![]).with_block_validity_window(10_u64..); + 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(vec![], vec![], vec![]).with_block_validity_window(..100_u64); + 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(vec![], vec![], vec![]).try_with_block_validity_window(5_u64..5); + let result = ProgramOutput::new(DEFAULT_PROGRAM_ID, None, vec![], vec![], vec![]) + .try_with_block_validity_window(5_u64..5); assert!(result.is_err()); } @@ -694,4 +942,162 @@ mod tests { 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()); + } } diff --git a/nssa/src/error.rs b/nssa/src/error.rs index 61966515..65079d25 100644 --- a/nssa/src/error.rs +++ b/nssa/src/error.rs @@ -1,12 +1,16 @@ use std::io; +use nssa_core::{ + account::{Account, AccountId}, + program::ProgramId, +}; use thiserror::Error; #[macro_export] macro_rules! ensure { ($cond:expr, $err:expr) => { if !$cond { - return Err($err); + return Err($err.into()); } }; } @@ -17,7 +21,7 @@ pub enum NssaError { InvalidInput(String), #[error("Program violated execution rules")] - InvalidProgramBehavior, + InvalidProgramBehavior(#[from] InvalidProgramBehaviorError), #[error("Serialization error: {0}")] InstructionSerializationError(String), @@ -32,15 +36,15 @@ pub enum NssaError { InvalidPublicKey(#[source] k256::schnorr::Error), #[error("Invalid hex for public key")] - InvalidHexPublicKey(hex::FromHexError), + InvalidHexPublicKey(#[source] hex::FromHexError), - #[error("Risc0 error: {0}")] + #[error("Failed to write program input: {0}")] ProgramWriteInputFailed(String), - #[error("Risc0 error: {0}")] + #[error("Failed to execute program: {0}")] ProgramExecutionFailed(String), - #[error("Risc0 error: {0}")] + #[error("Failed to prove program: {0}")] ProgramProveFailed(String), #[error("Invalid transaction: {0}")] @@ -77,6 +81,55 @@ pub enum NssaError { OutOfValidityWindow, } +#[derive(Error, Debug)] +pub enum InvalidProgramBehaviorError { + #[error( + "Inconsistent pre-state for account {account_id} : expected {expected:?}, actual {actual:?}" + )] + InconsistentAccountPreState { + account_id: AccountId, + // Boxed to reduce the size of the error type + expected: Box, + actual: Box, + }, + + #[error("Unauthorized account marked as authorized")] + InvalidAccountAuthorization { account_id: AccountId }, + + #[error("Program ID mismatch: expected {expected:?}, actual {actual:?}")] + MismatchedProgramId { + expected: ProgramId, + actual: ProgramId, + }, + + #[error("Caller program ID mismatch: expected {expected:?}, actual {actual:?}")] + MismatchedCallerProgramId { + expected: Option, + actual: Option, + }, + + #[error(transparent)] + ExecutionValidationFailed(#[from] nssa_core::program::ExecutionValidationError), + + #[error("Trying to claim account {account_id} which is not default")] + ClaimedNonDefaultAccount { account_id: AccountId }, + + #[error("Trying to claim account {account_id} which is not authorized")] + ClaimedUnauthorizedAccount { account_id: AccountId }, + + #[error("PDA claim mismatch: expected {expected:?}, actual {actual:?}")] + MismatchedPdaClaim { + expected: AccountId, + actual: AccountId, + }, + + #[error("Default account {account_id} was modified without being claimed")] + DefaultAccountModifiedWithoutClaim { account_id: AccountId }, + + #[error("Called program {program_id:?} which is not listed in dependencies")] + UndeclaredProgramDependency { program_id: ProgramId }, +} + #[cfg(test)] mod tests { diff --git a/nssa/src/lib.rs b/nssa/src/lib.rs index ce958354..5998e803 100644 --- a/nssa/src/lib.rs +++ b/nssa/src/lib.rs @@ -4,7 +4,7 @@ )] pub use nssa_core::{ - SharedSecretKey, + GENESIS_BLOCK_ID, SharedSecretKey, account::{Account, AccountId, Data}, encryption::EphemeralPublicKey, program::ProgramId, @@ -16,7 +16,11 @@ pub use program_deployment_transaction::ProgramDeploymentTransaction; pub use program_methods::PRIVACY_PRESERVING_CIRCUIT_ID; pub use public_transaction::PublicTransaction; pub use signature::{PrivateKey, PublicKey, Signature}; -pub use state::V03State; +pub use state::{ + CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID, + CLOCK_PROGRAM_ACCOUNT_IDS, V03State, system_faucet_account_id, +}; +pub use validated_state_diff::ValidatedStateDiff; pub mod encoding; pub mod error; @@ -27,6 +31,7 @@ pub mod program_deployment_transaction; pub mod public_transaction; mod signature; mod state; +mod validated_state_diff; pub mod program_methods { include!(concat!(env!("OUT_DIR"), "/program_methods/mod.rs")); diff --git a/nssa/src/merkle_tree/mod.rs b/nssa/src/merkle_tree/mod.rs index 588f0f60..e439d092 100644 --- a/nssa/src/merkle_tree/mod.rs +++ b/nssa/src/merkle_tree/mod.rs @@ -17,6 +17,26 @@ pub struct MerkleTree { } impl MerkleTree { + pub fn with_capacity(capacity: usize) -> Self { + // Adjust capacity to ensure power of two + let capacity = capacity.next_power_of_two(); + let total_depth = usize::try_from(capacity.trailing_zeros()).expect("u32 fits in usize"); + + let nodes = default_values::DEFAULT_VALUES[..=total_depth] + .iter() + .rev() + .enumerate() + .flat_map(|(level, default_value)| std::iter::repeat_n(default_value, 1 << level)) + .copied() + .collect(); + + Self { + nodes, + capacity, + length: 0, + } + } + pub fn root(&self) -> Node { let root_index = self.root_index(); *self.get_node(root_index) @@ -49,26 +69,6 @@ impl MerkleTree { self.nodes[index] = node; } - pub fn with_capacity(capacity: usize) -> Self { - // Adjust capacity to ensure power of two - let capacity = capacity.next_power_of_two(); - let total_depth = usize::try_from(capacity.trailing_zeros()).expect("u32 fits in usize"); - - let nodes = default_values::DEFAULT_VALUES[..=total_depth] - .iter() - .rev() - .enumerate() - .flat_map(|(level, default_value)| std::iter::repeat_n(default_value, 1 << level)) - .copied() - .collect(); - - Self { - nodes, - capacity, - length: 0, - } - } - /// Reallocates storage of Merkle tree for double capacity. /// The current tree is embedded into the new tree as a subtree. fn reallocate_to_double_capacity(&mut self) { diff --git a/nssa/src/privacy_preserving_transaction/circuit.rs b/nssa/src/privacy_preserving_transaction/circuit.rs index 0ae7eaac..902f5eaa 100644 --- a/nssa/src/privacy_preserving_transaction/circuit.rs +++ b/nssa/src/privacy_preserving_transaction/circuit.rs @@ -2,15 +2,14 @@ use std::collections::{HashMap, VecDeque}; use borsh::{BorshDeserialize, BorshSerialize}; use nssa_core::{ - MembershipProof, NullifierPublicKey, NullifierSecretKey, PrivacyPreservingCircuitInput, - PrivacyPreservingCircuitOutput, SharedSecretKey, + InputAccountIdentity, PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput, account::AccountWithMetadata, program::{ChainedCall, InstructionData, ProgramId, ProgramOutput}, }; use risc0_zkvm::{ExecutorEnv, InnerReceipt, ProverOpts, Receipt, default_prover}; use crate::{ - error::NssaError, + error::{InvalidProgramBehaviorError, NssaError}, program::Program, program_methods::{PRIVACY_PRESERVING_CIRCUIT_ELF, PRIVACY_PRESERVING_CIRCUIT_ID}, state::MAX_NUMBER_CHAINED_CALLS, @@ -63,14 +62,10 @@ impl From for ProgramWithDependencies { /// Generates a proof of the execution of a NSSA program inside the privacy preserving execution /// circuit. -/// TODO: too many parameters. pub fn execute_and_prove( pre_states: Vec, instruction_data: InstructionData, - visibility_mask: Vec, - private_account_keys: Vec<(NullifierPublicKey, SharedSecretKey)>, - private_account_nsks: Vec, - private_account_membership_proofs: Vec>, + account_identities: Vec, program_with_dependencies: &ProgramWithDependencies, ) -> Result<(PrivacyPreservingCircuitOutput, Proof), NssaError> { let ProgramWithDependencies { @@ -87,15 +82,16 @@ pub fn execute_and_prove( pda_seeds: vec![], }; - let mut chained_calls = VecDeque::from_iter([(initial_call, initial_program)]); + let mut chained_calls = VecDeque::from_iter([(initial_call, initial_program, None)]); let mut chain_calls_counter = 0; - while let Some((chained_call, program)) = chained_calls.pop_front() { + while let Some((chained_call, program, caller_program_id)) = chained_calls.pop_front() { if chain_calls_counter >= MAX_NUMBER_CHAINED_CALLS { return Err(NssaError::MaxChainedCallsDepthExceeded); } let inner_receipt = execute_and_prove_program( program, + caller_program_id, &chained_call.pre_states, &chained_call.instruction_data, )?; @@ -112,10 +108,12 @@ pub fn execute_and_prove( 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(NssaError::InvalidProgramBehavior)?; - chained_calls.push_front((new_call, next_program)); + 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 @@ -125,10 +123,7 @@ pub fn execute_and_prove( let circuit_input = PrivacyPreservingCircuitInput { program_outputs, - visibility_mask, - private_account_keys, - private_account_nsks, - private_account_membership_proofs, + account_identities, program_id: program_with_dependencies.program.id(), }; @@ -153,12 +148,19 @@ pub fn execute_and_prove( 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(pre_states, instruction_data, &mut env_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 @@ -174,8 +176,10 @@ mod tests { #![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")] use nssa_core::{ - Commitment, DUMMY_COMMITMENT_HASH, EncryptionScheme, Nullifier, SharedSecretKey, + Commitment, DUMMY_COMMITMENT_HASH, EncryptionScheme, Nullifier, + PrivacyPreservingCircuitOutput, SharedSecretKey, account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data}, + program::{PdaSeed, PrivateAccountKind}, }; use super::*; @@ -189,6 +193,21 @@ mod tests { }, }; + fn decrypt_kind( + output: &PrivacyPreservingCircuitOutput, + ssk: &SharedSecretKey, + idx: usize, + ) -> PrivateAccountKind { + let (kind, _) = EncryptionScheme::decrypt( + &output.ciphertexts[idx], + ssk, + &output.new_commitments[idx], + u32::try_from(idx).expect("idx fits in u32"), + ) + .unwrap(); + kind + } + #[test] fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts() { let recipient_keys = test_private_account_keys_1(); @@ -203,11 +222,8 @@ mod tests { AccountId::new([0; 32]), ); - let recipient = AccountWithMetadata::new( - Account::default(), - false, - AccountId::from(&recipient_keys.npk()), - ); + 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; @@ -221,22 +237,29 @@ mod tests { let expected_recipient_post = Account { program_owner: program.id(), balance: balance_to_move, - nonce: Nonce::private_account_nonce_init(&recipient_keys.npk()), + nonce: Nonce::private_account_nonce_init(&recipient_account_id), data: Data::default(), }; let expected_sender_pre = sender.clone(); let esk = [3; 32]; - let shared_secret = SharedSecretKey::new(&esk, &recipient_keys.vpk()); + let shared_secret = SharedSecretKey::new(esk, &recipient_keys.vpk()); let (output, proof) = execute_and_prove( vec![sender, recipient], - Program::serialize_instruction(balance_to_move).unwrap(), - vec![0, 2], - vec![(recipient_keys.npk(), shared_secret)], - vec![], - vec![None], + Program::serialize_instruction(authenticated_transfer_core::Instruction::Transfer { + amount: balance_to_move, + }) + .unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateUnauthorized { + npk: recipient_keys.npk(), + ssk: shared_secret, + identifier: 0, + }, + ], &Program::authenticated_transfer_program().into(), ) .unwrap(); @@ -251,7 +274,7 @@ mod tests { assert_eq!(output.new_nullifiers.len(), 1); assert_eq!(output.ciphertexts.len(), 1); - let recipient_post = EncryptionScheme::decrypt( + let (_identifier, recipient_post) = EncryptionScheme::decrypt( &output.ciphertexts[0], &shared_secret, &output.new_commitments[0], @@ -276,27 +299,24 @@ mod tests { data: Data::default(), }, true, - AccountId::from(&sender_keys.npk()), + AccountId::for_regular_private_account(&sender_keys.npk(), 0), ); - let commitment_sender = Commitment::new(&sender_keys.npk(), &sender_pre.account); + 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 = AccountWithMetadata::new( - Account::default(), - false, - AccountId::from(&recipient_keys.npk()), - ); + 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_keys.npk()), + Nullifier::for_account_initialization(&recipient_account_id), DUMMY_COMMITMENT_HASH, ), ]; @@ -312,30 +332,41 @@ mod tests { let expected_private_account_2 = Account { program_owner: program.id(), balance: balance_to_move, - nonce: Nonce::private_account_nonce_init(&recipient_keys.npk()), + nonce: Nonce::private_account_nonce_init(&recipient_account_id), ..Default::default() }; let expected_new_commitments = vec![ - Commitment::new(&sender_keys.npk(), &expected_private_account_1), - Commitment::new(&recipient_keys.npk(), &expected_private_account_2), + Commitment::new(&sender_account_id, &expected_private_account_1), + Commitment::new(&recipient_account_id, &expected_private_account_2), ]; let esk_1 = [3; 32]; - let shared_secret_1 = SharedSecretKey::new(&esk_1, &sender_keys.vpk()); + let shared_secret_1 = SharedSecretKey::new(esk_1, &sender_keys.vpk()); let esk_2 = [5; 32]; - let shared_secret_2 = SharedSecretKey::new(&esk_2, &recipient_keys.vpk()); + let shared_secret_2 = SharedSecretKey::new(esk_2, &recipient_keys.vpk()); let (output, proof) = execute_and_prove( vec![sender_pre, recipient], - Program::serialize_instruction(balance_to_move).unwrap(), - vec![1, 2], + Program::serialize_instruction(authenticated_transfer_core::Instruction::Transfer { + amount: balance_to_move, + }) + .unwrap(), vec![ - (sender_keys.npk(), shared_secret_1), - (recipient_keys.npk(), shared_secret_2), + InputAccountIdentity::PrivateAuthorizedUpdate { + 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 { + npk: recipient_keys.npk(), + ssk: shared_secret_2, + identifier: 0, + }, ], - vec![sender_keys.nsk], - vec![commitment_set.get_proof_for(&commitment_sender), None], &program.into(), ) .unwrap(); @@ -347,7 +378,7 @@ mod tests { assert_eq!(output.new_nullifiers, expected_new_nullifiers); assert_eq!(output.ciphertexts.len(), 2); - let sender_post = EncryptionScheme::decrypt( + let (_identifier, sender_post) = EncryptionScheme::decrypt( &output.ciphertexts[0], &shared_secret_1, &expected_new_commitments[0], @@ -356,7 +387,7 @@ mod tests { .unwrap(); assert_eq!(sender_post, expected_private_account_1); - let recipient_post = EncryptionScheme::decrypt( + let (_identifier, recipient_post) = EncryptionScheme::decrypt( &output.ciphertexts[1], &shared_secret_2, &expected_new_commitments[1], @@ -372,7 +403,7 @@ mod tests { let pre = AccountWithMetadata::new( Account::default(), false, - AccountId::from(&account_keys.npk()), + AccountId::for_regular_private_account(&account_keys.npk(), 0), ); let validity_window_chain_caller = Program::validity_window_chain_caller(); @@ -388,7 +419,7 @@ mod tests { .unwrap(); let esk = [3; 32]; - let shared_secret = SharedSecretKey::new(&esk, &account_keys.vpk()); + let shared_secret = SharedSecretKey::new(esk, &account_keys.vpk()); let program_with_deps = ProgramWithDependencies::new( validity_window_chain_caller, @@ -398,10 +429,448 @@ mod tests { let result = execute_and_prove( vec![pre], instruction, - vec![2], - vec![(account_keys.npk(), shared_secret)], - vec![], - vec![None], + vec![InputAccountIdentity::PrivateUnauthorized { + npk: account_keys.npk(), + ssk: shared_secret, + identifier: 0, + }], + &program_with_deps, + ); + + assert!(matches!(result, Err(NssaError::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 = Program::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::new([55; 32], &keys.vpk()); + + 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 { + 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 `authenticated_transfer`'s ownership. + /// The `auth_transfer_proxy` program chains to `authenticated_transfer` with `pda_seeds` + /// to establish authorization and the private PDA binding. + #[test] + fn private_pda_init() { + let program = Program::auth_transfer_proxy(); + let auth_transfer = Program::authenticated_transfer_program(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + let shared_secret_pda = SharedSecretKey::new([55; 32], &keys.vpk()); + + // 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 = auth_transfer.id(); + let program_with_deps = + ProgramWithDependencies::new(program, [(auth_id, auth_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 { + 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 `authenticated_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 = Program::auth_transfer_proxy(); + let auth_transfer = Program::authenticated_transfer_program(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + let shared_secret_pda = SharedSecretKey::new([55; 32], &keys.vpk()); + + // 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: auth_transfer.id(), + balance: 10000, + ..Account::default() + }, + true, + recipient_id, + ); + + let auth_id = auth_transfer.id(); + let program_with_deps = + ProgramWithDependencies::new(program, [(auth_id, auth_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 { + 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_auth_transfer() { + let program = Program::authenticated_transfer_program(); + let shared_keys = test_private_account_keys_1(); + let shared_npk = shared_keys.npk(); + let shared_identifier: u128 = 42; + let shared_secret = SharedSecretKey::new([55; 32], &shared_keys.vpk()); + + // 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(authenticated_transfer_core::Instruction::Transfer { + amount: balance_to_move, + }) + .unwrap(); + + let result = execute_and_prove( + vec![sender, recipient], + instruction, + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateUnauthorized { + 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 = Program::authenticated_transfer_program(); + let keys = test_private_account_keys_1(); + let identifier: u128 = 99; + let ssk = SharedSecretKey::new([55; 32], &keys.vpk()); + 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(authenticated_transfer_core::Instruction::Initialize) + .unwrap(), + vec![InputAccountIdentity::PrivateAuthorizedInit { + 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 = Program::authenticated_transfer_program(); + let keys = test_private_account_keys_1(); + let identifier: u128 = 99; + let ssk = SharedSecretKey::new([55; 32], &keys.vpk()); + + let sender = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 1, + ..Account::default() + }, + true, + AccountId::new([0; 32]), + ); + 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![sender, recipient], + Program::serialize_instruction(authenticated_transfer_core::Instruction::Transfer { + amount: 1, + }) + .unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateUnauthorized { + 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 = Program::authenticated_transfer_program(); + let keys = test_private_account_keys_1(); + let identifier: u128 = 99; + let ssk = SharedSecretKey::new([55; 32], &keys.vpk()); + 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 recipient = AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32])); + + let (output, _) = execute_and_prove( + vec![sender, recipient], + Program::serialize_instruction(authenticated_transfer_core::Instruction::Transfer { + amount: 1, + }) + .unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + ssk, + nsk: keys.nsk, + membership_proof: commitment_set.get_proof_for(&commitment).unwrap(), + identifier, + }, + InputAccountIdentity::Public, + ], + &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 = Program::pda_spend_proxy(); + let auth_transfer = Program::authenticated_transfer_program(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + let identifier: u128 = 99; + let ssk = SharedSecretKey::new([55; 32], &keys.vpk()); + + let auth_transfer_id = auth_transfer.id(); + let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, identifier); + let pda_account = Account { + program_owner: auth_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(), + [(auth_transfer_id, auth_transfer)].into(), + ); + + let (output, _) = execute_and_prove( + vec![pda_pre, recipient_pre], + Program::serialize_instruction((seed, 1_u128, auth_transfer_id, false)).unwrap(), + vec![ + InputAccountIdentity::PrivatePdaUpdate { + 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 = Program::pda_claimer(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk()); + + 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 { + npk, + ssk: shared_secret, + identifier: 99, + seed: None, + }], + &program.into(), + ); + + assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); + } + + #[test] + fn private_pda_update_identifier_mismatch_fails() { + let program = Program::pda_spend_proxy(); + let auth_transfer = Program::authenticated_transfer_program(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + let ssk = SharedSecretKey::new([55; 32], &keys.vpk()); + + let auth_transfer_id = auth_transfer.id(); + let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 5); + let pda_account = Account { + program_owner: auth_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, [(auth_transfer_id, auth_transfer)].into()); + + let result = execute_and_prove( + vec![pda_pre, recipient_pre], + Program::serialize_instruction((seed, 1_u128, auth_transfer_id, false)).unwrap(), + vec![ + InputAccountIdentity::PrivatePdaUpdate { + ssk, + nsk: keys.nsk, + membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(), + identifier: 99, + seed: None, + }, + InputAccountIdentity::Public, + ], &program_with_deps, ); diff --git a/nssa/src/privacy_preserving_transaction/message.rs b/nssa/src/privacy_preserving_transaction/message.rs index 85f4a202..3a968bfb 100644 --- a/nssa/src/privacy_preserving_transaction/message.rs +++ b/nssa/src/privacy_preserving_transaction/message.rs @@ -9,6 +9,8 @@ use sha2::{Digest as _, Sha256}; use crate::{AccountId, error::NssaError}; +const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Privacy/\x00\x00\x00\x00\x00\x00"; + pub type ViewTag = u8; #[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] @@ -118,22 +120,35 @@ impl Message { timestamp_validity_window: output.timestamp_validity_window, }) } + + #[must_use] + pub fn hash(&self) -> [u8; 32] { + let msg = self.to_bytes(); + let mut bytes = Vec::with_capacity( + PREFIX + .len() + .checked_add(msg.len()) + .expect("length overflow"), + ); + bytes.extend_from_slice(PREFIX); + bytes.extend_from_slice(&msg); + + Sha256::digest(bytes).into() + } } #[cfg(test)] pub mod tests { use nssa_core::{ - Commitment, EncryptionScheme, Nullifier, NullifierPublicKey, SharedSecretKey, - account::Account, + Commitment, EncryptionScheme, Nullifier, NullifierPublicKey, PrivateAccountKind, + SharedSecretKey, + account::{Account, AccountId, Nonce}, encryption::{EphemeralPublicKey, ViewingPublicKey}, program::{BlockValidityWindow, TimestampValidityWindow}, }; use sha2::{Digest as _, Sha256}; - use crate::{ - AccountId, - privacy_preserving_transaction::message::{EncryptedAccountData, Message}, - }; + use super::{EncryptedAccountData, Message, PREFIX}; #[must_use] pub fn message_for_tests() -> Message { @@ -154,9 +169,11 @@ pub mod tests { let encrypted_private_post_states = Vec::new(); - let new_commitments = vec![Commitment::new(&npk2, &account2)]; + let account_id2 = nssa_core::account::AccountId::for_regular_private_account(&npk2, 0); + let new_commitments = vec![Commitment::new(&account_id2, &account2)]; - let old_commitment = Commitment::new(&npk1, &account1); + let account_id1 = nssa_core::account::AccountId::for_regular_private_account(&npk1, 0); + let old_commitment = Commitment::new(&account_id1, &account1); let new_nullifiers = vec![( Nullifier::for_account_update(&old_commitment, &nsk1), [0; 32], @@ -174,16 +191,75 @@ pub mod tests { } } + #[test] + fn hash_privacy_pinned() { + let msg = Message { + public_account_ids: vec![AccountId::new([42_u8; 32])], + nonces: vec![Nonce(5)], + public_post_states: vec![], + encrypted_private_post_states: vec![], + new_commitments: vec![], + new_nullifiers: vec![], + block_validity_window: BlockValidityWindow::new_unbounded(), + timestamp_validity_window: TimestampValidityWindow::new_unbounded(), + }; + + let public_account_ids_bytes: &[u8] = &[42_u8; 32]; + let nonces_bytes: &[u8] = &[1, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + // all remaining vec fields are empty: u32 len=0 + let empty_vec_bytes: &[u8] = &[0_u8; 4]; + // validity windows: unbounded = {from: None (0u8), to: None (0u8)} + let unbounded_window_bytes: &[u8] = &[0_u8; 2]; + + let expected_borsh_vec: Vec = [ + &[1_u8, 0, 0, 0], // public_account_ids + public_account_ids_bytes, + nonces_bytes, + empty_vec_bytes, // public_post_state + empty_vec_bytes, // encrypted_private_post_states + empty_vec_bytes, // new_commitments + empty_vec_bytes, // new_nullifiers + unbounded_window_bytes, // block_validity_window + unbounded_window_bytes, // timestamp_validity_window + ] + .concat(); + let expected_borsh: &[u8] = &expected_borsh_vec; + + assert_eq!( + borsh::to_vec(&msg).unwrap(), + expected_borsh, + "`privacy_preserving_transaction::hash()`: expected borsh order has changed" + ); + + let mut preimage = Vec::with_capacity(PREFIX.len() + expected_borsh.len()); + preimage.extend_from_slice(PREFIX); + preimage.extend_from_slice(expected_borsh); + let expected_hash: [u8; 32] = Sha256::digest(&preimage).into(); + + assert_eq!( + msg.hash(), + expected_hash, + "`privacy_preserving_transaction::hash()`: serialization has changed" + ); + } + #[test] fn encrypted_account_data_constructor() { let npk = NullifierPublicKey::from(&[1; 32]); let vpk = ViewingPublicKey::from_scalar([2; 32]); let account = Account::default(); - let commitment = Commitment::new(&npk, &account); + let account_id = nssa_core::account::AccountId::for_regular_private_account(&npk, 0); + let commitment = Commitment::new(&account_id, &account); let esk = [3; 32]; - let shared_secret = SharedSecretKey::new(&esk, &vpk); + let shared_secret = SharedSecretKey::new(esk, &vpk); let epk = EphemeralPublicKey::from_scalar(esk); - let ciphertext = EncryptionScheme::encrypt(&account, &shared_secret, &commitment, 2); + let ciphertext = EncryptionScheme::encrypt( + &account, + &PrivateAccountKind::Regular(0), + &shared_secret, + &commitment, + 2, + ); let encrypted_account_data = EncryptedAccountData::new(ciphertext.clone(), &npk, &vpk, epk.clone()); diff --git a/nssa/src/privacy_preserving_transaction/transaction.rs b/nssa/src/privacy_preserving_transaction/transaction.rs index 977bb0d0..2e46f628 100644 --- a/nssa/src/privacy_preserving_transaction/transaction.rs +++ b/nssa/src/privacy_preserving_transaction/transaction.rs @@ -1,19 +1,10 @@ -use std::{ - collections::{HashMap, HashSet}, - hash::Hash, -}; +use std::collections::HashSet; use borsh::{BorshDeserialize, BorshSerialize}; -use nssa_core::{ - BlockId, PrivacyPreservingCircuitOutput, Timestamp, - account::{Account, AccountWithMetadata}, -}; +use nssa_core::account::AccountId; use sha2::{Digest as _, digest::FixedOutput as _}; use super::{message::Message, witness_set::WitnessSet}; -use crate::{ - AccountId, V03State, error::NssaError, privacy_preserving_transaction::circuit::Proof, -}; #[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct PrivacyPreservingTransaction { @@ -30,108 +21,6 @@ impl PrivacyPreservingTransaction { } } - pub(crate) fn validate_and_produce_public_state_diff( - &self, - state: &V03State, - block_id: BlockId, - timestamp: Timestamp, - ) -> Result, NssaError> { - let message = &self.message; - let witness_set = &self.witness_set; - - // 1. Commitments or nullifiers are non empty - if message.new_commitments.is_empty() && message.new_nullifiers.is_empty() { - return Err(NssaError::InvalidInput( - "Empty commitments and empty nullifiers found in message".into(), - )); - } - - // 2. Check there are no duplicate account_ids in the public_account_ids list. - if n_unique(&message.public_account_ids) != message.public_account_ids.len() { - return Err(NssaError::InvalidInput( - "Duplicate account_ids found in message".into(), - )); - } - - // Check there are no duplicate nullifiers in the new_nullifiers list - if n_unique(&message.new_nullifiers) != message.new_nullifiers.len() { - return Err(NssaError::InvalidInput( - "Duplicate nullifiers found in message".into(), - )); - } - - // Check there are no duplicate commitments in the new_commitments list - if n_unique(&message.new_commitments) != message.new_commitments.len() { - return Err(NssaError::InvalidInput( - "Duplicate commitments found in message".into(), - )); - } - - // 3. Nonce checks and Valid signatures - // Check exactly one nonce is provided for each signature - if message.nonces.len() != witness_set.signatures_and_public_keys.len() { - return Err(NssaError::InvalidInput( - "Mismatch between number of nonces and signatures/public keys".into(), - )); - } - - // Check the signatures are valid - if !witness_set.signatures_are_valid_for(message) { - return Err(NssaError::InvalidInput( - "Invalid signature for given message and public key".into(), - )); - } - - let signer_account_ids = self.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; - if current_nonce != *nonce { - return Err(NssaError::InvalidInput("Nonce mismatch".into())); - } - } - - // Verify validity window - if !message.block_validity_window.is_valid_for(block_id) - || !message.timestamp_validity_window.is_valid_for(timestamp) - { - return Err(NssaError::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)?; - - Ok(message - .public_account_ids - .iter() - .copied() - .zip(message.public_post_states.clone()) - .collect()) - } - #[must_use] pub const fn message(&self) -> &Message { &self.message @@ -170,36 +59,6 @@ impl PrivacyPreservingTransaction { } } -fn check_privacy_preserving_circuit_proof_is_valid( - proof: &Proof, - public_pre_states: &[AccountWithMetadata], - message: &Message, -) -> Result<(), NssaError> { - let output = PrivacyPreservingCircuitOutput { - public_pre_states: public_pre_states.to_vec(), - public_post_states: message.public_post_states.clone(), - ciphertexts: message - .encrypted_private_post_states - .iter() - .cloned() - .map(|value| value.ciphertext) - .collect(), - 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(NssaError::InvalidPrivacyPreservingProof) -} - -fn n_unique(data: &[T]) -> usize { - let set: HashSet<&T> = data.iter().collect(); - set.len() -} - #[cfg(test)] mod tests { use crate::{ diff --git a/nssa/src/privacy_preserving_transaction/witness_set.rs b/nssa/src/privacy_preserving_transaction/witness_set.rs index 373bbc9c..e17df90c 100644 --- a/nssa/src/privacy_preserving_transaction/witness_set.rs +++ b/nssa/src/privacy_preserving_transaction/witness_set.rs @@ -14,12 +14,12 @@ pub struct WitnessSet { impl WitnessSet { #[must_use] pub fn for_message(message: &Message, proof: Proof, private_keys: &[&PrivateKey]) -> Self { - let message_bytes = message.to_bytes(); + let message_hash = message.hash(); let signatures_and_public_keys = private_keys .iter() .map(|&key| { ( - Signature::new(key, &message_bytes), + Signature::new(key, &message_hash), PublicKey::new_from_private_key(key), ) }) @@ -32,9 +32,9 @@ impl WitnessSet { #[must_use] pub fn signatures_are_valid_for(&self, message: &Message) -> bool { - let message_bytes = message.to_bytes(); + let message_hash = message.hash(); for (signature, public_key) in self.signatures_and_public_keys() { - if !signature.is_valid_for(&message_bytes, public_key) { + if !signature.is_valid_for(&message_hash, public_key) { return false; } } diff --git a/nssa/src/program.rs b/nssa/src/program.rs index b87fcf35..696c2086 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -9,7 +9,9 @@ use serde::Serialize; use crate::{ error::NssaError, program_methods::{ - AMM_ELF, ASSOCIATED_TOKEN_ACCOUNT_ELF, AUTHENTICATED_TRANSFER_ELF, PINATA_ELF, TOKEN_ELF, + AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID, + AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, CLOCK_ELF, CLOCK_ID, FAUCET_ELF, + FAUCET_ID, PINATA_ELF, PINATA_ID, TOKEN_ELF, TOKEN_ID, VAULT_ELF, VAULT_ID, }, }; @@ -52,13 +54,20 @@ impl Program { pub(crate) fn execute( &self, + caller_program_id: Option, pre_states: &[AccountWithMetadata], instruction_data: &InstructionData, ) -> Result { // Write inputs to the program let mut env_builder = ExecutorEnv::builder(); env_builder.session_limit(Some(MAX_NUM_CYCLES_PUBLIC_EXECUTION)); - Self::write_inputs(pre_states, instruction_data, &mut env_builder)?; + Self::write_inputs( + self.id, + caller_program_id, + pre_states, + instruction_data, + &mut env_builder, + )?; let env = env_builder.build().unwrap(); // Execute the program (without proving) @@ -78,40 +87,82 @@ impl Program { /// Writes inputs to `env_builder` in the order expected by the programs. pub(crate) fn write_inputs( + program_id: ProgramId, + caller_program_id: Option, pre_states: &[AccountWithMetadata], instruction_data: &[u32], env_builder: &mut ExecutorEnvBuilder, ) -> Result<(), NssaError> { + env_builder + .write(&program_id) + .map_err(|e| NssaError::ProgramWriteInputFailed(e.to_string()))?; + env_builder + .write(&caller_program_id) + .map_err(|e| NssaError::ProgramWriteInputFailed(e.to_string()))?; let pre_states = pre_states.to_vec(); env_builder - .write(&(pre_states, instruction_data)) + .write(&pre_states) + .map_err(|e| NssaError::ProgramWriteInputFailed(e.to_string()))?; + env_builder + .write(&instruction_data) .map_err(|e| NssaError::ProgramWriteInputFailed(e.to_string()))?; Ok(()) } #[must_use] pub fn authenticated_transfer_program() -> Self { - // This unwrap won't panic since the `AUTHENTICATED_TRANSFER_ELF` comes from risc0 build of - // `program_methods` - Self::new(AUTHENTICATED_TRANSFER_ELF.to_vec()).unwrap() + Self { + id: AUTHENTICATED_TRANSFER_ID, + elf: AUTHENTICATED_TRANSFER_ELF.to_vec(), + } } #[must_use] pub fn token() -> Self { - // This unwrap won't panic since the `TOKEN_ELF` comes from risc0 build of - // `program_methods` - Self::new(TOKEN_ELF.to_vec()).unwrap() + Self { + id: TOKEN_ID, + elf: TOKEN_ELF.to_vec(), + } } #[must_use] pub fn amm() -> Self { - Self::new(AMM_ELF.to_vec()).expect("The AMM program must be a valid Risc0 program") + Self { + id: AMM_ID, + elf: AMM_ELF.to_vec(), + } + } + + #[must_use] + pub fn clock() -> Self { + Self { + id: CLOCK_ID, + elf: CLOCK_ELF.to_vec(), + } } #[must_use] pub fn ata() -> Self { - Self::new(ASSOCIATED_TOKEN_ACCOUNT_ELF.to_vec()) - .expect("The ATA program must be a valid Risc0 program") + Self { + id: ASSOCIATED_TOKEN_ACCOUNT_ID, + elf: ASSOCIATED_TOKEN_ACCOUNT_ELF.to_vec(), + } + } + + #[must_use] + pub fn vault() -> Self { + Self { + id: VAULT_ID, + elf: VAULT_ELF.to_vec(), + } + } + + #[must_use] + pub fn faucet() -> Self { + Self { + id: FAUCET_ID, + elf: FAUCET_ELF.to_vec(), + } } } @@ -119,16 +170,19 @@ impl Program { impl Program { #[must_use] pub fn pinata() -> Self { - // This unwrap won't panic since the `PINATA_ELF` comes from risc0 build of - // `program_methods` - Self::new(PINATA_ELF.to_vec()).unwrap() + Self { + id: PINATA_ID, + elf: PINATA_ELF.to_vec(), + } } #[must_use] - #[expect(clippy::non_ascii_literal, reason = "More readable")] pub fn pinata_token() -> Self { - use crate::program_methods::PINATA_TOKEN_ELF; - Self::new(PINATA_TOKEN_ELF.to_vec()).expect("Piñata program must be a valid R0BF file") + use crate::program_methods::{PINATA_TOKEN_ELF, PINATA_TOKEN_ID}; + Self { + id: PINATA_TOKEN_ID, + elf: PINATA_TOKEN_ELF.to_vec(), + } } } @@ -139,8 +193,10 @@ mod tests { use crate::{ program::Program, program_methods::{ - AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, PINATA_ELF, PINATA_ID, - TOKEN_ELF, TOKEN_ID, + AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID, + AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_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, }, }; @@ -253,6 +309,56 @@ mod tests { } } + #[must_use] + pub fn pda_claimer() -> Self { + use test_program_methods::{PDA_CLAIMER_ELF, PDA_CLAIMER_ID}; + + Self { + id: PDA_CLAIMER_ID, + elf: PDA_CLAIMER_ELF.to_vec(), + } + } + + #[must_use] + pub fn private_pda_delegator() -> Self { + use test_program_methods::{PRIVATE_PDA_DELEGATOR_ELF, PRIVATE_PDA_DELEGATOR_ID}; + + Self { + id: PRIVATE_PDA_DELEGATOR_ID, + elf: PRIVATE_PDA_DELEGATOR_ELF.to_vec(), + } + } + + #[must_use] + pub fn auth_transfer_proxy() -> Self { + use test_program_methods::{AUTH_TRANSFER_PROXY_ELF, AUTH_TRANSFER_PROXY_ID}; + + Self { + id: AUTH_TRANSFER_PROXY_ID, + elf: AUTH_TRANSFER_PROXY_ELF.to_vec(), + } + } + + #[must_use] + pub fn two_pda_claimer() -> Self { + use test_program_methods::{TWO_PDA_CLAIMER_ELF, TWO_PDA_CLAIMER_ID}; + + Self { + id: TWO_PDA_CLAIMER_ID, + elf: TWO_PDA_CLAIMER_ELF.to_vec(), + } + } + + #[must_use] + pub fn pda_spend_proxy() -> Self { + use test_program_methods::{PDA_SPEND_PROXY_ELF, PDA_SPEND_PROXY_ID}; + + Self { + id: PDA_SPEND_PROXY_ID, + elf: PDA_SPEND_PROXY_ELF.to_vec(), + } + } + #[must_use] pub fn changer_claimer() -> Self { use test_program_methods::{CHANGER_CLAIMER_ELF, CHANGER_CLAIMER_ID}; @@ -273,6 +379,16 @@ mod tests { } } + #[must_use] + pub fn auth_asserting_noop() -> Self { + use test_program_methods::{AUTH_ASSERTING_NOOP_ELF, AUTH_ASSERTING_NOOP_ID}; + + Self { + id: AUTH_ASSERTING_NOOP_ID, + elf: AUTH_ASSERTING_NOOP_ELF.to_vec(), + } + } + #[must_use] pub fn malicious_authorization_changer() -> Self { use test_program_methods::{ @@ -287,24 +403,89 @@ mod tests { #[must_use] pub fn modified_transfer_program() -> Self { - use test_program_methods::MODIFIED_TRANSFER_ELF; - // This unwrap won't panic since the `MODIFIED_TRANSFER_ELF` comes from risc0 build of - // `program_methods` - Self::new(MODIFIED_TRANSFER_ELF.to_vec()).unwrap() + use test_program_methods::{MODIFIED_TRANSFER_ELF, MODIFIED_TRANSFER_ID}; + Self { + id: MODIFIED_TRANSFER_ID, + elf: MODIFIED_TRANSFER_ELF.to_vec(), + } } #[must_use] pub fn validity_window() -> Self { - use test_program_methods::VALIDITY_WINDOW_ELF; - // This unwrap won't panic since the `VALIDITY_WINDOW_ELF` comes from risc0 build of - // `program_methods` - Self::new(VALIDITY_WINDOW_ELF.to_vec()).unwrap() + use test_program_methods::{VALIDITY_WINDOW_ELF, VALIDITY_WINDOW_ID}; + Self { + id: VALIDITY_WINDOW_ID, + elf: VALIDITY_WINDOW_ELF.to_vec(), + } } #[must_use] pub fn validity_window_chain_caller() -> Self { - use test_program_methods::VALIDITY_WINDOW_CHAIN_CALLER_ELF; - Self::new(VALIDITY_WINDOW_CHAIN_CALLER_ELF.to_vec()).unwrap() + use test_program_methods::{ + VALIDITY_WINDOW_CHAIN_CALLER_ELF, VALIDITY_WINDOW_CHAIN_CALLER_ID, + }; + Self { + id: VALIDITY_WINDOW_CHAIN_CALLER_ID, + elf: VALIDITY_WINDOW_CHAIN_CALLER_ELF.to_vec(), + } + } + + #[must_use] + pub fn flash_swap_initiator() -> Self { + use test_program_methods::FLASH_SWAP_INITIATOR_ELF; + Self::new(FLASH_SWAP_INITIATOR_ELF.to_vec()) + .expect("flash_swap_initiator must be a valid Risc0 program") + } + + #[must_use] + pub fn flash_swap_callback() -> Self { + use test_program_methods::FLASH_SWAP_CALLBACK_ELF; + Self::new(FLASH_SWAP_CALLBACK_ELF.to_vec()) + .expect("flash_swap_callback must be a valid Risc0 program") + } + + #[must_use] + pub fn malicious_self_program_id() -> Self { + use test_program_methods::MALICIOUS_SELF_PROGRAM_ID_ELF; + Self::new(MALICIOUS_SELF_PROGRAM_ID_ELF.to_vec()) + .expect("malicious_self_program_id must be a valid Risc0 program") + } + + #[must_use] + pub fn malicious_caller_program_id() -> Self { + use test_program_methods::MALICIOUS_CALLER_PROGRAM_ID_ELF; + Self::new(MALICIOUS_CALLER_PROGRAM_ID_ELF.to_vec()) + .expect("malicious_caller_program_id must be a valid Risc0 program") + } + + #[must_use] + pub fn time_locked_transfer() -> Self { + use test_program_methods::TIME_LOCKED_TRANSFER_ELF; + Self::new(TIME_LOCKED_TRANSFER_ELF.to_vec()).unwrap() + } + + #[must_use] + pub fn pinata_cooldown() -> Self { + use test_program_methods::PINATA_COOLDOWN_ELF; + Self::new(PINATA_COOLDOWN_ELF.to_vec()).unwrap() + } + + #[must_use] + pub fn malicious_injector() -> Self { + use test_program_methods::{MALICIOUS_INJECTOR_ELF, MALICIOUS_INJECTOR_ID}; + Self { + id: MALICIOUS_INJECTOR_ID, + elf: MALICIOUS_INJECTOR_ELF.to_vec(), + } + } + + #[must_use] + pub fn malicious_launderer() -> Self { + use test_program_methods::{MALICIOUS_LAUNDERER_ELF, MALICIOUS_LAUNDERER_ID}; + Self { + id: MALICIOUS_LAUNDERER_ID, + elf: MALICIOUS_LAUNDERER_ELF.to_vec(), + } } } @@ -333,7 +514,7 @@ mod tests { ..Account::default() }; let program_output = program - .execute(&[sender, recipient], &instruction_data) + .execute(None, &[sender, recipient], &instruction_data) .unwrap(); let [sender_post, recipient_post] = program_output.post_states.try_into().unwrap(); @@ -346,13 +527,38 @@ mod tests { fn builtin_programs() { let auth_transfer_program = Program::authenticated_transfer_program(); let token_program = Program::token(); + let vault_program = Program::vault(); + let faucet_program = Program::faucet(); let pinata_program = Program::pinata(); assert_eq!(auth_transfer_program.id, AUTHENTICATED_TRANSFER_ID); assert_eq!(auth_transfer_program.elf, AUTHENTICATED_TRANSFER_ELF); assert_eq!(token_program.id, TOKEN_ID); assert_eq!(token_program.elf, TOKEN_ELF); + assert_eq!(vault_program.id, VAULT_ID); + assert_eq!(vault_program.elf, VAULT_ELF); + assert_eq!(faucet_program.id, FAUCET_ID); + assert_eq!(faucet_program.elf, FAUCET_ELF); assert_eq!(pinata_program.id, PINATA_ID); assert_eq!(pinata_program.elf, PINATA_ELF); } + + #[test] + fn builtin_program_ids_match_elfs() { + let cases: &[(&[u8], [u32; 8])] = &[ + (AMM_ELF, AMM_ID), + (AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID), + (ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_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), + ]; + for (elf, expected_id) in cases { + let program = Program::new(elf.to_vec()).unwrap(); + assert_eq!(program.id(), *expected_id); + } + } } diff --git a/nssa/src/program_deployment_transaction/transaction.rs b/nssa/src/program_deployment_transaction/transaction.rs index 90387fe6..3fa775a8 100644 --- a/nssa/src/program_deployment_transaction/transaction.rs +++ b/nssa/src/program_deployment_transaction/transaction.rs @@ -2,9 +2,7 @@ use borsh::{BorshDeserialize, BorshSerialize}; use nssa_core::account::AccountId; use sha2::{Digest as _, digest::FixedOutput as _}; -use crate::{ - V03State, error::NssaError, program::Program, program_deployment_transaction::message::Message, -}; +use crate::program_deployment_transaction::message::Message; #[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct ProgramDeploymentTransaction { @@ -22,19 +20,6 @@ impl ProgramDeploymentTransaction { self.message } - pub(crate) fn validate_and_produce_public_state_diff( - &self, - state: &V03State, - ) -> Result { - // TODO: remove clone - let program = Program::new(self.message.bytecode.clone())?; - if state.programs().contains_key(&program.id()) { - Err(NssaError::ProgramAlreadyExists) - } else { - Ok(program) - } - } - #[must_use] pub fn hash(&self) -> [u8; 32] { let bytes = self.to_bytes(); diff --git a/nssa/src/public_transaction/message.rs b/nssa/src/public_transaction/message.rs index d4838b87..3ab7d74c 100644 --- a/nssa/src/public_transaction/message.rs +++ b/nssa/src/public_transaction/message.rs @@ -4,9 +4,12 @@ use nssa_core::{ program::{InstructionData, ProgramId}, }; use serde::Serialize; +use sha2::{Digest as _, Sha256}; use crate::{AccountId, error::NssaError, program::Program}; +const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Public/\x00\x00\x00\x00\x00\x00\x00"; + #[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct Message { pub program_id: ProgramId, @@ -63,4 +66,74 @@ impl Message { instruction_data, } } + + #[must_use] + pub fn hash(&self) -> [u8; 32] { + let mut bytes = Vec::with_capacity( + PREFIX + .len() + .checked_add(self.to_bytes().len()) + .expect("length overflow"), + ); + bytes.extend_from_slice(PREFIX); + bytes.extend_from_slice(&self.to_bytes()); + + Sha256::digest(bytes).into() + } +} + +#[cfg(test)] +mod tests { + use nssa_core::account::{AccountId, Nonce}; + use sha2::{Digest as _, Sha256}; + + use super::{Message, PREFIX}; + + #[test] + fn hash_public_pinned() { + let msg = Message::new_preserialized( + [1_u32; 8], + vec![AccountId::new([42_u8; 32])], + vec![Nonce(5)], + vec![], + ); + + // program_id: [1_u32; 8], each word as LE u32 + let program_id_bytes: &[u8] = &[ + 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, + 0, 0, 0, + ]; + // account_ids: AccountId([42_u8; 32]) + let account_ids_bytes: &[u8] = &[42_u8; 32]; + // nonces: u32 len=1, then Nonce(5) as LE u128 + let nonces_bytes: &[u8] = &[1, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + let instruction_data_bytes: &[u8] = &[0_u8; 4]; + + let expected_borsh_vec: Vec = [ + program_id_bytes, + &[1_u8, 0, 0, 0], // account_ids len=1 + account_ids_bytes, + nonces_bytes, + instruction_data_bytes, + ] + .concat(); + let expected_borsh: &[u8] = &expected_borsh_vec; + + assert_eq!( + borsh::to_vec(&msg).unwrap(), + expected_borsh, + "`public_transaction::hash()`: expected borsh order has changed" + ); + + let mut preimage = Vec::with_capacity(PREFIX.len() + expected_borsh.len()); + preimage.extend_from_slice(PREFIX); + preimage.extend_from_slice(expected_borsh); + let expected_hash: [u8; 32] = Sha256::digest(&preimage).into(); + + assert_eq!( + msg.hash(), + expected_hash, + "`public_transaction::hash()`: serialization has changed" + ); + } } diff --git a/nssa/src/public_transaction/transaction.rs b/nssa/src/public_transaction/transaction.rs index 6a27c0a4..8ab79535 100644 --- a/nssa/src/public_transaction/transaction.rs +++ b/nssa/src/public_transaction/transaction.rs @@ -1,20 +1,10 @@ -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::HashSet; use borsh::{BorshDeserialize, BorshSerialize}; -use log::debug; -use nssa_core::{ - BlockId, Timestamp, - account::{Account, AccountId, AccountWithMetadata}, - program::{ChainedCall, Claim, DEFAULT_PROGRAM_ID, validate_execution}, -}; +use nssa_core::account::AccountId; use sha2::{Digest as _, digest::FixedOutput as _}; -use crate::{ - V03State, ensure, - error::NssaError, - public_transaction::{Message, WitnessSet}, - state::MAX_NUMBER_CHAINED_CALLS, -}; +use crate::public_transaction::{Message, WitnessSet}; #[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct PublicTransaction { @@ -67,211 +57,6 @@ impl PublicTransaction { hasher.update(&bytes); hasher.finalize_fixed().into() } - - pub(crate) fn validate_and_produce_public_state_diff( - &self, - state: &V03State, - block_id: BlockId, - timestamp: Timestamp, - ) -> Result, NssaError> { - let message = self.message(); - let witness_set = self.witness_set(); - - // All account_ids must be different - ensure!( - message.account_ids.iter().collect::>().len() == message.account_ids.len(), - NssaError::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(), - NssaError::InvalidInput( - "Mismatch between number of nonces and signatures/public keys".into(), - ) - ); - - // Check the signatures are valid - ensure!( - witness_set.is_valid_for(message), - NssaError::InvalidInput("Invalid signature for given message and public key".into()) - ); - - let signer_account_ids = self.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, - NssaError::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![], - }; - - let mut chained_calls = VecDeque::from_iter([(initial_call, None)]); - let mut chain_calls_counter = 0; - - while let Some((chained_call, caller_program_id)) = chained_calls.pop_front() { - ensure!( - chain_calls_counter <= MAX_NUMBER_CHAINED_CALLS, - NssaError::MaxChainedCallsDepthExceeded - ); - - // Check that the `program_id` corresponds to a deployed program - let Some(program) = state.programs().get(&chained_call.program_id) else { - return Err(NssaError::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(&chained_call.pre_states, &chained_call.instruction_data)?; - debug!( - "Program {:?} output: {:?}", - chained_call.program_id, program_output - ); - - let authorized_pdas = nssa_core::program::compute_authorized_pdas( - caller_program_id, - &chained_call.pda_seeds, - ); - - let is_authorized = |account_id: &AccountId| { - signer_account_ids.contains(account_id) || authorized_pdas.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, - NssaError::InvalidProgramBehavior - ); - - // Check that authorization flags are consistent with the provided ones or - // authorized by program through the PDA mechanism - ensure!( - pre.is_authorized == is_authorized(&account_id), - NssaError::InvalidProgramBehavior - ); - } - - // Verify execution corresponds to a well-behaved program. - // See the # Programs section for the definition of the `validate_execution` method. - ensure!( - validate_execution( - &program_output.pre_states, - &program_output.post_states, - chained_call.program_id, - ), - NssaError::InvalidProgramBehavior - ); - - // Verify validity window - ensure!( - program_output.block_validity_window.is_valid_for(block_id) - && program_output - .timestamp_validity_window - .is_valid_for(timestamp), - NssaError::OutOfValidityWindow - ); - - for (i, post) in program_output.post_states.iter_mut().enumerate() { - let Some(claim) = post.required_claim() else { - continue; - }; - // The invoked program can only claim accounts with default program id. - ensure!( - post.account().program_owner == DEFAULT_PROGRAM_ID, - NssaError::InvalidProgramBehavior - ); - - let account_id = program_output.pre_states[i].account_id; - - match claim { - Claim::Authorized => { - // The program can only claim accounts that were authorized by the signer. - ensure!( - is_authorized(&account_id), - NssaError::InvalidProgramBehavior - ); - } - Claim::Pda(seed) => { - // The program can only claim accounts that correspond to the PDAs it is - // authorized to claim. - let pda = AccountId::from((&chained_call.program_id, &seed)); - ensure!(account_id == pda, NssaError::InvalidProgramBehavior); - } - } - - 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()); - } - - for new_call in program_output.chained_calls.into_iter().rev() { - chained_calls.push_front((new_call, 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"); - } - - // Check that all modified uninitialized accounts where claimed - for 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(post) - }) { - ensure!( - post.program_owner != DEFAULT_PROGRAM_ID, - NssaError::InvalidProgramBehavior - ); - } - - Ok(state_diff) - } } #[cfg(test)] @@ -283,6 +68,7 @@ pub mod tests { error::NssaError, program::Program, public_transaction::{Message, WitnessSet}, + validated_state_diff::ValidatedStateDiff, }; fn keys_for_tests() -> (PrivateKey, PrivateKey, AccountId, AccountId) { @@ -296,7 +82,7 @@ pub mod tests { fn state_for_tests() -> V03State { let (_, _, addr1, addr2) = keys_for_tests(); let initial_data = [(addr1, 10000), (addr2, 20000)]; - V03State::new_with_genesis_accounts(&initial_data, &[]) + V03State::new_with_genesis_accounts(&initial_data, vec![], 0) } fn transaction_for_tests() -> PublicTransaction { @@ -391,7 +177,7 @@ pub mod tests { let witness_set = WitnessSet::for_message(&message, &[&key1, &key1]); let tx = PublicTransaction::new(message, witness_set); - let result = tx.validate_and_produce_public_state_diff(&state, 1, 0); + let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0); assert!(matches!(result, Err(NssaError::InvalidInput(_)))); } @@ -411,7 +197,7 @@ pub mod tests { let witness_set = WitnessSet::for_message(&message, &[&key1, &key2]); let tx = PublicTransaction::new(message, witness_set); - let result = tx.validate_and_produce_public_state_diff(&state, 1, 0); + let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0); assert!(matches!(result, Err(NssaError::InvalidInput(_)))); } @@ -432,7 +218,7 @@ pub mod tests { let mut witness_set = WitnessSet::for_message(&message, &[&key1, &key2]); witness_set.signatures_and_public_keys[0].0 = Signature::new_for_tests([1; 64]); let tx = PublicTransaction::new(message, witness_set); - let result = tx.validate_and_produce_public_state_diff(&state, 1, 0); + let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0); assert!(matches!(result, Err(NssaError::InvalidInput(_)))); } @@ -452,7 +238,7 @@ pub mod tests { let witness_set = WitnessSet::for_message(&message, &[&key1, &key2]); let tx = PublicTransaction::new(message, witness_set); - let result = tx.validate_and_produce_public_state_diff(&state, 1, 0); + let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0); assert!(matches!(result, Err(NssaError::InvalidInput(_)))); } @@ -468,7 +254,7 @@ pub mod tests { let witness_set = WitnessSet::for_message(&message, &[&key1, &key2]); let tx = PublicTransaction::new(message, witness_set); - let result = tx.validate_and_produce_public_state_diff(&state, 1, 0); + let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0); assert!(matches!(result, Err(NssaError::InvalidInput(_)))); } } diff --git a/nssa/src/public_transaction/witness_set.rs b/nssa/src/public_transaction/witness_set.rs index d6b32891..1605f488 100644 --- a/nssa/src/public_transaction/witness_set.rs +++ b/nssa/src/public_transaction/witness_set.rs @@ -10,12 +10,12 @@ pub struct WitnessSet { impl WitnessSet { #[must_use] pub fn for_message(message: &Message, private_keys: &[&PrivateKey]) -> Self { - let message_bytes = message.to_bytes(); + let message_hash = message.hash(); let signatures_and_public_keys = private_keys .iter() .map(|&key| { ( - Signature::new(key, &message_bytes), + Signature::new(key, &message_hash), PublicKey::new_from_private_key(key), ) }) @@ -27,9 +27,9 @@ impl WitnessSet { #[must_use] pub fn is_valid_for(&self, message: &Message) -> bool { - let message_bytes = message.to_bytes(); + let message_hash = message.hash(); for (signature, public_key) in self.signatures_and_public_keys() { - if !signature.is_valid_for(&message_bytes, public_key) { + if !signature.is_valid_for(&message_hash, public_key) { return false; } } @@ -75,7 +75,7 @@ mod tests { assert_eq!(witness_set.signatures_and_public_keys.len(), 2); - let message_bytes = message.to_bytes(); + let message_bytes = message.hash(); for ((signature, public_key), expected_public_key) in witness_set .signatures_and_public_keys .into_iter() diff --git a/nssa/src/signature/bip340_test_vectors.rs b/nssa/src/signature/bip340_test_vectors.rs index e316db5e..ac3eb044 100644 --- a/nssa/src/signature/bip340_test_vectors.rs +++ b/nssa/src/signature/bip340_test_vectors.rs @@ -4,7 +4,7 @@ pub struct TestVector { pub seckey: Option, pub pubkey: PublicKey, pub aux_rand: Option<[u8; 32]>, - pub message: Option>, + pub message: [u8; 32], pub signature: Signature, pub verification_result: bool, } @@ -15,18 +15,21 @@ pub struct TestVector { pub fn test_vectors() -> Vec { vec![ TestVector { - seckey: Some(PrivateKey::try_new(hex_to_bytes( - "0000000000000000000000000000000000000000000000000000000000000003", - )).unwrap()), + seckey: Some( + PrivateKey::try_new(hex_to_bytes( + "0000000000000000000000000000000000000000000000000000000000000003", + )) + .unwrap(), + ), pubkey: PublicKey::try_new(hex_to_bytes( "F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9", - )).unwrap(), + )) + .unwrap(), aux_rand: Some(hex_to_bytes::<32>( "0000000000000000000000000000000000000000000000000000000000000000", )), - message: Some( - hex::decode("0000000000000000000000000000000000000000000000000000000000000000") - .unwrap(), + message: hex_to_bytes::<32>( + "0000000000000000000000000000000000000000000000000000000000000000", ), signature: Signature { value: hex_to_bytes( @@ -36,18 +39,21 @@ pub fn test_vectors() -> Vec { verification_result: true, }, TestVector { - seckey: Some(PrivateKey::try_new(hex_to_bytes( - "B7E151628AED2A6ABF7158809CF4F3C762E7160F38B4DA56A784D9045190CFEF", - )).unwrap()), + seckey: Some( + PrivateKey::try_new(hex_to_bytes( + "B7E151628AED2A6ABF7158809CF4F3C762E7160F38B4DA56A784D9045190CFEF", + )) + .unwrap(), + ), pubkey: PublicKey::try_new(hex_to_bytes( "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", - )).unwrap(), + )) + .unwrap(), aux_rand: Some(hex_to_bytes::<32>( "0000000000000000000000000000000000000000000000000000000000000001", )), - message: Some( - hex::decode("243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89") - .unwrap(), + message: hex_to_bytes::<32>( + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", ), signature: Signature { value: hex_to_bytes( @@ -57,18 +63,21 @@ pub fn test_vectors() -> Vec { verification_result: true, }, TestVector { - seckey: Some(PrivateKey::try_new(hex_to_bytes( - "C90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B14E5C9", - )).unwrap()), + seckey: Some( + PrivateKey::try_new(hex_to_bytes( + "C90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B14E5C9", + )) + .unwrap(), + ), pubkey: PublicKey::try_new(hex_to_bytes( "DD308AFEC5777E13121FA72B9CC1B7CC0139715309B086C960E18FD969774EB8", - )).unwrap(), + )) + .unwrap(), aux_rand: Some(hex_to_bytes::<32>( "C87AA53824B4D7AE2EB035A2B5BBBCCC080E76CDC6D1692C4B0B62D798E6D906", )), - message: Some( - hex::decode("7E2D58D8B3BCDF1ABADEC7829054F90DDA9805AAB56C77333024B9D0A508B75C") - .unwrap(), + message: hex_to_bytes::<32>( + "7E2D58D8B3BCDF1ABADEC7829054F90DDA9805AAB56C77333024B9D0A508B75C", ), signature: Signature { value: hex_to_bytes( @@ -78,18 +87,21 @@ pub fn test_vectors() -> Vec { verification_result: true, }, TestVector { - seckey: Some(PrivateKey::try_new(hex_to_bytes( - "0B432B2677937381AEF05BB02A66ECD012773062CF3FA2549E44F58ED2401710", - )).unwrap()), + seckey: Some( + PrivateKey::try_new(hex_to_bytes( + "0B432B2677937381AEF05BB02A66ECD012773062CF3FA2549E44F58ED2401710", + )) + .unwrap(), + ), pubkey: PublicKey::try_new(hex_to_bytes( "25D1DFF95105F5253C4022F628A996AD3A0D95FBF21D468A1B33F8C160D8F517", - )).unwrap(), + )) + .unwrap(), aux_rand: Some(hex_to_bytes::<32>( "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", )), - message: Some( - hex::decode("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF") - .unwrap(), + message: hex_to_bytes::<32>( + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", ), signature: Signature { value: hex_to_bytes( @@ -102,11 +114,11 @@ pub fn test_vectors() -> Vec { seckey: None, pubkey: PublicKey::try_new(hex_to_bytes( "D69C3509BB99E412E68B0FE8544E72837DFA30746D8BE2AA65975F29D22DC7B9", - )).unwrap(), + )) + .unwrap(), aux_rand: None, - message: Some( - hex::decode("4DF3C3F68FCC83B27E9D42C90431A72499F17875C81A599B566C9889B9696703") - .unwrap(), + message: hex_to_bytes::<32>( + "4DF3C3F68FCC83B27E9D42C90431A72499F17875C81A599B566C9889B9696703", ), signature: Signature { value: hex_to_bytes( @@ -122,13 +134,15 @@ pub fn test_vectors() -> Vec { // "EEFDEA4CDB677750A420FEE807EACF21EB9898AE79B9768766E4FAA04A2D4A34", // )).unwrap(), // aux_rand: None, - // message: Some( - // hex::decode("243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89").unwrap(), - // ), + // message: + // + // hex::decode("243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89"). + // unwrap(), ), // signature: Signature { // value: hex_to_bytes( - // "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E17776969E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", - // ), + // + // "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E17776969E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B" + // , ), // }, // verification_result: false, // }, @@ -136,11 +150,11 @@ pub fn test_vectors() -> Vec { seckey: None, pubkey: PublicKey::try_new(hex_to_bytes( "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", - )).unwrap(), + )) + .unwrap(), aux_rand: None, - message: Some( - hex::decode("243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89") - .unwrap(), + message: hex_to_bytes::<32>( + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", ), signature: Signature { value: hex_to_bytes( @@ -153,11 +167,11 @@ pub fn test_vectors() -> Vec { seckey: None, pubkey: PublicKey::try_new(hex_to_bytes( "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", - )).unwrap(), + )) + .unwrap(), aux_rand: None, - message: Some( - hex::decode("243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89") - .unwrap(), + message: hex_to_bytes::<32>( + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", ), signature: Signature { value: hex_to_bytes( @@ -170,11 +184,11 @@ pub fn test_vectors() -> Vec { seckey: None, pubkey: PublicKey::try_new(hex_to_bytes( "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", - )).unwrap(), + )) + .unwrap(), aux_rand: None, - message: Some( - hex::decode("243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89") - .unwrap(), + message: hex_to_bytes::<32>( + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", ), signature: Signature { value: hex_to_bytes( @@ -187,11 +201,11 @@ pub fn test_vectors() -> Vec { seckey: None, pubkey: PublicKey::try_new(hex_to_bytes( "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", - )).unwrap(), + )) + .unwrap(), aux_rand: None, - message: Some( - hex::decode("243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89") - .unwrap(), + message: hex_to_bytes::<32>( + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", ), signature: Signature { value: hex_to_bytes( @@ -204,11 +218,11 @@ pub fn test_vectors() -> Vec { seckey: None, pubkey: PublicKey::try_new(hex_to_bytes( "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", - )).unwrap(), + )) + .unwrap(), aux_rand: None, - message: Some( - hex::decode("243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89") - .unwrap(), + message: hex_to_bytes::<32>( + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", ), signature: Signature { value: hex_to_bytes( @@ -221,11 +235,11 @@ pub fn test_vectors() -> Vec { seckey: None, pubkey: PublicKey::try_new(hex_to_bytes( "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", - )).unwrap(), + )) + .unwrap(), aux_rand: None, - message: Some( - hex::decode("243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89") - .unwrap(), + message: hex_to_bytes::<32>( + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", ), signature: Signature { value: hex_to_bytes( @@ -238,11 +252,11 @@ pub fn test_vectors() -> Vec { seckey: None, pubkey: PublicKey::try_new(hex_to_bytes( "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", - )).unwrap(), + )) + .unwrap(), aux_rand: None, - message: Some( - hex::decode("243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89") - .unwrap(), + message: hex_to_bytes::<32>( + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", ), signature: Signature { value: hex_to_bytes( @@ -255,11 +269,11 @@ pub fn test_vectors() -> Vec { seckey: None, pubkey: PublicKey::try_new(hex_to_bytes( "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", - )).unwrap(), + )) + .unwrap(), aux_rand: None, - message: Some( - hex::decode("243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89") - .unwrap(), + message: hex_to_bytes::<32>( + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", ), signature: Signature { value: hex_to_bytes( @@ -275,90 +289,96 @@ pub fn test_vectors() -> Vec { // "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC30", // )).unwrap(), // aux_rand: None, - // message: Some( - // hex::decode("243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89").unwrap(), - // ), + // message: + // + // hex::decode("243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89"). + // unwrap(), ), // signature: Signature { // value: hex_to_bytes( - // "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E17776969E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B", - // ), + // + // "6CFF5C3BA86C69EA4B7376F31A9BCB4F74C1976089B2D9963DA2E5543E17776969E89B4C5564D00349106B8497785DD7D1D713A8AE82B32FA79D5F7FC407D39B" + // , ), // }, // verification_result: false, // }, - TestVector { - seckey: Some(PrivateKey::try_new(hex_to_bytes( - "0340034003400340034003400340034003400340034003400340034003400340", - )).unwrap()), - pubkey: PublicKey::try_new(hex_to_bytes( - "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", - )).unwrap(), - aux_rand: Some(hex_to_bytes::<32>( - "0000000000000000000000000000000000000000000000000000000000000000", - )), - message: None, - signature: Signature { - value: hex_to_bytes( - "71535DB165ECD9FBBC046E5FFAEA61186BB6AD436732FCCC25291A55895464CF6069CE26BF03466228F19A3A62DB8A649F2D560FAC652827D1AF0574E427AB63", - ), - }, - verification_result: true, - }, - TestVector { - seckey: Some(PrivateKey::try_new(hex_to_bytes( - "0340034003400340034003400340034003400340034003400340034003400340", - )).unwrap()), - pubkey: PublicKey::try_new(hex_to_bytes( - "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", - )).unwrap(), - aux_rand: Some(hex_to_bytes::<32>( - "0000000000000000000000000000000000000000000000000000000000000000", - )), - message: Some(hex::decode("11").unwrap()), - signature: Signature { - value: hex_to_bytes( - "08A20A0AFEF64124649232E0693C583AB1B9934AE63B4C3511F3AE1134C6A303EA3173BFEA6683BD101FA5AA5DBC1996FE7CACFC5A577D33EC14564CEC2BACBF", - ), - }, - verification_result: true, - }, - TestVector { - seckey: Some(PrivateKey::try_new(hex_to_bytes( - "0340034003400340034003400340034003400340034003400340034003400340", - )).unwrap()), - pubkey: PublicKey::try_new(hex_to_bytes( - "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", - )).unwrap(), - aux_rand: Some(hex_to_bytes::<32>( - "0000000000000000000000000000000000000000000000000000000000000000", - )), - message: Some(hex::decode("0102030405060708090A0B0C0D0E0F1011").unwrap()), - signature: Signature { - value: hex_to_bytes( - "5130F39A4059B43BC7CAC09A19ECE52B5D8699D1A71E3C52DA9AFDB6B50AC370C4A482B77BF960F8681540E25B6771ECE1E5A37FD80E5A51897C5566A97EA5A5", - ), - }, - verification_result: true, - }, - TestVector { - seckey: Some(PrivateKey::try_new(hex_to_bytes( - "0340034003400340034003400340034003400340034003400340034003400340", - )).unwrap()), - pubkey: PublicKey::try_new(hex_to_bytes( - "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", - )).unwrap(), - aux_rand: Some(hex_to_bytes::<32>( - "0000000000000000000000000000000000000000000000000000000000000000", - )), - message: Some( - hex::decode("99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999").unwrap(), - ), - signature: Signature { - value: hex_to_bytes( - "403B12B0D8555A344175EA7EC746566303321E5DBFA8BE6F091635163ECA79A8585ED3E3170807E7C03B720FC54C7B23897FCBA0E9D0B4A06894CFD249F22367", - ), - }, - verification_result: true, - }, + // Test with invalid message length (0); valid test for BIP-340 post 2022. + // TestVector { + // seckey: PrivateKey::try_new(hex_to_bytes( + // "0340034003400340034003400340034003400340034003400340034003400340", + // )).unwrap()), + // pubkey: PublicKey::try_new(hex_to_bytes( + // "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", + // )).unwrap(), + // aux_rand: hex_to_bytes::<32>( + // "0000000000000000000000000000000000000000000000000000000000000000", + // )), + // message: None, + // signature: Signature { + // value: hex_to_bytes( + // "71535DB165ECD9FBBC046E5FFAEA61186BB6AD436732FCCC25291A55895464CF6069CE26BF03466228F19A3A62DB8A649F2D560FAC652827D1AF0574E427AB63", + // ), + // }, + // verification_result: true, + // }, + // Test with invalid message length (1); valid test for BIP-340 post 2022. + // TestVector { + // seckey: PrivateKey::try_new(hex_to_bytes( + // "0340034003400340034003400340034003400340034003400340034003400340", + // )).unwrap()), + // pubkey: PublicKey::try_new(hex_to_bytes( + // "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", + // )).unwrap(), + // aux_rand: hex_to_bytes::<32>( + // "0000000000000000000000000000000000000000000000000000000000000000", + // )), + // message: hex::decode("11").unwrap()), + // signature: Signature { + // value: hex_to_bytes( + // "08A20A0AFEF64124649232E0693C583AB1B9934AE63B4C3511F3AE1134C6A303EA3173BFEA6683BD101FA5AA5DBC1996FE7CACFC5A577D33EC14564CEC2BACBF", + // ), + // }, + // verification_result: true, + // }, + // Test with invalid message length (17); valid test for BIP-340 post 2022. + // TestVector { + // seckey: PrivateKey::try_new(hex_to_bytes( + // "0340034003400340034003400340034003400340034003400340034003400340", + // )).unwrap()), + // pubkey: PublicKey::try_new(hex_to_bytes( + // "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", + // )).unwrap(), + // aux_rand: hex_to_bytes::<32>( + // "0000000000000000000000000000000000000000000000000000000000000000", + // )), + // message: hex::decode("0102030405060708090A0B0C0D0E0F1011").unwrap()), + // signature: Signature { + // value: hex_to_bytes( + // "5130F39A4059B43BC7CAC09A19ECE52B5D8699D1A71E3C52DA9AFDB6B50AC370C4A482B77BF960F8681540E25B6771ECE1E5A37FD80E5A51897C5566A97EA5A5", + // ), + // }, + // erification_result: true, + // }, + // Test with invalid message length (100); valid test for BIP-340 post 2022. + // TestVector { + // seckey: PrivateKey::try_new(hex_to_bytes( + // "0340034003400340034003400340034003400340034003400340034003400340", + // )).unwrap()), + // pubkey: PublicKey::try_new(hex_to_bytes( + // "778CAA53B4393AC467774D09497A87224BF9FAB6F6E68B23086497324D6FD117", + // )).unwrap(), + // aux_rand: hex_to_bytes::<32>( + // "0000000000000000000000000000000000000000000000000000000000000000", + // )), + // message: + // hex::decode("99999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999").unwrap(), + // ), + // signature: Signature { + // value: hex_to_bytes( + // "403B12B0D8555A344175EA7EC746566303321E5DBFA8BE6F091635163ECA79A8585ED3E3170807E7C03B720FC54C7B23897FCBA0E9D0B4A06894CFD249F22367", + // ), + // }, + // verification_result: true, + // }, ] } diff --git a/nssa/src/signature/mod.rs b/nssa/src/signature/mod.rs index 3a594da6..a46b1ff5 100644 --- a/nssa/src/signature/mod.rs +++ b/nssa/src/signature/mod.rs @@ -36,8 +36,10 @@ impl FromStr for Signature { } impl Signature { + /// This function expects the incoming message to be prehashed to be pre-2022 BIP-340/Keycard + /// compatible. #[must_use] - pub fn new(key: &PrivateKey, message: &[u8]) -> Self { + pub fn new(key: &PrivateKey, message: &[u8; 32]) -> Self { let mut aux_random = [0_u8; 32]; OsRng.fill_bytes(&mut aux_random); Self::new_with_aux_random(key, message, aux_random) @@ -45,14 +47,14 @@ impl Signature { pub(crate) fn new_with_aux_random( key: &PrivateKey, - message: &[u8], + message: &[u8; 32], aux_random: [u8; 32], ) -> Self { let value = { let signing_key = k256::schnorr::SigningKey::from_bytes(key.value()) .expect("Expect valid signing key"); signing_key - .sign_raw(message, &aux_random) + .sign_prehash_with_aux_rand(message, &aux_random) .expect("Expect to produce a valid signature") .to_bytes() }; @@ -61,7 +63,7 @@ impl Signature { } #[must_use] - pub fn is_valid_for(&self, bytes: &[u8], public_key: &PublicKey) -> bool { + pub fn is_valid_for(&self, bytes: &[u8; 32], public_key: &PublicKey) -> bool { let Ok(pk) = k256::schnorr::VerifyingKey::from_bytes(public_key.value()) else { return false; }; @@ -97,9 +99,8 @@ mod tests { let Some(aux_random) = test_vector.aux_rand else { continue; }; - let Some(message) = test_vector.message else { - continue; - }; + let message = test_vector.message; + if !test_vector.verification_result { continue; } @@ -114,7 +115,7 @@ mod tests { #[test] fn signature_verification_from_bip340_test_vectors() { for (i, test_vector) in bip340_test_vectors::test_vectors().into_iter().enumerate() { - let message = test_vector.message.unwrap_or(vec![]); + let message = test_vector.message; let expected_result = test_vector.verification_result; let result = test_vector diff --git a/nssa/src/state.rs b/nssa/src/state.rs index ec37884e..0f38f9f3 100644 --- a/nssa/src/state.rs +++ b/nssa/src/state.rs @@ -1,6 +1,11 @@ use std::collections::{BTreeSet, HashMap, HashSet}; use borsh::{BorshDeserialize, BorshSerialize}; +use clock_core::ClockAccountData; +pub use clock_core::{ + CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID, + CLOCK_PROGRAM_ACCOUNT_IDS, +}; use nssa_core::{ BlockId, Commitment, CommitmentSetDigest, DUMMY_COMMITMENT, MembershipProof, Nullifier, Timestamp, @@ -9,10 +14,13 @@ use nssa_core::{ }; use crate::{ - error::NssaError, merkle_tree::MerkleTree, - privacy_preserving_transaction::PrivacyPreservingTransaction, program::Program, + error::NssaError, + 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; @@ -73,7 +81,7 @@ impl NullifierSet { Self(BTreeSet::new()) } - fn extend(&mut self, new_nullifiers: Vec) { + fn extend(&mut self, new_nullifiers: &[Nullifier]) { self.0.extend(new_nullifiers); } @@ -114,14 +122,36 @@ pub struct V03State { programs: HashMap, } +impl Default for V03State { + fn default() -> Self { + let faucet_account_id = system_faucet_account_id(); + let faucet_account = system_faucet_account(); + let mut public_state = HashMap::new(); + public_state.insert(faucet_account_id, faucet_account); + + Self { + public_state, + private_state: (CommitmentSet::with_capacity(32), NullifierSet::new()), + programs: HashMap::new(), + } + } +} + impl V03State { + #[must_use] + pub fn new() -> Self { + Self::default() + } + #[must_use] pub fn new_with_genesis_accounts( initial_data: &[(AccountId, u128)], - initial_commitments: &[nssa_core::Commitment], + initial_private_accounts: Vec<(Commitment, Nullifier)>, + genesis_timestamp: nssa_core::Timestamp, ) -> Self { + let faucet_account_id = system_faucet_account_id(); let authenticated_transfer_program = Program::authenticated_transfer_program(); - let public_state = initial_data + let mut public_state: HashMap<_, _> = initial_data .iter() .copied() .map(|(account_id, balance)| { @@ -133,52 +163,98 @@ impl V03State { (account_id, account) }) .collect(); + let faucet_account = system_faucet_account(); + public_state.insert(faucet_account_id, faucet_account); - let mut private_state = CommitmentSet::with_capacity(32); - private_state.extend(&[DUMMY_COMMITMENT]); - private_state.extend(initial_commitments); + let mut commitment_set = CommitmentSet::with_capacity(32); + commitment_set.extend(&[DUMMY_COMMITMENT]); + let (commitments, nullifiers): (Vec, Vec) = + initial_private_accounts.into_iter().unzip(); + commitment_set.extend(&commitments); + let mut nullifier_set = NullifierSet::new(); + nullifier_set.extend(&nullifiers); + let private_state = (commitment_set, nullifier_set); let mut this = Self { public_state, - private_state: (private_state, NullifierSet::new()), + private_state, programs: HashMap::new(), }; + this.insert_program(Program::clock()); + this.insert_clock_accounts(genesis_timestamp); + this.insert_program(Program::authenticated_transfer_program()); this.insert_program(Program::token()); this.insert_program(Program::amm()); this.insert_program(Program::ata()); + this.insert_program(Program::vault()); + this.insert_program(Program::faucet()); this } + fn insert_clock_accounts(&mut self, genesis_timestamp: nssa_core::Timestamp) { + let data = ClockAccountData { + block_id: 0, + timestamp: genesis_timestamp, + } + .to_bytes(); + let clock_program_id = Program::clock().id(); + for account_id in CLOCK_PROGRAM_ACCOUNT_IDS { + self.public_state.insert( + account_id, + Account { + program_owner: clock_program_id, + data: data + .clone() + .try_into() + .expect("Clock account data should fit within accounts data"), + ..Account::default() + }, + ); + } + } + 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<(), NssaError> { - let state_diff = tx.validate_and_produce_public_state_diff(self, block_id, timestamp)?; - - #[expect( - clippy::iter_over_hash_type, - reason = "Iteration order doesn't matter here" - )] - for (account_id, post) in state_diff { - let current_account = self.get_account_by_id_mut(account_id); - - *current_account = post; - } - - for account_id in tx.signer_account_ids() { - let current_account = self.get_account_by_id_mut(account_id); - current_account.nonce.public_account_nonce_increment(); - } - + let diff = ValidatedStateDiff::from_public_transaction(tx, self, block_id, timestamp)?; + self.apply_state_diff(diff); Ok(()) } @@ -188,40 +264,9 @@ impl V03State { block_id: BlockId, timestamp: Timestamp, ) -> Result<(), NssaError> { - // 1. Verify the transaction satisfies acceptance criteria - let public_state_diff = - tx.validate_and_produce_public_state_diff(self, block_id, timestamp)?; - - let message = tx.message(); - - // 2. Add new commitments - self.private_state.0.extend(&message.new_commitments); - - // 3. Add new nullifiers - let new_nullifiers = message - .new_nullifiers - .iter() - .cloned() - .map(|(nullifier, _)| nullifier) - .collect::>(); - self.private_state.1.extend(new_nullifiers); - - // 4. Update public accounts - #[expect( - clippy::iter_over_hash_type, - reason = "Iteration order doesn't matter here" - )] - for (account_id, post) in public_state_diff { - let current_account = self.get_account_by_id_mut(account_id); - *current_account = post; - } - - // 5. Increment nonces for public signers - for account_id in tx.signer_account_ids() { - let current_account = self.get_account_by_id_mut(account_id); - current_account.nonce.public_account_nonce_increment(); - } - + let diff = + ValidatedStateDiff::from_privacy_preserving_transaction(tx, self, block_id, timestamp)?; + self.apply_state_diff(diff); Ok(()) } @@ -229,8 +274,8 @@ impl V03State { &mut self, tx: &ProgramDeploymentTransaction, ) -> Result<(), NssaError> { - let program = tx.validate_and_produce_public_state_diff(self)?; - self.insert_program(program); + let diff = ValidatedStateDiff::from_program_deployment_transaction(tx, self)?; + self.apply_state_diff(diff); Ok(()) } @@ -331,6 +376,19 @@ impl V03State { } } +fn system_faucet_account() -> Account { + Account { + program_owner: Program::authenticated_transfer_program().id(), + balance: u128::MAX, + ..Account::default() + } +} + +#[must_use] +pub fn system_faucet_account_id() -> AccountId { + faucet_core::compute_faucet_account_id(Program::faucet().id()) +} + #[cfg(test)] pub mod tests { #![expect( @@ -341,17 +399,21 @@ pub mod tests { use std::collections::HashMap; + use authenticated_transfer_core::Instruction as AuthTransferInstruction; use nssa_core::{ - BlockId, Commitment, Nullifier, NullifierPublicKey, NullifierSecretKey, SharedSecretKey, - Timestamp, + BlockId, Commitment, InputAccountIdentity, Nullifier, NullifierPublicKey, + NullifierSecretKey, SharedSecretKey, Timestamp, account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data}, encryption::{EphemeralPublicKey, Scalar, ViewingPublicKey}, - program::{BlockValidityWindow, PdaSeed, ProgramId, TimestampValidityWindow}, + program::{ + BlockValidityWindow, ExecutionValidationError, PdaSeed, ProgramId, + TimestampValidityWindow, WrappedBalanceSum, + }, }; use crate::{ PublicKey, PublicTransaction, V03State, - error::NssaError, + error::{InvalidProgramBehaviorError, NssaError}, execute_and_prove, privacy_preserving_transaction::{ PrivacyPreservingTransaction, @@ -362,7 +424,11 @@ pub mod tests { program::Program, public_transaction, signature::PrivateKey, - state::MAX_NUMBER_CHAINED_CALLS, + state::{ + CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID, + CLOCK_PROGRAM_ACCOUNT_IDS, MAX_NUMBER_CHAINED_CALLS, system_faucet_account, + }, + system_faucet_account_id, }; impl V03State { @@ -382,6 +448,12 @@ pub mod tests { self.insert_program(Program::claimer()); self.insert_program(Program::changer_claimer()); self.insert_program(Program::validity_window()); + self.insert_program(Program::flash_swap_initiator()); + self.insert_program(Program::flash_swap_callback()); + self.insert_program(Program::malicious_self_program_id()); + self.insert_program(Program::malicious_caller_program_id()); + self.insert_program(Program::time_locked_transfer()); + self.insert_program(Program::pinata_cooldown()); self } @@ -427,7 +499,8 @@ pub mod tests { #[must_use] pub fn with_private_account(mut self, keys: &TestPrivateKeys, account: &Account) -> Self { - let commitment = Commitment::new(&keys.npk(), account); + 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 } @@ -458,6 +531,28 @@ pub mod tests { } } + // ── 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 transfer_transaction( from: AccountId, from_key: &PrivateKey, @@ -470,13 +565,35 @@ pub mod tests { let account_ids = vec![from, to]; let nonces = vec![Nonce(from_nonce), Nonce(to_nonce)]; let program_id = Program::authenticated_transfer_program().id(); - let message = - public_transaction::Message::try_new(program_id, account_ids, nonces, balance).unwrap(); + let message = public_transaction::Message::try_new( + program_id, + account_ids, + nonces, + AuthTransferInstruction::Transfer { amount: 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_with_genesis() { let key1 = PrivateKey::try_new([1; 32]).unwrap(); @@ -485,6 +602,7 @@ pub mod tests { let addr2 = AccountId::from(&PublicKey::new_from_private_key(&key2)); let initial_data = [(addr1, 100_u128), (addr2, 151_u128)]; let authenticated_transfers_program = Program::authenticated_transfer_program(); + let clock_program = Program::clock(); let expected_public_state = { let mut this = HashMap::new(); this.insert( @@ -503,6 +621,17 @@ pub mod tests { ..Account::default() }, ); + this.insert(system_faucet_account_id(), system_faucet_account()); + for account_id in CLOCK_PROGRAM_ACCOUNT_IDS { + this.insert( + account_id, + Account { + program_owner: clock_program.id(), + data: [0_u8; 16].to_vec().try_into().unwrap(), + ..Account::default() + }, + ); + } this }; let expected_builtin_programs = { @@ -511,21 +640,54 @@ pub mod tests { authenticated_transfers_program.id(), authenticated_transfers_program, ); + this.insert(clock_program.id(), clock_program); this.insert(Program::token().id(), Program::token()); this.insert(Program::amm().id(), Program::amm()); this.insert(Program::ata().id(), Program::ata()); + this.insert(Program::vault().id(), Program::vault()); + this.insert(Program::faucet().id(), Program::faucet()); this }; - let state = V03State::new_with_genesis_accounts(&initial_data, &[]); + let state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0); assert_eq!(state.public_state, expected_public_state); assert_eq!(state.programs, expected_builtin_programs); } + #[test] + fn new_with_genesis_includes_nullifiers_for_private_accounts() { + let keys1 = test_private_account_keys_1(); + let keys2 = test_private_account_keys_2(); + + let account = Account { + balance: 100, + program_owner: Program::authenticated_transfer_program().id(), + ..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_genesis_accounts(&[], initial_private_accounts, 0); + + 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_with_genesis_accounts(&[], &[]); + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); let program_to_insert = Program::simple_balance_transfer(); let program_id = program_to_insert.id(); assert!(!state.programs.contains_key(&program_id)); @@ -540,7 +702,7 @@ pub mod tests { let key = PrivateKey::try_new([1; 32]).unwrap(); let account_id = AccountId::from(&PublicKey::new_from_private_key(&key)); let initial_data = [(account_id, 100_u128)]; - let state = V03State::new_with_genesis_accounts(&initial_data, &[]); + let state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0); let expected_account = &state.public_state[&account_id]; let account = state.get_account_by_id(account_id); @@ -551,7 +713,7 @@ pub mod tests { #[test] fn get_account_by_account_id_default_account() { let addr2 = AccountId::new([0; 32]); - let state = V03State::new_with_genesis_accounts(&[], &[]); + let state = V03State::new_with_genesis_accounts(&[], vec![], 0); let expected_account = Account::default(); let account = state.get_account_by_id(addr2); @@ -561,7 +723,7 @@ pub mod tests { #[test] fn builtin_programs_getter() { - let state = V03State::new_with_genesis_accounts(&[], &[]); + let state = V03State::new_with_genesis_accounts(&[], vec![], 0); let builtin_programs = state.programs(); @@ -573,7 +735,7 @@ pub mod tests { let key = PrivateKey::try_new([1; 32]).unwrap(); let account_id = AccountId::from(&PublicKey::new_from_private_key(&key)); let initial_data = [(account_id, 100)]; - let mut state = V03State::new_with_genesis_accounts(&initial_data, &[]); + let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0); let from = account_id; let to_key = PrivateKey::try_new([2; 32]).unwrap(); let to = AccountId::from(&PublicKey::new_from_private_key(&to_key)); @@ -594,7 +756,7 @@ pub mod tests { let key = PrivateKey::try_new([1; 32]).unwrap(); let account_id = AccountId::from(&PublicKey::new_from_private_key(&key)); let initial_data = [(account_id, 100)]; - let mut state = V03State::new_with_genesis_accounts(&initial_data, &[]); + let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0); let from = account_id; let from_key = key; let to_key = PrivateKey::try_new([2; 32]).unwrap(); @@ -619,7 +781,7 @@ pub mod tests { 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, 100), (account_id2, 200)]; - let mut state = V03State::new_with_genesis_accounts(&initial_data, &[]); + let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0); let from = account_id2; let from_key = key2; let to = account_id1; @@ -643,7 +805,7 @@ pub mod tests { let key2 = PrivateKey::try_new([2; 32]).unwrap(); let account_id2 = AccountId::from(&PublicKey::new_from_private_key(&key2)); let initial_data = [(account_id1, 100)]; - let mut state = V03State::new_with_genesis_accounts(&initial_data, &[]); + let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0); let key3 = PrivateKey::try_new([3; 32]).unwrap(); let account_id3 = AccountId::from(&PublicKey::new_from_private_key(&key3)); let balance_to_move = 5; @@ -678,12 +840,156 @@ pub mod tests { assert_eq!(state.get_account_by_id(account_id3).nonce, Nonce(1)); } + fn clock_transaction(timestamp: nssa_core::Timestamp) -> PublicTransaction { + let message = public_transaction::Message::try_new( + Program::clock().id(), + CLOCK_PROGRAM_ACCOUNT_IDS.to_vec(), + vec![], + timestamp, + ) + .unwrap(); + PublicTransaction::new( + message, + public_transaction::WitnessSet::from_raw_parts(vec![]), + ) + } + + fn clock_account_data(state: &V03State, account_id: AccountId) -> (u64, nssa_core::Timestamp) { + let data = state.get_account_by_id(account_id).data.into_inner(); + let parsed = clock_core::ClockAccountData::from_bytes(&data); + (parsed.block_id, parsed.timestamp) + } + + #[test] + fn clock_genesis_state_has_zero_block_id_and_genesis_timestamp() { + let genesis_timestamp = 1_000_000_u64; + let state = V03State::new_with_genesis_accounts(&[], vec![], genesis_timestamp); + + let (block_id, timestamp) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID); + + assert_eq!(block_id, 0); + assert_eq!(timestamp, genesis_timestamp); + } + + #[test] + fn clock_invocation_increments_block_id() { + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); + + let tx = clock_transaction(1234); + state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + + let (block_id, _) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID); + assert_eq!(block_id, 1); + } + + #[test] + fn clock_invocation_stores_timestamp_from_instruction() { + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); + let block_timestamp = 1_700_000_000_000_u64; + + let tx = clock_transaction(block_timestamp); + state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + + let (_, timestamp) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID); + assert_eq!(timestamp, block_timestamp); + } + + #[test] + fn clock_invocation_sequence_correctly_increments_block_id() { + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); + + for expected_block_id in 1_u64..=5 { + let tx = clock_transaction(expected_block_id * 1000); + state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + + let (block_id, timestamp) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID); + assert_eq!(block_id, expected_block_id); + assert_eq!(timestamp, expected_block_id * 1000); + } + } + + #[test] + fn clock_10_account_not_updated_when_block_id_not_multiple_of_10() { + let genesis_timestamp = 0_u64; + let mut state = V03State::new_with_genesis_accounts(&[], vec![], genesis_timestamp); + + // Run 9 clock ticks (block_ids 1..=9), none of which are multiples of 10. + for tick in 1_u64..=9 { + let tx = clock_transaction(tick * 1000); + state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + } + + let (block_id_10, timestamp_10) = clock_account_data(&state, CLOCK_10_PROGRAM_ACCOUNT_ID); + // The 10-block account should still reflect genesis state. + assert_eq!(block_id_10, 0); + assert_eq!(timestamp_10, genesis_timestamp); + } + + #[test] + fn clock_10_account_updated_when_block_id_is_multiple_of_10() { + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); + + // Run 10 clock ticks so block_id reaches 10. + for tick in 1_u64..=10 { + let tx = clock_transaction(tick * 1000); + state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + } + + let (block_id_1, timestamp_1) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID); + let (block_id_10, timestamp_10) = clock_account_data(&state, CLOCK_10_PROGRAM_ACCOUNT_ID); + assert_eq!(block_id_1, 10); + assert_eq!(block_id_10, 10); + assert_eq!(timestamp_10, timestamp_1); + } + + #[test] + fn clock_50_account_only_updated_at_multiples_of_50() { + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); + + // After 49 ticks the 50-block account should be unchanged. + for tick in 1_u64..=49 { + let tx = clock_transaction(tick * 1000); + state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + } + let (block_id_50, _) = clock_account_data(&state, CLOCK_50_PROGRAM_ACCOUNT_ID); + assert_eq!(block_id_50, 0); + + // Tick 50 — now the 50-block account should update. + let tx = clock_transaction(50 * 1000); + state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + let (block_id_50, timestamp_50) = clock_account_data(&state, CLOCK_50_PROGRAM_ACCOUNT_ID); + assert_eq!(block_id_50, 50); + assert_eq!(timestamp_50, 50 * 1000); + } + + #[test] + fn all_three_clock_accounts_updated_at_multiple_of_50() { + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); + + // Advance to block 50 (a multiple of both 10 and 50). + for tick in 1_u64..=50 { + let tx = clock_transaction(tick * 1000); + state.transition_from_public_transaction(&tx, 0, 0).unwrap(); + } + + let (block_id_1, ts_1) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID); + let (block_id_10, ts_10) = clock_account_data(&state, CLOCK_10_PROGRAM_ACCOUNT_ID); + let (block_id_50, ts_50) = clock_account_data(&state, CLOCK_50_PROGRAM_ACCOUNT_ID); + + assert_eq!(block_id_1, 50); + assert_eq!(block_id_10, 50); + assert_eq!(block_id_50, 50); + assert_eq!(ts_1, ts_10); + assert_eq!(ts_1, ts_50); + } + #[test] fn program_should_fail_if_modifies_nonces() { - let initial_data = [(AccountId::new([1; 32]), 100)]; + let account_id = AccountId::new([1; 32]); + let initial_data = [(account_id, 100)]; let mut state = - V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs(); - let account_ids = vec![AccountId::new([1; 32])]; + V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs(); + let account_ids = vec![account_id]; let program_id = Program::nonce_changer_program().id(); let message = public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap(); @@ -692,14 +998,21 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::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 initial_data = [(AccountId::new([1; 32]), 100)]; let mut state = - V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs(); + V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs(); let account_ids = vec![AccountId::new([1; 32])]; let program_id = Program::extra_output_program().id(); let message = @@ -709,14 +1022,24 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::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 initial_data = [(AccountId::new([1; 32]), 100)]; let mut state = - V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs(); + V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs(); let account_ids = vec![AccountId::new([1; 32]), AccountId::new([2; 32])]; let program_id = Program::missing_output_program().id(); let message = @@ -726,14 +1049,24 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::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]), 0)]; let mut state = - V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs(); + V03State::new_with_genesis_accounts(&initial_data, vec![], 0).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 @@ -750,13 +1083,18 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::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 = []; - let mut state = V03State::new_with_genesis_accounts(&initial_data, &[]) + let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0) .with_test_programs() .with_non_default_accounts_but_default_program_owners(); let account_id = AccountId::new([255; 32]); @@ -774,13 +1112,18 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::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 = []; - let mut state = V03State::new_with_genesis_accounts(&initial_data, &[]) + let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0) .with_test_programs() .with_non_default_accounts_but_default_program_owners(); let account_id = AccountId::new([254; 32]); @@ -798,13 +1141,18 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::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 = []; - let mut state = V03State::new_with_genesis_accounts(&initial_data, &[]) + let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0) .with_test_programs() .with_non_default_accounts_but_default_program_owners(); let account_id = AccountId::new([253; 32]); @@ -822,16 +1170,21 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::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 initial_data = [(AccountId::new([1; 32]), 100)]; - let mut state = - V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs(); let sender_account_id = AccountId::new([1; 32]); let receiver_account_id = AccountId::new([2; 32]); + let initial_data = [(sender_account_id, 100)]; + let mut state = + V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs(); let balance_to_move: u128 = 1; let program_id = Program::simple_balance_transfer().id(); assert_ne!( @@ -850,13 +1203,18 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::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 = []; - let mut state = V03State::new_with_genesis_accounts(&initial_data, &[]) + let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0) .with_test_programs() .with_non_default_accounts_but_default_program_owners(); let account_id = AccountId::new([255; 32]); @@ -875,14 +1233,19 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::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 = []; let mut state = - V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs(); + V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs(); let account_id = AccountId::new([1; 32]); let program_id = Program::minter().id(); @@ -891,15 +1254,20 @@ pub mod tests { 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); + let result = state.transition_from_public_transaction(&tx, 2, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::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 = []; - let mut state = V03State::new_with_genesis_accounts(&initial_data, &[]) + let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0) .with_test_programs() .with_account_owned_by_burner_program(); let program_id = Program::burner().id(); @@ -920,9 +1288,14 @@ pub mod tests { .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); + let result = state.transition_from_public_transaction(&tx, 2, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::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 { @@ -931,6 +1304,12 @@ pub mod tests { } } + 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], @@ -959,19 +1338,27 @@ pub mod tests { let sender_nonce = sender.account.nonce; - let recipient = AccountWithMetadata::new(Account::default(), false, &recipient_keys.npk()); + let recipient = + AccountWithMetadata::new(Account::default(), false, (&recipient_keys.npk(), 0)); let esk = [3; 32]; - let shared_secret = SharedSecretKey::new(&esk, &recipient_keys.vpk()); + let shared_secret = SharedSecretKey::new(esk, &recipient_keys.vpk()); let epk = EphemeralPublicKey::from_scalar(esk); let (output, proof) = circuit::execute_and_prove( vec![sender, recipient], - Program::serialize_instruction(balance_to_move).unwrap(), - vec![0, 2], - vec![(recipient_keys.npk(), shared_secret)], - vec![], - vec![None], + Program::serialize_instruction(AuthTransferInstruction::Transfer { + amount: balance_to_move, + }) + .unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateUnauthorized { + npk: recipient_keys.npk(), + ssk: shared_secret, + identifier: 0, + }, + ], &Program::authenticated_transfer_program().into(), ) .unwrap(); @@ -996,30 +1383,45 @@ pub mod tests { state: &V03State, ) -> PrivacyPreservingTransaction { let program = Program::authenticated_transfer_program(); - let sender_commitment = Commitment::new(&sender_keys.npk(), sender_private_account); - let sender_pre = - AccountWithMetadata::new(sender_private_account.clone(), true, &sender_keys.npk()); + 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()); + AccountWithMetadata::new(Account::default(), false, (&recipient_keys.npk(), 0)); let esk_1 = [3; 32]; - let shared_secret_1 = SharedSecretKey::new(&esk_1, &sender_keys.vpk()); + let shared_secret_1 = SharedSecretKey::new(esk_1, &sender_keys.vpk()); let epk_1 = EphemeralPublicKey::from_scalar(esk_1); let esk_2 = [3; 32]; - let shared_secret_2 = SharedSecretKey::new(&esk_2, &recipient_keys.vpk()); + let shared_secret_2 = SharedSecretKey::new(esk_2, &recipient_keys.vpk()); let epk_2 = EphemeralPublicKey::from_scalar(esk_2); let (output, proof) = circuit::execute_and_prove( vec![sender_pre, recipient_pre], - Program::serialize_instruction(balance_to_move).unwrap(), - vec![1, 2], + Program::serialize_instruction(AuthTransferInstruction::Transfer { + amount: balance_to_move, + }) + .unwrap(), vec![ - (sender_keys.npk(), shared_secret_1), - (recipient_keys.npk(), shared_secret_2), + InputAccountIdentity::PrivateAuthorizedUpdate { + 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 { + npk: recipient_keys.npk(), + ssk: shared_secret_2, + identifier: 0, + }, ], - vec![sender_keys.nsk], - vec![state.get_proof_for_commitment(&sender_commitment), None], &program.into(), ) .unwrap(); @@ -1048,9 +1450,13 @@ pub mod tests { state: &V03State, ) -> PrivacyPreservingTransaction { let program = Program::authenticated_transfer_program(); - let sender_commitment = Commitment::new(&sender_keys.npk(), sender_private_account); - let sender_pre = - AccountWithMetadata::new(sender_private_account.clone(), true, &sender_keys.npk()); + 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, @@ -1058,16 +1464,26 @@ pub mod tests { ); let esk = [3; 32]; - let shared_secret = SharedSecretKey::new(&esk, &sender_keys.vpk()); + let shared_secret = SharedSecretKey::new(esk, &sender_keys.vpk()); let epk = EphemeralPublicKey::from_scalar(esk); let (output, proof) = circuit::execute_and_prove( vec![sender_pre, recipient_pre], - Program::serialize_instruction(balance_to_move).unwrap(), - vec![1, 0], - vec![(sender_keys.npk(), shared_secret)], - vec![sender_keys.nsk], - vec![state.get_proof_for_commitment(&sender_commitment)], + Program::serialize_instruction(AuthTransferInstruction::Transfer { + amount: balance_to_move, + }) + .unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + 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(); @@ -1091,7 +1507,7 @@ pub mod tests { let recipient_keys = test_private_account_keys_1(); let mut state = - V03State::new_with_genesis_accounts(&[(sender_keys.account_id(), 200)], &[]); + V03State::new_with_genesis_accounts(&[(sender_keys.account_id(), 200)], vec![], 0); let balance_to_move = 37; @@ -1139,7 +1555,7 @@ pub mod tests { }; let recipient_keys = test_private_account_keys_2(); - let mut state = V03State::new_with_genesis_accounts(&[], &[]) + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0) .with_private_account(&sender_keys, &sender_private_account); let balance_to_move = 37; @@ -1152,8 +1568,10 @@ pub mod tests { &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_keys.npk(), + &sender_account_id, &Account { program_owner: Program::authenticated_transfer_program().id(), nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk), @@ -1162,15 +1580,15 @@ pub mod tests { }, ); - let sender_pre_commitment = Commitment::new(&sender_keys.npk(), &sender_private_account); + 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_keys.npk(), + &recipient_account_id, &Account { program_owner: Program::authenticated_transfer_program().id(), - nonce: Nonce::private_account_nonce_init(&recipient_keys.npk()), + nonce: Nonce::private_account_nonce_init(&recipient_account_id), balance: balance_to_move, ..Account::default() }, @@ -1208,7 +1626,8 @@ pub mod tests { let recipient_initial_balance = 400; let mut state = V03State::new_with_genesis_accounts( &[(recipient_keys.account_id(), recipient_initial_balance)], - &[], + vec![], + 0, ) .with_private_account(&sender_keys, &sender_private_account); @@ -1228,8 +1647,9 @@ pub mod tests { &state, ); + let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0); let expected_new_commitment = Commitment::new( - &sender_keys.npk(), + &sender_account_id, &Account { program_owner: Program::authenticated_transfer_program().id(), nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk), @@ -1238,7 +1658,7 @@ pub mod tests { }, ); - let sender_pre_commitment = Commitment::new(&sender_keys.npk(), &sender_private_account); + 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); @@ -1277,10 +1697,7 @@ pub mod tests { let result = execute_and_prove( vec![public_account], Program::serialize_instruction(10_u128).unwrap(), - vec![0], - vec![], - vec![], - vec![], + vec![InputAccountIdentity::Public], &program.into(), ); @@ -1303,10 +1720,7 @@ pub mod tests { let result = execute_and_prove( vec![public_account], Program::serialize_instruction(10_u128).unwrap(), - vec![0], - vec![], - vec![], - vec![], + vec![InputAccountIdentity::Public], &program.into(), ); @@ -1329,10 +1743,7 @@ pub mod tests { let result = execute_and_prove( vec![public_account], Program::serialize_instruction(()).unwrap(), - vec![0], - vec![], - vec![], - vec![], + vec![InputAccountIdentity::Public], &program.into(), ); @@ -1355,10 +1766,7 @@ pub mod tests { let result = execute_and_prove( vec![public_account], Program::serialize_instruction(vec![0]).unwrap(), - vec![0], - vec![], - vec![], - vec![], + vec![InputAccountIdentity::Public], &program.into(), ); @@ -1389,10 +1797,7 @@ pub mod tests { let result = execute_and_prove( vec![public_account], Program::serialize_instruction(large_data).unwrap(), - vec![0], - vec![], - vec![], - vec![], + vec![InputAccountIdentity::Public], &program.into(), ); @@ -1415,10 +1820,7 @@ pub mod tests { let result = execute_and_prove( vec![public_account], Program::serialize_instruction(()).unwrap(), - vec![0], - vec![], - vec![], - vec![], + vec![InputAccountIdentity::Public], &program.into(), ); @@ -1450,10 +1852,7 @@ pub mod tests { let result = execute_and_prove( vec![public_account_1, public_account_2], Program::serialize_instruction(()).unwrap(), - vec![0, 0], - vec![], - vec![], - vec![], + vec![InputAccountIdentity::Public, InputAccountIdentity::Public], &program.into(), ); @@ -1476,10 +1875,7 @@ pub mod tests { let result = execute_and_prove( vec![public_account], Program::serialize_instruction(()).unwrap(), - vec![0], - vec![], - vec![], - vec![], + vec![InputAccountIdentity::Public], &program.into(), ); @@ -1511,10 +1907,7 @@ pub mod tests { let result = execute_and_prove( vec![public_account_1, public_account_2], Program::serialize_instruction(10_u128).unwrap(), - vec![0, 0], - vec![], - vec![], - vec![], + vec![InputAccountIdentity::Public, InputAccountIdentity::Public], &program.into(), ); @@ -1543,170 +1936,11 @@ pub mod tests { AccountId::new([1; 32]), ); - // Setting only one visibility mask for a circuit execution with two pre_state accounts. - let visibility_mask = [0]; + // 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(), - visibility_mask.to_vec(), - vec![], - vec![], - vec![], - &program.into(), - ); - - assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); - } - - #[test] - fn circuit_fails_if_insufficient_nonces_are_provided() { - let program = Program::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(), - ); - let private_account_2 = - AccountWithMetadata::new(Account::default(), false, &recipient_keys.npk()); - - let result = execute_and_prove( - vec![private_account_1, private_account_2], - Program::serialize_instruction(10_u128).unwrap(), - vec![1, 2], - vec![ - ( - sender_keys.npk(), - SharedSecretKey::new(&[55; 32], &sender_keys.vpk()), - ), - ( - recipient_keys.npk(), - SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()), - ), - ], - vec![sender_keys.nsk], - vec![Some((0, vec![]))], - &program.into(), - ); - - assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); - } - - #[test] - fn circuit_fails_if_insufficient_keys_are_provided() { - let program = Program::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(), - ); - let private_account_2 = - AccountWithMetadata::new(Account::default(), false, AccountId::new([1; 32])); - - // Setting only one key for an execution with two private accounts. - let private_account_keys = [( - sender_keys.npk(), - SharedSecretKey::new(&[55; 32], &sender_keys.vpk()), - )]; - let result = execute_and_prove( - vec![private_account_1, private_account_2], - Program::serialize_instruction(10_u128).unwrap(), - vec![1, 2], - private_account_keys.to_vec(), - vec![sender_keys.nsk], - vec![Some((0, vec![]))], - &program.into(), - ); - - assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); - } - - #[test] - fn circuit_fails_if_insufficient_commitment_proofs_are_provided() { - let program = Program::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(), - ); - let private_account_2 = - AccountWithMetadata::new(Account::default(), false, &recipient_keys.npk()); - - // Setting no second commitment proof. - let private_account_membership_proofs = [Some((0, vec![]))]; - let result = execute_and_prove( - vec![private_account_1, private_account_2], - Program::serialize_instruction(10_u128).unwrap(), - vec![1, 2], - vec![ - ( - sender_keys.npk(), - SharedSecretKey::new(&[55; 32], &sender_keys.vpk()), - ), - ( - recipient_keys.npk(), - SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()), - ), - ], - vec![sender_keys.nsk], - private_account_membership_proofs.to_vec(), - &program.into(), - ); - - assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); - } - - #[test] - fn circuit_fails_if_insufficient_auth_keys_are_provided() { - let program = Program::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(), - ); - let private_account_2 = - AccountWithMetadata::new(Account::default(), false, &recipient_keys.npk()); - - // Setting no auth key for an execution with one non default private accounts. - let private_account_nsks = []; - let result = execute_and_prove( - vec![private_account_1, private_account_2], - Program::serialize_instruction(10_u128).unwrap(), - vec![1, 2], - vec![ - ( - sender_keys.npk(), - SharedSecretKey::new(&[55; 32], &sender_keys.vpk()), - ), - ( - recipient_keys.npk(), - SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()), - ), - ], - private_account_nsks.to_vec(), - vec![], + vec![InputAccountIdentity::Public], &program.into(), ); @@ -1725,36 +1959,31 @@ pub mod tests { ..Account::default() }, true, - &sender_keys.npk(), + (&sender_keys.npk(), 0), ); let private_account_2 = - AccountWithMetadata::new(Account::default(), false, &recipient_keys.npk()); + AccountWithMetadata::new(Account::default(), false, (&recipient_keys.npk(), 0)); - let private_account_keys = [ - // First private account is the sender - ( - sender_keys.npk(), - SharedSecretKey::new(&[55; 32], &sender_keys.vpk()), - ), - // Second private account is the recipient - ( - recipient_keys.npk(), - SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()), - ), - ]; - - // Setting the recipient key to authorize the sender. - // This should be set to the sender private account in - // a normal circumstance. The recipient can't authorize this. - let private_account_nsks = [recipient_keys.nsk]; - let private_account_membership_proofs = [Some((0, vec![]))]; + // 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![1, 2], - private_account_keys.to_vec(), - private_account_nsks.to_vec(), - private_account_membership_proofs.to_vec(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()), + nsk: recipient_keys.nsk, + membership_proof: (0, vec![]), + identifier: 0, + }, + InputAccountIdentity::PrivateUnauthorized { + npk: recipient_keys.npk(), + ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()), + identifier: 0, + }, + ], &program.into(), ); @@ -1773,7 +2002,7 @@ pub mod tests { ..Account::default() }, true, - &sender_keys.npk(), + (&sender_keys.npk(), 0), ); let private_account_2 = AccountWithMetadata::new( Account { @@ -1782,25 +2011,25 @@ pub mod tests { ..Account::default() }, false, - &recipient_keys.npk(), + (&recipient_keys.npk(), 0), ); let result = execute_and_prove( vec![private_account_1, private_account_2], Program::serialize_instruction(10_u128).unwrap(), - vec![1, 2], vec![ - ( - sender_keys.npk(), - SharedSecretKey::new(&[55; 32], &sender_keys.vpk()), - ), - ( - recipient_keys.npk(), - SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()), - ), + InputAccountIdentity::PrivateAuthorizedUpdate { + ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()), + nsk: sender_keys.nsk, + membership_proof: (0, vec![]), + identifier: 0, + }, + InputAccountIdentity::PrivateUnauthorized { + npk: recipient_keys.npk(), + ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()), + identifier: 0, + }, ], - vec![sender_keys.nsk], - vec![Some((0, vec![]))], &program.into(), ); @@ -1819,7 +2048,7 @@ pub mod tests { ..Account::default() }, true, - &sender_keys.npk(), + (&sender_keys.npk(), 0), ); let private_account_2 = AccountWithMetadata::new( Account { @@ -1828,25 +2057,25 @@ pub mod tests { ..Account::default() }, false, - &recipient_keys.npk(), + (&recipient_keys.npk(), 0), ); let result = execute_and_prove( vec![private_account_1, private_account_2], Program::serialize_instruction(10_u128).unwrap(), - vec![1, 2], vec![ - ( - sender_keys.npk(), - SharedSecretKey::new(&[55; 32], &sender_keys.vpk()), - ), - ( - recipient_keys.npk(), - SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()), - ), + InputAccountIdentity::PrivateAuthorizedUpdate { + ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()), + nsk: sender_keys.nsk, + membership_proof: (0, vec![]), + identifier: 0, + }, + InputAccountIdentity::PrivateUnauthorized { + npk: recipient_keys.npk(), + ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()), + identifier: 0, + }, ], - vec![sender_keys.nsk], - vec![Some((0, vec![]))], &program.into(), ); @@ -1865,7 +2094,7 @@ pub mod tests { ..Account::default() }, true, - &sender_keys.npk(), + (&sender_keys.npk(), 0), ); let private_account_2 = AccountWithMetadata::new( Account { @@ -1874,25 +2103,25 @@ pub mod tests { ..Account::default() }, false, - &recipient_keys.npk(), + (&recipient_keys.npk(), 0), ); let result = execute_and_prove( vec![private_account_1, private_account_2], Program::serialize_instruction(10_u128).unwrap(), - vec![1, 2], vec![ - ( - sender_keys.npk(), - SharedSecretKey::new(&[55; 32], &sender_keys.vpk()), - ), - ( - recipient_keys.npk(), - SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()), - ), + InputAccountIdentity::PrivateAuthorizedUpdate { + ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()), + nsk: sender_keys.nsk, + membership_proof: (0, vec![]), + identifier: 0, + }, + InputAccountIdentity::PrivateUnauthorized { + npk: recipient_keys.npk(), + ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()), + identifier: 0, + }, ], - vec![sender_keys.nsk], - vec![Some((0, vec![]))], &program.into(), ); @@ -1911,7 +2140,7 @@ pub mod tests { ..Account::default() }, true, - &sender_keys.npk(), + (&sender_keys.npk(), 0), ); let private_account_2 = AccountWithMetadata::new( Account { @@ -1920,25 +2149,25 @@ pub mod tests { ..Account::default() }, false, - &recipient_keys.npk(), + (&recipient_keys.npk(), 0), ); let result = execute_and_prove( vec![private_account_1, private_account_2], Program::serialize_instruction(10_u128).unwrap(), - vec![1, 2], vec![ - ( - sender_keys.npk(), - SharedSecretKey::new(&[55; 32], &sender_keys.vpk()), - ), - ( - recipient_keys.npk(), - SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()), - ), + InputAccountIdentity::PrivateAuthorizedUpdate { + ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()), + nsk: sender_keys.nsk, + membership_proof: (0, vec![]), + identifier: 0, + }, + InputAccountIdentity::PrivateUnauthorized { + npk: recipient_keys.npk(), + ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()), + identifier: 0, + }, ], - vec![sender_keys.nsk], - vec![Some((0, vec![]))], &program.into(), ); @@ -1958,40 +2187,47 @@ pub mod tests { ..Account::default() }, true, - &sender_keys.npk(), + (&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(), + (&recipient_keys.npk(), 0), ); let result = execute_and_prove( vec![private_account_1, private_account_2], Program::serialize_instruction(10_u128).unwrap(), - vec![1, 2], vec![ - ( - sender_keys.npk(), - SharedSecretKey::new(&[55; 32], &sender_keys.vpk()), - ), - ( - recipient_keys.npk(), - SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()), - ), + InputAccountIdentity::PrivateAuthorizedUpdate { + ssk: SharedSecretKey::new([55; 32], &sender_keys.vpk()), + nsk: sender_keys.nsk, + membership_proof: (0, vec![]), + identifier: 0, + }, + InputAccountIdentity::PrivateUnauthorized { + npk: recipient_keys.npk(), + ssk: SharedSecretKey::new([56; 32], &recipient_keys.vpk()), + identifier: 0, + }, ], - vec![sender_keys.nsk], - vec![Some((0, vec![]))], &program.into(), ); assert!(matches!(result, Err(NssaError::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 circuit_should_fail_with_invalid_visibility_mask_value() { + fn private_pda_without_binding_fails() { let program = Program::simple_balance_transfer(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk()); let public_account_1 = AccountWithMetadata::new( Account { program_owner: program.id(), @@ -2001,146 +2237,253 @@ pub mod tests { true, AccountId::new([0; 32]), ); - let public_account_2 = + let private_pda_account = AccountWithMetadata::new(Account::default(), false, AccountId::new([1; 32])); - let visibility_mask = [0, 3]; let result = execute_and_prove( - vec![public_account_1, public_account_2], + vec![public_account_1, private_pda_account], Program::serialize_instruction(10_u128).unwrap(), - visibility_mask.to_vec(), - vec![], - vec![], - vec![], - &program.into(), - ); - - assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); - } - - #[test] - fn circuit_should_fail_with_too_many_nonces() { - let program = Program::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(), - ); - let private_account_2 = - AccountWithMetadata::new(Account::default(), false, &recipient_keys.npk()); - - let result = execute_and_prove( - vec![private_account_1, private_account_2], - Program::serialize_instruction(10_u128).unwrap(), - vec![1, 2], vec![ - ( - sender_keys.npk(), - SharedSecretKey::new(&[55; 32], &sender_keys.vpk()), - ), - ( - recipient_keys.npk(), - SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()), - ), + InputAccountIdentity::Public, + InputAccountIdentity::PrivatePdaInit { + npk, + ssk: shared_secret, + identifier: u128::MAX, + seed: None, + }, ], - vec![sender_keys.nsk], - vec![Some((0, vec![]))], &program.into(), ); assert!(matches!(result, Err(NssaError::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 circuit_should_fail_with_too_many_private_account_keys() { - let program = Program::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(), - ); - let private_account_2 = - AccountWithMetadata::new(Account::default(), false, &recipient_keys.npk()); + fn private_pda_claim_succeeds() { + let program = Program::pda_claimer(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk()); + + let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, u128::MAX); + let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); - // Setting three private account keys for a circuit execution with only two private - // accounts. - let private_account_keys = [ - ( - sender_keys.npk(), - SharedSecretKey::new(&[55; 32], &sender_keys.vpk()), - ), - ( - recipient_keys.npk(), - SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()), - ), - ( - sender_keys.npk(), - SharedSecretKey::new(&[57; 32], &sender_keys.vpk()), - ), - ]; let result = execute_and_prove( - vec![private_account_1, private_account_2], - Program::serialize_instruction(10_u128).unwrap(), - vec![1, 2], - private_account_keys.to_vec(), - vec![sender_keys.nsk], - vec![Some((0, vec![]))], + vec![pre_state], + Program::serialize_instruction(seed).unwrap(), + vec![InputAccountIdentity::PrivatePdaInit { + 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.ciphertexts.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 = Program::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::new([55; 32], &keys_b.vpk()); + + // `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 { + npk: npk_b, + ssk: shared_secret, + identifier: u128::MAX, + seed: None, + }], &program.into(), ); assert!(matches!(result, Err(NssaError::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 circuit_should_fail_with_too_many_private_account_auth_keys() { - let program = Program::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(), - ); - let private_account_2 = - AccountWithMetadata::new(Account::default(), false, &recipient_keys.npk()); + fn caller_pda_seeds_authorize_private_pda_for_callee() { + let delegator = Program::private_pda_delegator(); + let callee = Program::auth_asserting_noop(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([77; 32]); + let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk()); + + 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()); - // Setting two private account keys for a circuit execution with only one non default - // private account (visibility mask equal to 1 means that auth keys are expected). - let visibility_mask = [1, 2]; - let private_account_nsks = [sender_keys.nsk, recipient_keys.nsk]; - let private_account_membership_proofs = [Some((0, vec![])), Some((1, vec![]))]; let result = execute_and_prove( - vec![private_account_1, private_account_2], - Program::serialize_instruction(10_u128).unwrap(), - visibility_mask.to_vec(), + vec![pre_state], + Program::serialize_instruction((seed, seed, callee_id)).unwrap(), + vec![InputAccountIdentity::PrivatePdaInit { + 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 = Program::private_pda_delegator(); + let callee = Program::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::new([55; 32], &keys.vpk()); + + 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 { + npk, + ssk: shared_secret, + identifier: u128::MAX, + seed: None, + }], + &program_with_deps, + ); + + assert!(matches!(result, Err(NssaError::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 = Program::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::new([66; 32], &keys_a.vpk()); + let shared_b = SharedSecretKey::new([77; 32], &keys_b.vpk()); + + 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![ - ( - sender_keys.npk(), - SharedSecretKey::new(&[55; 32], &sender_keys.vpk()), - ), - ( - recipient_keys.npk(), - SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()), - ), + InputAccountIdentity::PrivatePdaInit { + npk: keys_a.npk(), + ssk: shared_a, + identifier: u128::MAX, + seed: None, + }, + InputAccountIdentity::PrivatePdaInit { + npk: keys_b.npk(), + ssk: shared_b, + identifier: u128::MAX, + seed: None, + }, ], - private_account_nsks.to_vec(), - private_account_membership_proofs.to_vec(), + &program.into(), + ); + + assert!(matches!(result, Err(NssaError::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 = Program::noop(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let shared_secret = SharedSecretKey::new([55; 32], &keys.vpk()); + 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 { + npk, + ssk: shared_secret, + identifier: u128::MAX, + seed: None, + }], &program.into(), ); @@ -2160,7 +2503,7 @@ pub mod tests { }; let recipient_keys = test_private_account_keys_2(); - let mut state = V03State::new_with_genesis_accounts(&[], &[]) + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0) .with_private_account(&sender_keys, &sender_private_account); let balance_to_move = 37; @@ -2214,23 +2557,27 @@ pub mod tests { ..Account::default() }, true, - &sender_keys.npk(), + (&sender_keys.npk(), 0), ); - let visibility_mask = [1, 1]; - let private_account_nsks = [sender_keys.nsk, sender_keys.nsk]; - let private_account_membership_proofs = [Some((1, vec![])), Some((1, vec![]))]; - let shared_secret = SharedSecretKey::new(&[55; 32], &sender_keys.vpk()); + let shared_secret = SharedSecretKey::new([55; 32], &sender_keys.vpk()); let result = execute_and_prove( vec![private_account_1.clone(), private_account_1], Program::serialize_instruction(100_u128).unwrap(), - visibility_mask.to_vec(), vec![ - (sender_keys.npk(), shared_secret), - (sender_keys.npk(), shared_secret), + InputAccountIdentity::PrivateAuthorizedUpdate { + ssk: shared_secret, + nsk: sender_keys.nsk, + membership_proof: (1, vec![]), + identifier: 0, + }, + InputAccountIdentity::PrivateAuthorizedUpdate { + ssk: shared_secret, + nsk: sender_keys.nsk, + membership_proof: (1, vec![]), + identifier: 0, + }, ], - private_account_nsks.to_vec(), - private_account_membership_proofs.to_vec(), &program.into(), ); @@ -2245,7 +2592,7 @@ pub mod tests { let initial_balance = 100; let initial_data = [(from, initial_balance)]; let mut state = - V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs(); + V03State::new_with_genesis_accounts(&initial_data, vec![], 0).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; @@ -2264,7 +2611,7 @@ pub mod tests { program.id(), vec![from, to], vec![Nonce(0), Nonce(0)], - amount, + AuthTransferInstruction::Transfer { amount }, ) .unwrap(); let witness_set = @@ -2283,19 +2630,23 @@ pub mod tests { let program = Program::authenticated_transfer_program(); 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_genesis_accounts(&[], &[]); + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); 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 message = public_transaction::Message::try_new( + program.id(), + vec![account_id], + vec![], + AuthTransferInstruction::Initialize, + ) + .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); + let result = state.transition_from_public_transaction(&tx, 2, 0); - assert!(matches!(result, Err(NssaError::ProgramExecutionFailed(_)))); + assert!(matches!(result, Err(NssaError::InvalidProgramBehavior(_)))); assert_eq!(state.get_account_by_id(account_id), Account::default()); } @@ -2304,7 +2655,7 @@ pub mod tests { let program = Program::authenticated_transfer_program(); 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_genesis_accounts(&[], &[]); + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); assert_eq!(state.get_account_by_id(account_id), Account::default()); @@ -2312,7 +2663,7 @@ pub mod tests { program.id(), vec![account_id], vec![Nonce(0)], - 0_u128, + AuthTransferInstruction::Initialize, ) .unwrap(); let witness_set = public_transaction::WitnessSet::for_message(&message, &[&account_key]); @@ -2339,7 +2690,7 @@ pub mod tests { let initial_balance = 1000; let initial_data = [(from, initial_balance), (to, 0)]; let mut state = - V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs(); + V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs(); let from_key = key; let amount: u128 = 37; let instruction: (u128, ProgramId, u32, Option) = ( @@ -2384,7 +2735,7 @@ pub mod tests { let initial_balance = 100; let initial_data = [(from, initial_balance), (to, 0)]; let mut state = - V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs(); + V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs(); let from_key = key; let amount: u128 = 0; let instruction: (u128, ProgramId, u32, Option) = ( @@ -2417,12 +2768,12 @@ pub mod tests { fn execution_that_requires_authentication_of_a_program_derived_account_id_succeeds() { let chain_caller = Program::chain_caller(); let pda_seed = PdaSeed::new([37; 32]); - let from = AccountId::from((&chain_caller.id(), &pda_seed)); + 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_genesis_accounts(&initial_data, &[]).with_test_programs(); + V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs(); let amount: u128 = 58; let instruction: (u128, ProgramId, u32, Option) = ( amount, @@ -2468,7 +2819,7 @@ pub mod tests { let initial_balance = 100; let initial_data = [(from, initial_balance)]; let mut state = - V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs(); + V03State::new_with_genesis_accounts(&initial_data, vec![], 0).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; @@ -2520,15 +2871,12 @@ pub mod tests { let result = execute_and_prove( vec![public_account], - Program::serialize_instruction(0_u128).unwrap(), - vec![0], - vec![], - vec![], - vec![], + Program::serialize_instruction(AuthTransferInstruction::Initialize).unwrap(), + vec![InputAccountIdentity::Public], &program.into(), ); - assert!(matches!(result, Err(NssaError::ProgramProveFailed(_)))); + assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); } #[test] @@ -2541,26 +2889,44 @@ pub mod tests { balance: 100, ..Account::default() }; - let sender_commitment = Commitment::new(&sender_keys.npk(), &sender_private_account); - let mut state = - V03State::new_with_genesis_accounts(&[], std::slice::from_ref(&sender_commitment)); - let sender_pre = AccountWithMetadata::new(sender_private_account, true, &sender_keys.npk()); + 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_genesis_accounts( + &[], + vec![(sender_commitment.clone(), sender_init_nullifier)], + 0, + ); + 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 esk = [5; 32]; - let shared_secret = SharedSecretKey::new(&esk, &sender_keys.vpk()); + let shared_secret = SharedSecretKey::new(esk, &sender_keys.vpk()); let epk = EphemeralPublicKey::from_scalar(esk); + let balance = 37; + let (output, proof) = execute_and_prove( vec![sender_pre, recipient_pre], - Program::serialize_instruction(37_u128).unwrap(), - vec![1, 0], - vec![(sender_keys.npk(), shared_secret)], - vec![sender_keys.nsk], - vec![state.get_proof_for_commitment(&sender_commitment)], + Program::serialize_instruction(authenticated_transfer_core::Instruction::Transfer { + amount: balance, + }) + .unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + 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(); @@ -2587,7 +2953,7 @@ pub mod tests { state.get_account_by_id(recipient_account_id), Account { program_owner: program_id, - balance: 37, + balance, nonce: Nonce(1), ..Account::default() } @@ -2610,7 +2976,7 @@ pub mod tests { ..Account::default() }, true, - &from_keys.npk(), + (&from_keys.npk(), 0), ); let to_account = AccountWithMetadata::new( Account { @@ -2618,14 +2984,22 @@ pub mod tests { ..Account::default() }, true, - &to_keys.npk(), + (&to_keys.npk(), 0), ); - let from_commitment = Commitment::new(&from_keys.npk(), &from_account.account); - let to_commitment = Commitment::new(&to_keys.npk(), &to_account.account); + 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_genesis_accounts( &[], - &[from_commitment.clone(), to_commitment.clone()], + vec![ + (from_commitment.clone(), from_init_nullifier), + (to_commitment.clone(), to_init_nullifier), + ], + 0, ) .with_test_programs(); let amount: u128 = 37; @@ -2637,11 +3011,11 @@ pub mod tests { ); let from_esk = [3; 32]; - let from_ss = SharedSecretKey::new(&from_esk, &from_keys.vpk()); + let from_ss = SharedSecretKey::new(from_esk, &from_keys.vpk()); let from_epk = EphemeralPublicKey::from_scalar(from_esk); let to_esk = [3; 32]; - let to_ss = SharedSecretKey::new(&to_esk, &to_keys.vpk()); + let to_ss = SharedSecretKey::new(to_esk, &to_keys.vpk()); let to_epk = EphemeralPublicKey::from_scalar(to_esk); let mut dependencies = HashMap::new(); @@ -2657,25 +3031,36 @@ pub mod tests { nonce: from_new_nonce, ..from_account.account.clone() }; - let from_expected_commitment = Commitment::new(&from_keys.npk(), &from_expected_post); + 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_keys.npk(), &to_expected_post); + 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![1, 1], - vec![(from_keys.npk(), to_ss), (to_keys.npk(), from_ss)], - vec![from_keys.nsk, to_keys.nsk], vec![ - state.get_proof_for_commitment(&from_commitment), - state.get_proof_for_commitment(&to_commitment), + InputAccountIdentity::PrivateAuthorizedUpdate { + 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 { + 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, ) @@ -2719,7 +3104,8 @@ pub mod tests { 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::from((&pinata_token.id(), &PdaSeed::new([0; 32]))); + 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 { @@ -2732,7 +3118,7 @@ pub mod tests { ..Account::default() }; - let mut state = V03State::new_with_genesis_accounts(&[], &[]); + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); state.add_pinata_token_program(pinata_definition_id); // Set up the token accounts directly (bypassing public transactions which @@ -2804,7 +3190,7 @@ pub mod tests { #[test] fn claiming_mechanism_cannot_claim_initialied_accounts() { let claimer = Program::claimer(); - let mut state = V03State::new_with_genesis_accounts(&[], &[]).with_test_programs(); + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs(); let account_id = AccountId::new([2; 32]); // Insert an account with non-default program owner @@ -2824,13 +3210,18 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::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() { + 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; @@ -2844,7 +3235,8 @@ pub mod tests { (sender_id, sender_init_balance), (recipient_id, recipient_init_balance), ], - &[], + vec![], + 0, ); state.insert_program(Program::modified_transfer_program()); @@ -2868,8 +3260,23 @@ pub mod tests { 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, 1, 0); - assert!(matches!(res, Err(NssaError::InvalidProgramBehavior))); + 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(NssaError::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); @@ -2894,33 +3301,33 @@ pub mod tests { #[test] fn private_authorized_uninitialized_account() { - let mut state = V03State::new_with_genesis_accounts(&[], &[]); + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0); // 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()); + AccountWithMetadata::new(Account::default(), true, (&private_keys.npk(), 0)); let program = Program::authenticated_transfer_program(); // Set up parameters for the new account let esk = [3; 32]; - let shared_secret = SharedSecretKey::new(&esk, &private_keys.vpk()); + let shared_secret = SharedSecretKey::new(esk, &private_keys.vpk()); let epk = EphemeralPublicKey::from_scalar(esk); - // Balance to initialize the account with (0 for a new account) - let balance: u128 = 0; + let instruction = authenticated_transfer_core::Instruction::Initialize; // 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(balance).unwrap(), - vec![1], - vec![(private_keys.npk(), shared_secret)], - vec![private_keys.nsk], - vec![None], + Program::serialize_instruction(instruction).unwrap(), + vec![InputAccountIdentity::PrivateAuthorizedInit { + ssk: shared_secret, + nsk: private_keys.nsk, + identifier: 0, + }], &program.into(), ) .unwrap(); @@ -2940,13 +3347,14 @@ pub mod tests { let result = state.transition_from_privacy_preserving_transaction(&tx, 1, 0); assert!(result.is_ok()); - let nullifier = Nullifier::for_account_initialization(&private_keys.npk()); + 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_genesis_accounts(&[], &[]).with_test_programs(); + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs(); let private_keys = test_private_account_keys_1(); // This is intentional: claim authorization was introduced to protect public accounts, @@ -2954,20 +3362,21 @@ pub mod tests { // operate them without the corresponding private keys, so unauthorized private claiming // remains allowed. let unauthorized_account = - AccountWithMetadata::new(Account::default(), false, &private_keys.npk()); + AccountWithMetadata::new(Account::default(), false, (&private_keys.npk(), 0)); let program = Program::claimer(); let esk = [5; 32]; - let shared_secret = SharedSecretKey::new(&esk, &private_keys.vpk()); + let shared_secret = SharedSecretKey::new(esk, &private_keys.vpk()); let epk = EphemeralPublicKey::from_scalar(esk); let (output, proof) = execute_and_prove( vec![unauthorized_account], Program::serialize_instruction(0_u128).unwrap(), - vec![2], - vec![(private_keys.npk(), shared_secret)], - vec![], - vec![None], + vec![InputAccountIdentity::PrivateUnauthorized { + npk: private_keys.npk(), + ssk: shared_secret, + identifier: 0, + }], &program.into(), ) .unwrap(); @@ -2987,38 +3396,40 @@ pub mod tests { .transition_from_privacy_preserving_transaction(&tx, 1, 0) .unwrap(); - let nullifier = Nullifier::for_account_initialization(&private_keys.npk()); + 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_genesis_accounts(&[], &[]).with_test_programs(); + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).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()); + AccountWithMetadata::new(Account::default(), true, (&private_keys.npk(), 0)); let claimer_program = Program::claimer(); // Set up parameters for claiming the new account let esk = [3; 32]; - let shared_secret = SharedSecretKey::new(&esk, &private_keys.vpk()); + let shared_secret = SharedSecretKey::new(esk, &private_keys.vpk()); let epk = EphemeralPublicKey::from_scalar(esk); - let balance: u128 = 0; + let instruction = authenticated_transfer_core::Instruction::Initialize; // Step 2: Execute claimer program to claim the account with authentication let (output, proof) = execute_and_prove( vec![authorized_account.clone()], - Program::serialize_instruction(balance).unwrap(), - vec![1], - vec![(private_keys.npk(), shared_secret)], - vec![private_keys.nsk], - vec![None], + Program::serialize_instruction(instruction).unwrap(), + vec![InputAccountIdentity::PrivateAuthorizedInit { + ssk: shared_secret, + nsk: private_keys.nsk, + identifier: 0, + }], &claimer_program.into(), ) .unwrap(); @@ -3042,7 +3453,8 @@ pub mod tests { ); // Verify the account is now initialized (nullifier exists) - let nullifier = Nullifier::for_account_initialization(&private_keys.npk()); + 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 @@ -3054,16 +3466,17 @@ pub mod tests { let noop_program = Program::noop(); let esk2 = [4; 32]; - let shared_secret2 = SharedSecretKey::new(&esk2, &private_keys.vpk()); + let shared_secret2 = SharedSecretKey::new(esk2, &private_keys.vpk()); // 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![1], - vec![(private_keys.npk(), shared_secret2)], - vec![private_keys.nsk], - vec![None], + vec![InputAccountIdentity::PrivateAuthorizedInit { + ssk: shared_secret2, + nsk: private_keys.nsk, + identifier: 0, + }], &noop_program.into(), ); @@ -3074,7 +3487,7 @@ pub mod tests { fn public_changer_claimer_no_data_change_no_claim_succeeds() { let initial_data = []; let mut state = - V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs(); + V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs(); let account_id = AccountId::new([1; 32]); let program_id = Program::changer_claimer().id(); // Don't change data (None) and don't claim (false) @@ -3098,7 +3511,7 @@ pub mod tests { fn public_changer_claimer_data_change_no_claim_fails() { let initial_data = []; let mut state = - V03State::new_with_genesis_accounts(&initial_data, &[]).with_test_programs(); + V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs(); let account_id = AccountId::new([1; 32]); let program_id = Program::changer_claimer().id(); // Change data but don't claim (false) - should fail @@ -3114,7 +3527,14 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); // Should fail - cannot modify data without claiming the account - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior( + InvalidProgramBehaviorError::DefaultAccountModifiedWithoutClaim { + account_id: err_account_id + } + )) if err_account_id == account_id + )); } #[test] @@ -3122,20 +3542,19 @@ pub mod tests { let program = Program::changer_claimer(); let sender_keys = test_private_account_keys_1(); let private_account = - AccountWithMetadata::new(Account::default(), true, &sender_keys.npk()); + 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![1], - vec![( - sender_keys.npk(), - SharedSecretKey::new(&[3; 32], &sender_keys.vpk()), - )], - vec![sender_keys.nsk], - vec![Some((0, vec![]))], + vec![InputAccountIdentity::PrivateAuthorizedUpdate { + ssk: SharedSecretKey::new([3; 32], &sender_keys.vpk()), + nsk: sender_keys.nsk, + membership_proof: (0, vec![]), + identifier: 0, + }], &program.into(), ); @@ -3148,7 +3567,7 @@ pub mod tests { let program = Program::changer_claimer(); let sender_keys = test_private_account_keys_1(); let private_account = - AccountWithMetadata::new(Account::default(), true, &sender_keys.npk()); + 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); @@ -3156,13 +3575,12 @@ pub mod tests { let result = execute_and_prove( vec![private_account], Program::serialize_instruction(instruction).unwrap(), - vec![1], - vec![( - sender_keys.npk(), - SharedSecretKey::new(&[3; 32], &sender_keys.vpk()), - )], - vec![sender_keys.nsk], - vec![Some((0, vec![]))], + vec![InputAccountIdentity::PrivateAuthorizedUpdate { + ssk: SharedSecretKey::new([3; 32], &sender_keys.vpk()), + nsk: sender_keys.nsk, + membership_proof: (0, vec![]), + identifier: 0, + }], &program.into(), ); @@ -3188,13 +3606,16 @@ pub mod tests { sender_keys.account_id(), ); let recipient_account = - AccountWithMetadata::new(Account::default(), true, &recipient_keys.npk()); + 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_keys.npk(), &recipient_account.account); + Commitment::new(&recipient_account_id, &recipient_account.account); + let recipient_init_nullifier = Nullifier::for_account_initialization(&recipient_account_id); let state = V03State::new_with_genesis_accounts( &[(sender_account.account_id, sender_account.account.balance)], - std::slice::from_ref(&recipient_commitment), + vec![(recipient_commitment.clone(), recipient_init_nullifier)], + 0, ) .with_test_programs(); @@ -3202,7 +3623,7 @@ pub mod tests { let instruction = (balance_to_transfer, auth_transfers.id()); let recipient_esk = [3; 32]; - let recipient = SharedSecretKey::new(&recipient_esk, &recipient_keys.vpk()); + let recipient = SharedSecretKey::new(recipient_esk, &recipient_keys.vpk()); let mut dependencies = HashMap::new(); dependencies.insert(auth_transfers.id(), auth_transfers); @@ -3212,10 +3633,17 @@ pub mod tests { let result = execute_and_prove( vec![sender_account, recipient_account], Program::serialize_instruction(instruction).unwrap(), - vec![0, 1], - vec![(recipient_keys.npk(), recipient)], - vec![recipient_keys.nsk], - vec![state.get_proof_for_commitment(&recipient_commitment)], + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateAuthorizedUpdate { + 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, ); @@ -3244,7 +3672,7 @@ pub mod tests { let validity_window_program = Program::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_genesis_accounts(&[], &[]).with_test_programs(); + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs(); let tx = { let account_ids = vec![pre.account_id]; let nonces = vec![]; @@ -3296,7 +3724,7 @@ pub mod tests { let validity_window_program = Program::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_genesis_accounts(&[], &[]).with_test_programs(); + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs(); let tx = { let account_ids = vec![pre.account_id]; let nonces = vec![]; @@ -3348,11 +3776,11 @@ pub mod tests { let block_validity_window: BlockValidityWindow = validity_window.try_into().unwrap(); let validity_window_program = Program::validity_window(); let account_keys = test_private_account_keys_1(); - let pre = AccountWithMetadata::new(Account::default(), false, &account_keys.npk()); - let mut state = V03State::new_with_genesis_accounts(&[], &[]).with_test_programs(); + let pre = AccountWithMetadata::new(Account::default(), false, (&account_keys.npk(), 0)); + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs(); let tx = { let esk = [3; 32]; - let shared_secret = SharedSecretKey::new(&esk, &account_keys.vpk()); + let shared_secret = SharedSecretKey::new(esk, &account_keys.vpk()); let epk = EphemeralPublicKey::from_scalar(esk); let instruction = ( @@ -3362,10 +3790,11 @@ pub mod tests { let (output, proof) = circuit::execute_and_prove( vec![pre], Program::serialize_instruction(instruction).unwrap(), - vec![2], - vec![(account_keys.npk(), shared_secret)], - vec![], - vec![None], + vec![InputAccountIdentity::PrivateUnauthorized { + npk: account_keys.npk(), + ssk: shared_secret, + identifier: 0, + }], &validity_window_program.into(), ) .unwrap(); @@ -3417,11 +3846,11 @@ pub mod tests { validity_window.try_into().unwrap(); let validity_window_program = Program::validity_window(); let account_keys = test_private_account_keys_1(); - let pre = AccountWithMetadata::new(Account::default(), false, &account_keys.npk()); - let mut state = V03State::new_with_genesis_accounts(&[], &[]).with_test_programs(); + let pre = AccountWithMetadata::new(Account::default(), false, (&account_keys.npk(), 0)); + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs(); let tx = { let esk = [3; 32]; - let shared_secret = SharedSecretKey::new(&esk, &account_keys.vpk()); + let shared_secret = SharedSecretKey::new(esk, &account_keys.vpk()); let epk = EphemeralPublicKey::from_scalar(esk); let instruction = ( @@ -3431,10 +3860,11 @@ pub mod tests { let (output, proof) = circuit::execute_and_prove( vec![pre], Program::serialize_instruction(instruction).unwrap(), - vec![2], - vec![(account_keys.npk(), shared_secret)], - vec![], - vec![None], + vec![InputAccountIdentity::PrivateUnauthorized { + npk: account_keys.npk(), + ssk: shared_secret, + identifier: 0, + }], &validity_window_program.into(), ) .unwrap(); @@ -3467,14 +3897,766 @@ pub mod tests { } } + 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 = Program::time_locked_transfer().id(); + let message = public_transaction::Message::try_new( + program_id, + vec![from, to, clock_account_id], + vec![Nonce(from_nonce)], + (amount, deadline), + ) + .unwrap(); + let witness_set = public_transaction::WitnessSet::for_message(&message, &[from_key]); + PublicTransaction::new(message, witness_set) + } + + #[test] + fn time_locked_transfer_succeeds_when_deadline_has_passed() { + let recipient_id = AccountId::new([42; 32]); + let genesis_timestamp = 500_u64; + let mut state = + V03State::new_with_genesis_accounts(&[(recipient_id, 0)], vec![], genesis_timestamp) + .with_test_programs(); + 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: Program::time_locked_transfer().id(), + balance: 100, + ..Account::default() + }, + ); + + let amount = 100_u128; + // Deadline in the past: transfer should succeed. + let deadline = 0_u64; + + let tx = time_locked_transfer_transaction( + sender_id, + &key1, + 0, + recipient_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, + amount, + deadline, + ); + + let block_id = 1; + let timestamp = genesis_timestamp + 100; + state + .transition_from_public_transaction(&tx, block_id, 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 recipient_id = AccountId::new([42; 32]); + let genesis_timestamp = 500_u64; + let mut state = + V03State::new_with_genesis_accounts(&[(recipient_id, 0)], vec![], genesis_timestamp) + .with_test_programs(); + 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: Program::time_locked_transfer().id(), + balance: 100, + ..Account::default() + }, + ); + + let amount = 100_u128; + // Far-future deadline: program should panic. + let deadline = u64::MAX; + + let tx = time_locked_transfer_transaction( + sender_id, + &key1, + 0, + recipient_id, + CLOCK_01_PROGRAM_ACCOUNT_ID, + amount, + deadline, + ); + + let block_id = 1; + let timestamp = genesis_timestamp + 100; + let result = state.transition_from_public_transaction(&tx, block_id, 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 = Program::pinata_cooldown().id(); + let message = public_transaction::Message::try_new( + program_id, + vec![pinata_id, winner_id, clock_account_id], + vec![], + (), + ) + .unwrap(); + let witness_set = 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_u64; + let mut state = + V03State::new_with_genesis_accounts(&[(winner_id, 0)], vec![], genesis_timestamp) + .with_test_programs(); + + let prize = 50_u128; + let cooldown_ms = 500_u64; + // Last claim was at genesis, so any timestamp >= genesis + cooldown should work. + let last_claim_timestamp = genesis_timestamp; + + state.force_insert_account( + pinata_id, + Account { + program_owner: Program::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, CLOCK_01_PROGRAM_ACCOUNT_ID); + + let block_id = 1; + let block_timestamp = genesis_timestamp + cooldown_ms; + // Advance clock so the cooldown check reads an updated timestamp. + let clock_tx = clock_transaction(block_timestamp); + state + .transition_from_public_transaction(&clock_tx, block_id, block_timestamp) + .unwrap(); + + state + .transition_from_public_transaction(&tx, block_id, 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_u64; + let mut state = + V03State::new_with_genesis_accounts(&[(winner_id, 0)], vec![], genesis_timestamp) + .with_test_programs(); + + let prize = 50_u128; + let cooldown_ms = 500_u64; + let last_claim_timestamp = genesis_timestamp; + + state.force_insert_account( + pinata_id, + Account { + 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, CLOCK_01_PROGRAM_ACCOUNT_ID); + + let block_id = 1; + // Timestamp is only 100ms after last claim, well within the 500ms cooldown. + let block_timestamp = genesis_timestamp + 100; + let clock_tx = clock_transaction(block_timestamp); + state + .transition_from_public_transaction(&clock_tx, block_id, block_timestamp) + .unwrap(); + + let result = state.transition_from_public_transaction(&tx, block_id, 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 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_genesis_accounts(&initial_data, &[]).with_test_programs(); + let state = + V03State::new_with_genesis_accounts(&initial_data, vec![], 0).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 = Program::flash_swap_initiator(); + let callback = Program::flash_swap_callback(); + let token = Program::authenticated_transfer_program(); + + 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_genesis_accounts(&[], vec![], 0).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 = Program::flash_swap_initiator(); + let callback = Program::flash_swap_callback(); + let token = Program::authenticated_transfer_program(); + + 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_genesis_accounts(&[], vec![], 0).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 = Program::flash_swap_initiator(); + let callback = Program::flash_swap_callback(); + let token = Program::authenticated_transfer_program(); + + 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_genesis_accounts(&[], vec![], 0).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 = Program::flash_swap_initiator(); + let token = Program::authenticated_transfer_program(); + + 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_genesis_accounts(&[], vec![], 0).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 = Program::malicious_self_program_id(); + let acc_id = AccountId::new([99; 32]); + let account = Account::default(); + + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).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 = Program::malicious_caller_program_id(); + let acc_id = AccountId::new([99; 32]); + let account = Account::default(); + + let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).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 = Program::pda_spend_proxy(); + let auth_transfer = Program::authenticated_transfer_program(); + let proxy_id = proxy.id(); + let auth_transfer_id = auth_transfer.id(); + let seed = PdaSeed::new([42; 32]); + let amount: u128 = 100; + + let spend_with_deps = + ProgramWithDependencies::new(proxy, [(auth_transfer_id, auth_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_genesis_accounts(&[(funder_id, 500)], vec![], 0); + + let alice_pda_0_account = Account { + program_owner: auth_transfer_id, + balance: amount, + nonce: Nonce::private_account_nonce_init(&alice_pda_0_id), + ..Account::default() + }; + let alice_pda_1_account = Account { + program_owner: auth_transfer_id, + balance: amount, + nonce: Nonce::private_account_nonce_init(&alice_pda_1_id), + ..Account::default() + }; + + let alice_shared_0 = SharedSecretKey::new([10; 32], &alice_keys.vpk()); + let alice_shared_1 = SharedSecretKey::new([11; 32], &alice_keys.vpk()); + + // 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(AuthTransferInstruction::Transfer { amount }) + .unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivatePdaInit { + npk: alice_npk, + ssk: alice_shared_0, + identifier: 0, + seed: Some((seed, proxy_id)), + }, + ], + &auth_transfer.clone().into(), + ) + .unwrap(); + let message = Message::try_from_circuit_output( + vec![funder_id], + vec![funder_nonce], + vec![( + alice_npk, + alice_keys.vpk(), + EphemeralPublicKey::from_scalar([10; 32]), + )], + 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(AuthTransferInstruction::Transfer { amount }) + .unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivatePdaInit { + npk: alice_npk, + ssk: alice_shared_1, + identifier: 1, + seed: Some((seed, proxy_id)), + }, + ], + &auth_transfer.into(), + ) + .unwrap(); + let message = Message::try_from_circuit_output( + vec![funder_id], + vec![funder_nonce], + vec![( + alice_npk, + alice_keys.vpk(), + EphemeralPublicKey::from_scalar([11; 32]), + )], + 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, auth_transfer_id)).unwrap(), + vec![ + InputAccountIdentity::PrivatePdaUpdate { + 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)], + vec![( + alice_npk, + alice_keys.vpk(), + EphemeralPublicKey::from_scalar([10; 32]), + )], + 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, auth_transfer_id)).unwrap(), + vec![ + InputAccountIdentity::PrivatePdaUpdate { + 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![], + vec![( + alice_npk, + alice_keys.vpk(), + EphemeralPublicKey::from_scalar([11; 32]), + )], + 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 auth_transfer using PrivatePdaUpdate with an + // external seed. + let alice_pda_1_account_after_spend = Account { + program_owner: auth_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::new([12; 32], &alice_keys.vpk()); + { + 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(AuthTransferInstruction::Transfer { amount }) + .unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivatePdaUpdate { + 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)), + }, + ], + &Program::authenticated_transfer_program().into(), + ) + .unwrap(); + let message = Message::try_from_circuit_output( + vec![recipient_id], + vec![recipient_nonce], + vec![( + alice_npk, + alice_keys.vpk(), + EphemeralPublicKey::from_scalar([12; 32]), + )], + 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/nssa/src/validated_state_diff.rs b/nssa/src/validated_state_diff.rs new file mode 100644 index 00000000..87bde206 --- /dev/null +++ b/nssa/src/validated_state_diff.rs @@ -0,0 +1,918 @@ +use std::{ + collections::{HashMap, HashSet, VecDeque}, + hash::Hash, +}; + +use log::debug; +use nssa_core::{ + BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, Timestamp, + account::{Account, AccountId, AccountWithMetadata}, + program::{ + ChainedCall, Claim, DEFAULT_PROGRAM_ID, ProgramId, compute_public_authorized_pdas, + validate_execution, + }, +}; + +use crate::{ + V03State, ensure, + error::{InvalidProgramBehaviorError, NssaError}, + 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(); + + // All account_ids must be different + ensure!( + message.account_ids.iter().collect::>().len() == message.account_ids.len(), + NssaError::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(), + NssaError::InvalidInput( + "Mismatch between number of nonces and signatures/public keys".into(), + ) + ); + + // Check the signatures are valid + ensure!( + witness_set.is_valid_for(message), + NssaError::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, + NssaError::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, + NssaError::MaxChainedCallsDepthExceeded + ); + + // Check that the `program_id` corresponds to a deployed program + let Some(program) = state.programs().get(&chained_call.program_id) else { + return Err(NssaError::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. + let is_indeed_authorized = is_authorized(&account_id); + ensure!( + !pre.is_authorized || is_indeed_authorized, + InvalidProgramBehaviorError::InvalidAccountAuthorization { 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), + NssaError::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). + let authorized_accounts: HashSet<_> = 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(), + NssaError::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(), + NssaError::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) == message.new_nullifiers.len(), + NssaError::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(), + NssaError::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(), + NssaError::InvalidInput( + "Mismatch between number of nonces and signatures/public keys".into(), + ) + ); + + // Check the signatures are valid + ensure!( + witness_set.signatures_are_valid_for(message), + NssaError::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, + NssaError::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), + NssaError::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())?; + if state.programs().contains_key(&program.id()) { + return Err(NssaError::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<(), NssaError> { + let output = PrivacyPreservingCircuitOutput { + public_pre_states: public_pre_states.to_vec(), + public_post_states: message.public_post_states.clone(), + ciphertexts: message + .encrypted_private_post_states + .iter() + .cloned() + .map(|value| value.ciphertext) + .collect(), + 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(NssaError::InvalidPrivacyPreservingProof) +} + +fn n_unique(data: &[T]) -> usize { + let set: HashSet<&T> = data.iter().collect(); + set.len() +} + +#[cfg(test)] +mod tests { + use nssa_core::account::{AccountId, Nonce}; + + use crate::{ + PrivateKey, PublicKey, V03State, + error::{InvalidProgramBehaviorError, NssaError}, + program::Program, + public_transaction::{Message, WitnessSet}, + validated_state_diff::ValidatedStateDiff, + }; + + /// 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 nssa_core::{ + Commitment, InputAccountIdentity, SharedSecretKey, + account::{Account, AccountWithMetadata}, + encryption::EphemeralPublicKey, + }; + + 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 = ( + nssa_core::program::ProgramId, // p2_id + nssa_core::program::ProgramId, // auth_transfer_id + [u8; 32], // victim_id_raw + u128, // victim_balance + u128, // victim_nonce + nssa_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_esk = [12_u8; 32]; + let attacker_ssk = SharedSecretKey::new(attacker_esk, &attacker_keys.vpk()); + let attacker_epk = EphemeralPublicKey::from_scalar(attacker_esk); + + 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 = authenticated_transfer_program.id() on all accounts. + let mut state = V03State::new_with_genesis_accounts( + &[(victim_id, victim_balance), (recipient_id, 0)], + vec![], + 0, + ); + state.insert_program(Program::malicious_injector()); + state.insert_program(Program::malicious_launderer()); + + // Build attacker's private account and its local commitment tree. + let attacker_account = Account { + program_owner: Program::authenticated_transfer_program().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 = ( + Program::malicious_launderer().id(), + Program::authenticated_transfer_program().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 = Program::malicious_launderer(); + let at = Program::authenticated_transfer_program(); + let program_with_deps = ProgramWithDependencies::new( + Program::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 authenticated_transfer's program_output.pre_states + // [2] recipient — first seen in authenticated_transfer's program_output.pre_states + let account_identities = vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + 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 + vec![(attacker_keys.npk(), attacker_keys.vpk(), attacker_epk)], + 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(NssaError::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 nssa_core::{ + Commitment, InputAccountIdentity, SharedSecretKey, + account::{Account, AccountWithMetadata}, + encryption::EphemeralPublicKey, + }; + + 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 = ( + nssa_core::program::ProgramId, // p2_id + nssa_core::program::ProgramId, // auth_transfer_id + [u8; 32], // victim_id_raw + u128, // victim_balance + u128, // victim_nonce + nssa_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_esk = [12_u8; 32]; + let attacker_ssk = SharedSecretKey::new(attacker_esk, &attacker_keys.vpk()); + let attacker_epk = EphemeralPublicKey::from_scalar(attacker_esk); + + // 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 mut state = V03State::new_with_genesis_accounts(&[(recipient_id, 0)], vec![], 0); + state.insert_program(Program::malicious_injector()); + state.insert_program(Program::malicious_launderer()); + + // Build attacker's private account and its local commitment tree. + let attacker_account = Account { + program_owner: Program::authenticated_transfer_program().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 authenticated_transfer + // to succeed inside the circuit, which has no access to chain state and cannot detect + // that these values are fabricated. + let instruction: InjectorInstruction = ( + Program::malicious_launderer().id(), + Program::authenticated_transfer_program().id(), + *victim_id.value(), + victim_balance, + 0_u128, // nonce + Program::authenticated_transfer_program().id(), // program_owner + *recipient_id.value(), + victim_balance, + ); + let instruction_data = Program::serialize_instruction(instruction).unwrap(); + + let p2 = Program::malicious_launderer(); + let at = Program::authenticated_transfer_program(); + let program_with_deps = ProgramWithDependencies::new( + Program::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 authenticated_transfer's program_output.pre_states + // [2] recipient — first seen in authenticated_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 { + 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: authenticated_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 + vec![(attacker_keys.npk(), attacker_keys.vpk(), attacker_epk)], + 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(NssaError::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 `authenticated_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, auth_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 = ( + nssa_core::program::ProgramId, // p2_id + nssa_core::program::ProgramId, // auth_transfer_id + [u8; 32], // victim_id_raw + u128, // victim_balance + u128, // victim_nonce + nssa_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 mut state = V03State::new_with_genesis_accounts( + &[ + (attacker_id, 100), + (victim_id, victim_balance), + (recipient_id, 0), + ], + vec![], + 0, + ); + + state.insert_program(Program::malicious_injector()); + state.insert_program(Program::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 = ( + Program::malicious_launderer().id(), + Program::authenticated_transfer_program().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( + Program::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(NssaError::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" + ); + } +} diff --git a/program_methods/guest/Cargo.toml b/program_methods/guest/Cargo.toml index 29ef8304..136fb0b8 100644 --- a/program_methods/guest/Cargo.toml +++ b/program_methods/guest/Cargo.toml @@ -9,11 +9,15 @@ workspace = true [dependencies] nssa_core.workspace = true +authenticated_transfer_core.workspace = true +clock_core.workspace = true token_core.workspace = true token_program.workspace = true amm_core.workspace = true amm_program.workspace = true ata_core.workspace = true ata_program.workspace = true +faucet_core.workspace = true +vault_core.workspace = true risc0-zkvm.workspace = true serde = { workspace = true, default-features = false } diff --git a/program_methods/guest/src/bin/amm.rs b/program_methods/guest/src/bin/amm.rs index 748630d9..bce76c63 100644 --- a/program_methods/guest/src/bin/amm.rs +++ b/program_methods/guest/src/bin/amm.rs @@ -14,6 +14,8 @@ use nssa_core::program::{ProgramInput, ProgramOutput, read_nssa_inputs}; fn main() { let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction, }, @@ -112,15 +114,15 @@ fn main() { min_amount_to_remove_token_b, ) } - Instruction::Swap { + Instruction::SwapExactInput { swap_amount_in, min_amount_out, token_definition_id_in, } => { let [pool, vault_a, vault_b, user_holding_a, user_holding_b] = pre_states .try_into() - .expect("Transfer instruction requires exactly five accounts"); - amm_program::swap::swap( + .expect("SwapExactInput instruction requires exactly five accounts"); + amm_program::swap::swap_exact_input( pool, vault_a, vault_b, @@ -131,9 +133,34 @@ fn main() { token_definition_id_in, ) } + Instruction::SwapExactOutput { + exact_amount_out, + max_amount_in, + token_definition_id_in, + } => { + let [pool, vault_a, vault_b, user_holding_a, user_holding_b] = pre_states + .try_into() + .expect("SwapExactOutput instruction requires exactly five accounts"); + amm_program::swap::swap_exact_output( + pool, + vault_a, + vault_b, + user_holding_a, + user_holding_b, + exact_amount_out, + max_amount_in, + token_definition_id_in, + ) + } }; - ProgramOutput::new(instruction_words, pre_states_clone, post_states) - .with_chained_calls(chained_calls) - .write(); + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states_clone, + post_states, + ) + .with_chained_calls(chained_calls) + .write(); } diff --git a/program_methods/guest/src/bin/associated_token_account.rs b/program_methods/guest/src/bin/associated_token_account.rs index 55d5824b..9b155d7f 100644 --- a/program_methods/guest/src/bin/associated_token_account.rs +++ b/program_methods/guest/src/bin/associated_token_account.rs @@ -4,6 +4,8 @@ use nssa_core::program::{ProgramInput, ProgramOutput, read_nssa_inputs}; fn main() { let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction, }, @@ -56,7 +58,13 @@ fn main() { } }; - ProgramOutput::new(instruction_words, pre_states_clone, post_states) - .with_chained_calls(chained_calls) - .write(); + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states_clone, + post_states, + ) + .with_chained_calls(chained_calls) + .write(); } diff --git a/program_methods/guest/src/bin/authenticated_transfer.rs b/program_methods/guest/src/bin/authenticated_transfer.rs index 2fb0ea8b..0c8040d9 100644 --- a/program_methods/guest/src/bin/authenticated_transfer.rs +++ b/program_methods/guest/src/bin/authenticated_transfer.rs @@ -1,3 +1,4 @@ +use authenticated_transfer_core::Instruction; use nssa_core::{ account::{Account, AccountWithMetadata}, program::{ @@ -8,7 +9,6 @@ use nssa_core::{ /// Initializes a default account under the ownership of this program. fn initialize_account(pre_state: AccountWithMetadata) -> AccountPostState { let account_to_claim = AccountPostState::new_claimed(pre_state.account, Claim::Authorized); - let is_authorized = pre_state.is_authorized; // Continue only if the account to claim has default values assert!( @@ -16,9 +16,6 @@ fn initialize_account(pre_state: AccountWithMetadata) -> AccountPostState { "Account must be uninitialized" ); - // Continue only if the owner authorized this operation - assert!(is_authorized, "Account must be authorized"); - account_to_claim } @@ -28,7 +25,7 @@ fn transfer( recipient: AccountWithMetadata, balance_to_move: u128, ) -> Vec { - // Continue only if the sender has authorized this operation + // Continue only if the sender has authorized this operation. assert!(sender.is_authorized, "Sender must be authorized"); // Create accounts post states, with updated balances @@ -67,22 +64,35 @@ fn main() { // Read input accounts. let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, - instruction: balance_to_move, + instruction, }, instruction_words, - ) = read_nssa_inputs(); + ) = read_nssa_inputs::(); - let post_states = match (pre_states.as_slice(), balance_to_move) { - ([account_to_claim], 0) => { - let post = initialize_account(account_to_claim.clone()); - vec![post] + let post_states = match instruction { + Instruction::Initialize => { + let [account_to_claim] = <[_; 1]>::try_from(pre_states.clone()) + .expect("Initialize requires exactly 1 account"); + vec![initialize_account(account_to_claim)] } - ([sender, recipient], balance_to_move) => { - transfer(sender.clone(), recipient.clone(), balance_to_move) + Instruction::Transfer { + amount: balance_to_move, + } => { + let [sender, recipient] = <[_; 2]>::try_from(pre_states.clone()) + .expect("Transfer requires exactly 2 accounts"); + transfer(sender, recipient, balance_to_move) } - _ => panic!("invalid params"), }; - ProgramOutput::new(instruction_words, pre_states, post_states).write(); + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + post_states, + ) + .write(); } diff --git a/program_methods/guest/src/bin/clock.rs b/program_methods/guest/src/bin/clock.rs new file mode 100644 index 00000000..cb49c384 --- /dev/null +++ b/program_methods/guest/src/bin/clock.rs @@ -0,0 +1,94 @@ +//! Clock Program. +//! +//! A system program that records the current block ID and timestamp into dedicated clock accounts. +//! Three accounts are maintained, updated at different block intervals (every 1, 10, and 50 +//! blocks), allowing programs to read recent timestamps at various granularities. +//! +//! This program can only be invoked exclusively by the sequencer as the last transaction in every +//! block. Clock accounts are assigned to the clock program at genesis, so no claiming is required +//! here. + +use clock_core::{ + CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID, + ClockAccountData, Instruction, +}; +use nssa_core::{ + account::AccountWithMetadata, + program::{AccountPostState, ProgramInput, ProgramOutput, read_nssa_inputs}, +}; + +fn update_if_multiple( + pre: AccountWithMetadata, + divisor: u64, + current_block_id: u64, + updated_data: &[u8], +) -> (AccountWithMetadata, AccountPostState) { + if current_block_id.is_multiple_of(divisor) { + let mut post_account = pre.account.clone(); + post_account.data = updated_data + .to_vec() + .try_into() + .expect("Clock account data should fit in account data"); + (pre, AccountPostState::new(post_account)) + } else { + let post = AccountPostState::new(pre.account.clone()); + (pre, post) + } +} + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: timestamp, + }, + instruction_words, + ) = read_nssa_inputs::(); + + let Ok([pre_01, pre_10, pre_50]) = <[_; 3]>::try_from(pre_states) else { + panic!("Invalid number of input accounts"); + }; + + // Verify pre-states correspond to the expected clock account IDs. + if pre_01.account_id != CLOCK_01_PROGRAM_ACCOUNT_ID + || pre_10.account_id != CLOCK_10_PROGRAM_ACCOUNT_ID + || pre_50.account_id != CLOCK_50_PROGRAM_ACCOUNT_ID + { + panic!("Invalid input accounts"); + } + + // Verify all clock accounts are owned by this program (assigned at genesis). + if pre_01.account.program_owner != self_program_id + || pre_10.account.program_owner != self_program_id + || pre_50.account.program_owner != self_program_id + { + panic!("Clock accounts must be owned by the clock program"); + } + + let prev_data = ClockAccountData::from_bytes(&pre_01.account.data.clone().into_inner()); + let current_block_id = prev_data + .block_id + .checked_add(1) + .expect("Next block id should be within u64 boundaries"); + + let updated_data = ClockAccountData { + block_id: current_block_id, + timestamp, + } + .to_bytes(); + + let (pre_01, post_01) = update_if_multiple(pre_01, 1, current_block_id, &updated_data); + let (pre_10, post_10) = update_if_multiple(pre_10, 10, current_block_id, &updated_data); + let (pre_50, post_50) = update_if_multiple(pre_50, 50, current_block_id, &updated_data); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pre_01, pre_10, pre_50], + vec![post_01, post_10, post_50], + ) + .write(); +} diff --git a/program_methods/guest/src/bin/faucet.rs b/program_methods/guest/src/bin/faucet.rs new file mode 100644 index 00000000..e56330cd --- /dev/null +++ b/program_methods/guest/src/bin/faucet.rs @@ -0,0 +1,71 @@ +use faucet_core::Instruction; +use nssa_core::program::{ + AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_nssa_inputs, +}; + +fn unchanged_post_states( + pre_states: &[nssa_core::account::AccountWithMetadata], +) -> Vec { + pre_states + .iter() + .map(|pre_state| AccountPostState::new(pre_state.account.clone())) + .collect() +} + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_nssa_inputs::(); + + let pre_states_clone = pre_states.clone(); + let post_states = unchanged_post_states(&pre_states_clone); + + let chained_calls = match instruction { + Instruction::Transfer { + vault_program_id, + recipient_id, + amount, + } => { + let [faucet, recipient_vault] = pre_states + .try_into() + .expect("Transfer requires exactly 2 accounts"); + + assert_eq!( + faucet.account_id, + faucet_core::compute_faucet_account_id(self_program_id), + "First account must be faucet PDA" + ); + + let mut faucet_for_vault = faucet; + faucet_for_vault.is_authorized = true; + + vec![ + ChainedCall::new( + vault_program_id, + vec![faucet_for_vault, recipient_vault], + &vault_core::Instruction::Transfer { + recipient_id, + amount, + }, + ) + .with_pda_seeds(vec![faucet_core::compute_faucet_seed()]), + ] + } + }; + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states_clone, + post_states, + ) + .with_chained_calls(chained_calls) + .write(); +} diff --git a/program_methods/guest/src/bin/pinata.rs b/program_methods/guest/src/bin/pinata.rs index 2f85f069..dcc76397 100644 --- a/program_methods/guest/src/bin/pinata.rs +++ b/program_methods/guest/src/bin/pinata.rs @@ -46,6 +46,8 @@ fn main() { // It is expected to receive only two accounts: [pinata_account, winner_account] let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction: solution, }, @@ -79,6 +81,8 @@ fn main() { .expect("Overflow when adding prize to winner"); ProgramOutput::new( + self_program_id, + caller_program_id, instruction_words, vec![pinata, winner], vec![ diff --git a/program_methods/guest/src/bin/pinata_token.rs b/program_methods/guest/src/bin/pinata_token.rs index 3dee05b7..1f7ad9da 100644 --- a/program_methods/guest/src/bin/pinata_token.rs +++ b/program_methods/guest/src/bin/pinata_token.rs @@ -52,6 +52,8 @@ fn main() { // winner_token_holding] let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction: solution, }, @@ -97,6 +99,8 @@ fn main() { .with_pda_seeds(vec![PdaSeed::new([0; 32])]); ProgramOutput::new( + self_program_id, + caller_program_id, instruction_words, vec![ pinata_definition, diff --git a/program_methods/guest/src/bin/privacy_preserving_circuit.rs b/program_methods/guest/src/bin/privacy_preserving_circuit.rs deleted file mode 100644 index e53334f9..00000000 --- a/program_methods/guest/src/bin/privacy_preserving_circuit.rs +++ /dev/null @@ -1,495 +0,0 @@ -use std::{ - collections::{HashMap, HashSet, VecDeque, hash_map::Entry}, - convert::Infallible, -}; - -use nssa_core::{ - Commitment, CommitmentSetDigest, DUMMY_COMMITMENT_HASH, EncryptionScheme, MembershipProof, - Nullifier, NullifierPublicKey, NullifierSecretKey, PrivacyPreservingCircuitInput, - PrivacyPreservingCircuitOutput, SharedSecretKey, - account::{Account, AccountId, AccountWithMetadata, Nonce}, - compute_digest_for_path, - program::{ - AccountPostState, BlockValidityWindow, ChainedCall, Claim, DEFAULT_PROGRAM_ID, - MAX_NUMBER_CHAINED_CALLS, ProgramId, ProgramOutput, TimestampValidityWindow, - validate_execution, - }, -}; -use risc0_zkvm::{guest::env, serde::to_vec}; - -/// State of the involved accounts before and after program execution. -struct ExecutionState { - pre_states: Vec, - post_states: HashMap, - block_validity_window: BlockValidityWindow, - timestamp_validity_window: TimestampValidityWindow, -} - -impl ExecutionState { - /// Validate program outputs and derive the overall execution state. - pub fn derive_from_outputs( - visibility_mask: &[u8], - program_id: ProgramId, - program_outputs: Vec, - ) -> Self { - let block_valid_from = program_outputs - .iter() - .filter_map(|output| output.block_validity_window.start()) - .max(); - let block_valid_until = program_outputs - .iter() - .filter_map(|output| output.block_validity_window.end()) - .min(); - let ts_valid_from = program_outputs - .iter() - .filter_map(|output| output.timestamp_validity_window.start()) - .max(); - let ts_valid_until = program_outputs - .iter() - .filter_map(|output| output.timestamp_validity_window.end()) - .min(); - - let block_validity_window: BlockValidityWindow = (block_valid_from, block_valid_until) - .try_into() - .expect( - "There should be non empty intersection in the program output block validity windows", - ); - let timestamp_validity_window: TimestampValidityWindow = - (ts_valid_from, ts_valid_until) - .try_into() - .expect( - "There should be non empty intersection in the program output timestamp validity windows", - ); - - let mut execution_state = Self { - pre_states: Vec::new(), - post_states: HashMap::new(), - block_validity_window, - timestamp_validity_window, - }; - - let Some(first_output) = program_outputs.first() else { - panic!("No program outputs provided"); - }; - - let initial_call = ChainedCall { - program_id, - instruction_data: first_output.instruction_data.clone(), - pre_states: first_output.pre_states.clone(), - pda_seeds: Vec::new(), - }; - let mut chained_calls = VecDeque::from_iter([(initial_call, None)]); - - let mut program_outputs_iter = program_outputs.into_iter(); - let mut chain_calls_counter = 0; - - while let Some((chained_call, caller_program_id)) = chained_calls.pop_front() { - assert!( - chain_calls_counter <= MAX_NUMBER_CHAINED_CALLS, - "Max chained calls depth is exceeded" - ); - - let Some(program_output) = program_outputs_iter.next() else { - panic!("Insufficient program outputs for chained calls"); - }; - - // Check that instruction data in chained call is the instruction data in program output - assert_eq!( - chained_call.instruction_data, program_output.instruction_data, - "Mismatched instruction data between chained call and program output" - ); - - // Check that `program_output` is consistent with the execution of the corresponding - // program. - let program_output_words = - &to_vec(&program_output).expect("program_output must be serializable"); - env::verify(chained_call.program_id, program_output_words).unwrap_or_else( - |_: Infallible| unreachable!("Infallible error is never constructed"), - ); - - // Check that the program is well behaved. - // See the # Programs section for the definition of the `validate_execution` method. - let execution_valid = validate_execution( - &program_output.pre_states, - &program_output.post_states, - chained_call.program_id, - ); - assert!(execution_valid, "Bad behaved program"); - - for next_call in program_output.chained_calls.iter().rev() { - chained_calls.push_front((next_call.clone(), Some(chained_call.program_id))); - } - - let authorized_pdas = nssa_core::program::compute_authorized_pdas( - caller_program_id, - &chained_call.pda_seeds, - ); - execution_state.validate_and_sync_states( - visibility_mask, - chained_call.program_id, - &authorized_pdas, - program_output.pre_states, - program_output.post_states, - ); - chain_calls_counter = chain_calls_counter.checked_add(1).expect( - "Chain calls counter should not overflow as it checked before incrementing", - ); - } - - assert!( - program_outputs_iter.next().is_none(), - "Inner call without a chained call found", - ); - - // Check that all modified uninitialized accounts were claimed - for (account_id, post) in execution_state - .pre_states - .iter() - .filter(|a| a.account.program_owner == DEFAULT_PROGRAM_ID) - .map(|a| { - let post = execution_state - .post_states - .get(&a.account_id) - .expect("Post state must exist for pre state"); - (a, post) - }) - .filter(|(pre_default, post)| pre_default.account != **post) - .map(|(pre, post)| (pre.account_id, post)) - { - assert_ne!( - post.program_owner, DEFAULT_PROGRAM_ID, - "Account {account_id} was modified but not claimed" - ); - } - - execution_state - } - - /// Validate program pre and post states and populate the execution state. - fn validate_and_sync_states( - &mut self, - visibility_mask: &[u8], - program_id: ProgramId, - authorized_pdas: &HashSet, - pre_states: Vec, - post_states: Vec, - ) { - for (pre, mut post) in pre_states.into_iter().zip(post_states) { - let pre_account_id = pre.account_id; - let pre_is_authorized = pre.is_authorized; - let post_states_entry = self.post_states.entry(pre.account_id); - match &post_states_entry { - Entry::Occupied(occupied) => { - #[expect( - clippy::shadow_unrelated, - reason = "Shadowing is intentional to use all fields" - )] - let AccountWithMetadata { - account: pre_account, - account_id: pre_account_id, - is_authorized: pre_is_authorized, - } = pre; - - // Ensure that new pre state is the same as known post state - assert_eq!( - occupied.get(), - &pre_account, - "Inconsistent pre state for account {pre_account_id}", - ); - - let previous_is_authorized = self - .pre_states - .iter() - .find(|acc| acc.account_id == pre_account_id) - .map_or_else( - || panic!( - "Pre state must exist in execution state for account {pre_account_id}", - ), - |acc| acc.is_authorized - ); - - let is_authorized = - previous_is_authorized || authorized_pdas.contains(&pre_account_id); - - assert_eq!( - pre_is_authorized, is_authorized, - "Inconsistent authorization for account {pre_account_id}", - ); - } - Entry::Vacant(_) => { - // Pre state for the initial call - self.pre_states.push(pre); - } - } - - if let Some(claim) = post.required_claim() { - // The invoked program can only claim accounts with default program id. - assert_eq!( - post.account().program_owner, - DEFAULT_PROGRAM_ID, - "Cannot claim an initialized account {pre_account_id}" - ); - - let pre_state_position = self - .pre_states - .iter() - .position(|acc| acc.account_id == pre_account_id) - .expect("Pre state must exist at this point"); - - let is_public_account = visibility_mask[pre_state_position] == 0; - if is_public_account { - match claim { - Claim::Authorized => { - // Note: no need to check authorized pdas because we have already - // checked consistency of authorization above. - assert!( - pre_is_authorized, - "Cannot claim unauthorized account {pre_account_id}" - ); - } - Claim::Pda(seed) => { - let pda = AccountId::from((&program_id, &seed)); - assert_eq!( - pre_account_id, pda, - "Invalid PDA claim for account {pre_account_id} which does not match derived PDA {pda}" - ); - } - } - } else { - // We don't care about the exact claim mechanism for private accounts. - // This is because the main reason to have it is to protect against PDA griefing - // attacks in public execution, while private PDA doesn't make much sense - // anyway. - } - - post.account_mut().program_owner = program_id; - } - - post_states_entry.insert_entry(post.into_account()); - } - } - - /// Get an iterator over pre and post states of each account involved in the execution. - pub fn into_states_iter( - mut self, - ) -> impl ExactSizeIterator { - self.pre_states.into_iter().map(move |pre| { - let post = self - .post_states - .remove(&pre.account_id) - .expect("Account from pre states should exist in state diff"); - (pre, post) - }) - } -} - -fn compute_circuit_output( - execution_state: ExecutionState, - visibility_mask: &[u8], - private_account_keys: &[(NullifierPublicKey, SharedSecretKey)], - private_account_nsks: &[NullifierSecretKey], - private_account_membership_proofs: &[Option], -) -> PrivacyPreservingCircuitOutput { - let mut output = PrivacyPreservingCircuitOutput { - public_pre_states: Vec::new(), - public_post_states: Vec::new(), - ciphertexts: Vec::new(), - new_commitments: Vec::new(), - new_nullifiers: Vec::new(), - block_validity_window: execution_state.block_validity_window, - timestamp_validity_window: execution_state.timestamp_validity_window, - }; - - let states_iter = execution_state.into_states_iter(); - assert_eq!( - visibility_mask.len(), - states_iter.len(), - "Invalid visibility mask length" - ); - - let mut private_keys_iter = private_account_keys.iter(); - let mut private_nsks_iter = private_account_nsks.iter(); - let mut private_membership_proofs_iter = private_account_membership_proofs.iter(); - - let mut output_index = 0; - for (account_visibility_mask, (pre_state, post_state)) in - visibility_mask.iter().copied().zip(states_iter) - { - match account_visibility_mask { - 0 => { - // Public account - output.public_pre_states.push(pre_state); - output.public_post_states.push(post_state); - } - 1 | 2 => { - let Some((npk, shared_secret)) = private_keys_iter.next() else { - panic!("Missing private account key"); - }; - - assert_eq!( - AccountId::from(npk), - pre_state.account_id, - "AccountId mismatch" - ); - - let (new_nullifier, new_nonce) = if account_visibility_mask == 1 { - // Private account with authentication - - let Some(nsk) = private_nsks_iter.next() else { - panic!("Missing private account nullifier secret key"); - }; - - // Verify the nullifier public key - assert_eq!( - npk, - &NullifierPublicKey::from(nsk), - "Nullifier public key mismatch" - ); - - // Check pre_state authorization - assert!( - pre_state.is_authorized, - "Pre-state not authorized for authenticated private account" - ); - - let Some(membership_proof_opt) = private_membership_proofs_iter.next() else { - panic!("Missing membership proof"); - }; - - let new_nullifier = compute_nullifier_and_set_digest( - membership_proof_opt.as_ref(), - &pre_state.account, - npk, - nsk, - ); - - let new_nonce = pre_state.account.nonce.private_account_nonce_increment(nsk); - - (new_nullifier, new_nonce) - } else { - // Private account without authentication - - assert_eq!( - pre_state.account, - Account::default(), - "Found new private account with non default values", - ); - - assert!( - !pre_state.is_authorized, - "Found new private account marked as authorized." - ); - - let Some(membership_proof_opt) = private_membership_proofs_iter.next() else { - panic!("Missing membership proof"); - }; - - assert!( - membership_proof_opt.is_none(), - "Membership proof must be None for unauthorized accounts" - ); - - let nullifier = Nullifier::for_account_initialization(npk); - - let new_nonce = Nonce::private_account_nonce_init(npk); - - ((nullifier, DUMMY_COMMITMENT_HASH), new_nonce) - }; - output.new_nullifiers.push(new_nullifier); - - // Update post-state with new nonce - let mut post_with_updated_nonce = post_state; - post_with_updated_nonce.nonce = new_nonce; - - // Compute commitment - let commitment_post = Commitment::new(npk, &post_with_updated_nonce); - - // Encrypt and push post state - let encrypted_account = EncryptionScheme::encrypt( - &post_with_updated_nonce, - shared_secret, - &commitment_post, - output_index, - ); - - output.new_commitments.push(commitment_post); - output.ciphertexts.push(encrypted_account); - output_index = output_index - .checked_add(1) - .unwrap_or_else(|| panic!("Too many private accounts, output index overflow")); - } - _ => panic!("Invalid visibility mask value"), - } - } - - assert!( - private_keys_iter.next().is_none(), - "Too many private account keys" - ); - - assert!( - private_nsks_iter.next().is_none(), - "Too many private account nullifier secret keys" - ); - - assert!( - private_membership_proofs_iter.next().is_none(), - "Too many private account membership proofs" - ); - - output -} - -fn compute_nullifier_and_set_digest( - membership_proof_opt: Option<&MembershipProof>, - pre_account: &Account, - npk: &NullifierPublicKey, - nsk: &NullifierSecretKey, -) -> (Nullifier, CommitmentSetDigest) { - membership_proof_opt.as_ref().map_or_else( - || { - assert_eq!( - *pre_account, - Account::default(), - "Found new private account with non default values" - ); - - // Compute initialization nullifier - let nullifier = Nullifier::for_account_initialization(npk); - (nullifier, DUMMY_COMMITMENT_HASH) - }, - |membership_proof| { - // Compute commitment set digest associated with provided auth path - let commitment_pre = Commitment::new(npk, pre_account); - let set_digest = compute_digest_for_path(&commitment_pre, membership_proof); - - // Compute update nullifier - let nullifier = Nullifier::for_account_update(&commitment_pre, nsk); - (nullifier, set_digest) - }, - ) -} - -fn main() { - let PrivacyPreservingCircuitInput { - program_outputs, - visibility_mask, - private_account_keys, - private_account_nsks, - private_account_membership_proofs, - program_id, - } = env::read(); - - let execution_state = - ExecutionState::derive_from_outputs(&visibility_mask, program_id, program_outputs); - - let output = compute_circuit_output( - execution_state, - &visibility_mask, - &private_account_keys, - &private_account_nsks, - &private_account_membership_proofs, - ); - - env::commit(&output); -} diff --git a/program_methods/guest/src/bin/privacy_preserving_circuit/execution_state.rs b/program_methods/guest/src/bin/privacy_preserving_circuit/execution_state.rs new file mode 100644 index 00000000..c06698d6 --- /dev/null +++ b/program_methods/guest/src/bin/privacy_preserving_circuit/execution_state.rs @@ -0,0 +1,596 @@ +use std::{ + collections::{HashMap, HashSet, VecDeque, hash_map::Entry}, + convert::Infallible, +}; + +use nssa_core::{ + Identifier, InputAccountIdentity, NullifierPublicKey, + account::{Account, AccountId, AccountWithMetadata}, + program::{ + AccountPostState, BlockValidityWindow, ChainedCall, Claim, DEFAULT_PROGRAM_ID, + MAX_NUMBER_CHAINED_CALLS, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow, + validate_execution, + }, +}; +use risc0_zkvm::{guest::env, serde::to_vec}; + +/// State of the involved accounts before and after program execution. +pub struct ExecutionState { + pre_states: Vec, + post_states: HashMap, + 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, + /// 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` + /// under the private derivation. Binding is an idempotent property, not an event: the same + /// position can legitimately be bound through both paths in the same tx (e.g. a program + /// claims a private PDA and then delegates it to a callee), and the map uses `contains_key`, + /// not `assert!(insert)`. After the main loop, every private-PDA position must appear in this + /// map; otherwise the npk is unbound and the circuit rejects. + /// The stored `(ProgramId, PdaSeed)` is the owner program and seed, used in + /// `compute_circuit_output` to construct `PrivateAccountKind::Pda { program_id, seed, + /// identifier }`. + private_pda_bound_positions: HashMap, + /// Across the whole transaction, each `(program_id, seed)` pair may resolve to at most one + /// `AccountId`. A seed under a program can derive a family of accounts, one public PDA and + /// one private PDA per distinct npk. Without this check, a single `pda_seeds: [S]` entry in + /// a chained call could authorize multiple family members at once (different npks under the + /// same seed) and let a callee mix balances across them. Every claim and every + /// caller-authorization resolution is recorded here, either as a new `(program, seed)` → + /// `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, + /// 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 + /// 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, + authorized_accounts: HashSet, +} + +impl ExecutionState { + /// Validate program outputs and derive the overall execution state. + pub fn derive_from_outputs( + account_identities: &[InputAccountIdentity], + program_id: ProgramId, + program_outputs: Vec, + ) -> Self { + // Build position → (npk, identifier) map for private-PDA pre_states, indexed by position + // 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(); + 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)); + } + } + + let block_valid_from = program_outputs + .iter() + .filter_map(|output| output.block_validity_window.start()) + .max(); + let block_valid_until = program_outputs + .iter() + .filter_map(|output| output.block_validity_window.end()) + .min(); + let ts_valid_from = program_outputs + .iter() + .filter_map(|output| output.timestamp_validity_window.start()) + .max(); + let ts_valid_until = program_outputs + .iter() + .filter_map(|output| output.timestamp_validity_window.end()) + .min(); + + let block_validity_window: BlockValidityWindow = (block_valid_from, block_valid_until) + .try_into() + .expect( + "There should be non empty intersection in the program output block validity windows", + ); + let timestamp_validity_window: TimestampValidityWindow = + (ts_valid_from, ts_valid_until) + .try_into() + .expect( + "There should be non empty intersection in the program output timestamp validity windows", + ); + + let mut execution_state = Self { + pre_states: Vec::new(), + post_states: HashMap::new(), + block_validity_window, + timestamp_validity_window, + private_pda_bound_positions: HashMap::new(), + pda_family_binding: HashMap::new(), + private_pda_npk_by_position, + authorized_accounts: HashSet::new(), + }; + + let Some(first_output) = program_outputs.first() else { + panic!("No program outputs provided"); + }; + + let initial_call = ChainedCall { + program_id, + instruction_data: first_output.instruction_data.clone(), + pre_states: first_output.pre_states.clone(), + pda_seeds: Vec::new(), + }; + let mut chained_calls = VecDeque::from_iter([(initial_call, None)]); + + let mut program_outputs_iter = program_outputs.into_iter(); + let mut chain_calls_counter = 0; + + while let Some((chained_call, caller_program_id)) = chained_calls.pop_front() { + assert!( + chain_calls_counter <= MAX_NUMBER_CHAINED_CALLS, + "Max chained calls depth is exceeded" + ); + + let Some(program_output) = program_outputs_iter.next() else { + panic!("Insufficient program outputs for chained calls"); + }; + + // Check that instruction data in chained call is the instruction data in program output + assert_eq!( + chained_call.instruction_data, program_output.instruction_data, + "Mismatched instruction data between chained call and program output" + ); + + // Check that `program_output` is consistent with the execution of the corresponding + // program. + let program_output_words = + &to_vec(&program_output).expect("program_output must be serializable"); + env::verify(chained_call.program_id, program_output_words).unwrap_or_else( + |_: Infallible| unreachable!("Infallible error is never constructed"), + ); + + // Verify that the program output's self_program_id matches the expected program ID. + // This ensures the proof commits to which program produced the output. + assert_eq!( + program_output.self_program_id, chained_call.program_id, + "Program output self_program_id does not match chained call program_id" + ); + + // Verify that the program output's caller_program_id matches the actual caller. + // This prevents a malicious user from privately executing an internal function + // by spoofing caller_program_id (e.g. passing caller_program_id = self_program_id + // to bypass access control checks). + assert_eq!( + program_output.caller_program_id, caller_program_id, + "Program output caller_program_id does not match actual caller" + ); + + // Check that the program is well behaved. + // See the # Programs section for the definition of the `validate_execution` method. + let validated_execution = validate_execution( + &program_output.pre_states, + &program_output.post_states, + chained_call.program_id, + ); + if let Err(err) = validated_execution { + panic!( + "Invalid program behavior in program {:?}: {err}", + chained_call.program_id + ); + } + + for next_call in program_output.chained_calls.iter().rev() { + chained_calls.push_front((next_call.clone(), Some(chained_call.program_id))); + } + + execution_state.validate_and_sync_states( + account_identities, + chained_call.program_id, + caller_program_id, + &chained_call.pda_seeds, + program_output.pre_states, + program_output.post_states, + ); + chain_calls_counter = chain_calls_counter.checked_add(1).expect( + "Chain calls counter should not overflow as it checked before incrementing", + ); + } + + assert!( + program_outputs_iter.next().is_none(), + "Inner call without a chained call found", + ); + + // Every private-PDA pre_state must have had its npk bound to its account_id, either via + // a `Claim::Pda(seed)` in some program's post_state or via a caller's `pda_seeds` + // matching the private derivation. An unbound private-PDA pre_state has no + // cryptographic link between the supplied npk and the account_id, and must be rejected. + for (pos, account_identity) in account_identities.iter().enumerate() { + if account_identity.is_private_pda() { + assert!( + execution_state + .private_pda_bound_positions + .contains_key(&pos), + "private PDA pre_state at position {pos} has no proven (seed, npk) binding via Claim::Pda or caller pda_seeds" + ); + } + } + + // Check that all modified uninitialized accounts were claimed + for (account_id, post) in execution_state + .pre_states + .iter() + .filter(|a| a.account.program_owner == DEFAULT_PROGRAM_ID) + .map(|a| { + let post = execution_state + .post_states + .get(&a.account_id) + .expect("Post state must exist for pre state"); + (a, post) + }) + .filter(|(pre_default, post)| pre_default.account != **post) + .map(|(pre, post)| (pre.account_id, post)) + { + assert_ne!( + post.program_owner, DEFAULT_PROGRAM_ID, + "Account {account_id} was modified but not claimed" + ); + } + + execution_state + } + + /// Validate program pre and post states and populate the execution state. + fn validate_and_sync_states( + &mut self, + account_identities: &[InputAccountIdentity], + program_id: ProgramId, + caller_program_id: Option, + caller_pda_seeds: &[PdaSeed], + output_pre_states: Vec, + output_post_states: Vec, + ) { + for (pre, mut post) in output_pre_states.into_iter().zip(output_post_states) { + let pre_account_id = pre.account_id; + let pre_is_authorized = pre.is_authorized; + let post_states_entry = self.post_states.entry(pre.account_id); + match &post_states_entry { + Entry::Occupied(occupied) => { + #[expect( + clippy::shadow_unrelated, + reason = "Shadowing is intentional to use all fields" + )] + let AccountWithMetadata { + account: pre_account, + account_id: pre_account_id, + is_authorized: pre_is_authorized, + } = pre; + + // Ensure that new pre state is the same as known post state + assert_eq!( + occupied.get(), + &pre_account, + "Inconsistent pre state for account {pre_account_id}", + ); + + let (previous_is_authorized, pre_state_position) = self + .pre_states + .iter() + .enumerate() + .find(|(_, acc)| acc.account_id == pre_account_id) + .map_or_else( + || panic!( + "Pre state must exist in execution state for account {pre_account_id}", + ), + |(pos, acc)| (acc.is_authorized, pos) + ); + + 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, + &mut self.authorized_accounts, + pre_account_id, + pre_state_position, + caller_program_id, + caller_pda_seeds, + previous_is_authorized, + ); + + assert_eq!( + pre_is_authorized, is_authorized, + "Inconsistent authorization for account {pre_account_id}", + ); + } + Entry::Vacant(_) => { + // Pre state for the initial call + let pre_state_position = self.pre_states.len(); + let external_seed = match account_identities.get(pre_state_position) { + Some(InputAccountIdentity::PrivatePdaInit { + npk, + identifier, + seed: Some((seed, authority_program_id)), + .. + }) => { + let expected = AccountId::for_private_pda( + authority_program_id, + seed, + npk, + *identifier, + ); + assert_eq!( + pre_account_id, expected, + "External seed mismatch for PrivatePdaInit at position {pre_state_position}" + ); + Some((*seed, *authority_program_id)) + } + Some(InputAccountIdentity::PrivatePdaUpdate { + nsk, + identifier, + seed: Some((seed, authority_program_id)), + .. + }) => { + let npk = NullifierPublicKey::from(nsk); + let expected = AccountId::for_private_pda( + authority_program_id, + seed, + &npk, + *identifier, + ); + assert_eq!( + pre_account_id, expected, + "External seed mismatch for PrivatePdaUpdate at position {pre_state_position}" + ); + Some((*seed, *authority_program_id)) + } + _ => None, + }; + // External seed is only consulted the first time the account is seen. + // Subsequent calls need no re-check because the entry is already recorded on + // private_pda_bound_positions. + if let Some((seed, authority_program_id)) = external_seed { + assert!( + !pre.is_authorized, + "Private PDA with externally-provided seed must not be authorized at position {pre_state_position}" + ); + bind_private_pda_position( + &mut self.private_pda_bound_positions, + pre_state_position, + authority_program_id, + seed, + ); + assert_family_binding( + &mut self.pda_family_binding, + authority_program_id, + seed, + pre_account_id, + ); + } + self.pre_states.push(pre); + } + } + + if let Some(claim) = post.required_claim() { + // The invoked program can only claim accounts with default program id. + assert_eq!( + post.account().program_owner, + DEFAULT_PROGRAM_ID, + "Cannot claim an initialized account {pre_account_id}" + ); + + let pre_state_position = self + .pre_states + .iter() + .position(|acc| acc.account_id == pre_account_id) + .expect("Pre state must exist at this point"); + + let account_identity = &account_identities[pre_state_position]; + if account_identity.is_public() { + match claim { + Claim::Authorized => { + // Note: no need to check authorized pdas because we have already + // checked consistency of authorization above. + assert!( + pre_is_authorized, + "Cannot claim unauthorized account {pre_account_id}" + ); + } + Claim::Pda(seed) => { + let pda = AccountId::for_public_pda(&program_id, &seed); + assert_eq!( + pre_account_id, pda, + "Invalid PDA claim for account {pre_account_id} which does not match derived PDA {pda}" + ); + assert_family_binding( + &mut self.pda_family_binding, + program_id, + seed, + pre_account_id, + ); + } + } + } else { + // Private accounts: don't enforce the claim semantics. Unauthorized private + // claiming is intentionally allowed + match claim { + Claim::Authorized => {} + Claim::Pda(seed) => { + let (npk, identifier) = self + .private_pda_npk_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); + assert_eq!( + pre_account_id, pda, + "Invalid private PDA claim for account {pre_account_id}" + ); + bind_private_pda_position( + &mut self.private_pda_bound_positions, + pre_state_position, + program_id, + seed, + ); + assert_family_binding( + &mut self.pda_family_binding, + program_id, + seed, + pre_account_id, + ); + } + } + } + + post.account_mut().program_owner = program_id; + } + + post_states_entry.insert_entry(post.into_account()); + } + } + + /// Consume self and yield the validity windows, the per-position PDA seed/program map + /// (recorded during `derive_from_outputs`), and an iterator over pre and post states of each + /// account involved in the execution. Returning everything together keeps the + /// fields module-private rather than forcing them visible to downstream consumers. + #[expect( + clippy::type_complexity, + reason = "tuple bundles four exit values from one consuming call so all fields stay private; a struct would only rename it" + )] + pub fn into_parts( + mut self, + ) -> ( + BlockValidityWindow, + TimestampValidityWindow, + HashMap, + impl ExactSizeIterator, + ) { + let block_validity_window = self.block_validity_window; + let timestamp_validity_window = self.timestamp_validity_window; + let pda_seed_by_position = std::mem::take(&mut self.private_pda_bound_positions); + let states_iter = self.pre_states.into_iter().map(move |pre| { + let post = self + .post_states + .remove(&pre.account_id) + .expect("Account from pre states should exist in state diff"); + (pre, post) + }); + ( + block_validity_window, + timestamp_validity_window, + pda_seed_by_position, + states_iter, + ) + } +} + +/// Record or re-verify the `(program_id, seed) → account_id` family binding for the +/// transaction. Any claim or caller-seed authorization that resolves a `pre_state` under +/// `(program_id, seed)` must agree with every prior resolution of the same pair; otherwise a +/// single `pda_seeds: [seed]` entry could authorize multiple private-PDA family members at +/// once (different npks under the same seed) and let a callee mix balances across them. Free +/// function so callers can pass `&mut self.pda_family_binding` without holding a borrow on +/// the surrounding struct's other fields. +fn assert_family_binding( + bindings: &mut HashMap<(ProgramId, PdaSeed), AccountId>, + program_id: ProgramId, + seed: PdaSeed, + account_id: AccountId, +) { + match bindings.entry((program_id, seed)) { + Entry::Vacant(e) => { + e.insert(account_id); + } + Entry::Occupied(e) => { + assert_eq!( + *e.get(), + account_id, + "Two different accounts resolved under the same (program, seed) in one transaction: existing {}, new {account_id}", + e.get() + ); + } + } +} + +fn bind_private_pda_position( + map: &mut HashMap, + position: usize, + program_id: ProgramId, + seed: PdaSeed, +) { + match map.entry(position) { + Entry::Occupied(e) => assert_eq!( + *e.get(), + (program_id, seed), + "Duplicate binding at position {position}: conflicting (program_id, seed)" + ), + Entry::Vacant(e) => { + e.insert((program_id, seed)); + } + } +} + +/// Resolve the authorization state of a `pre_state` seen again in a chained call and record +/// any resulting bindings. Returns `true` if the `pre_state` is authorized through either a +/// previously-seen authorization or a matching caller seed (under the public or private +/// derivation). When a caller seed matches, also records the `(caller, seed) → account_id` +/// family binding and, for the private form, marks the position in +/// `private_pda_bound_positions`. Only reachable when `caller_program_id.is_some()`, +/// top-level flows have no caller-emitted seeds, so binding at top level must come from the +/// claim path. Free function so callers can pass individual `&mut self.*` field borrows +/// without holding a borrow on the surrounding struct's other fields. +#[expect( + clippy::too_many_arguments, + reason = "breaking out a context struct does not buy us anything here" +)] +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, + authorized_accounts: &mut HashSet, + pre_account_id: AccountId, + pre_state_position: usize, + caller_program_id: Option, + caller_pda_seeds: &[PdaSeed], + previous_is_authorized: bool, +) -> bool { + let matched_caller_seed: Option<(PdaSeed, bool, ProgramId)> = + caller_program_id.and_then(|caller| { + caller_pda_seeds.iter().find_map(|seed| { + 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 + { + return Some((*seed, true, caller)); + } + None + }) + }); + + if let Some((seed, is_private_form, caller)) = matched_caller_seed { + assert_family_binding(pda_family_binding, caller, seed, pre_account_id); + if is_private_form { + bind_private_pda_position( + private_pda_bound_positions, + pre_state_position, + caller, + seed, + ); + } + } + + if authorized_accounts.contains(&pre_account_id) { + return true; + } + + let authorized = previous_is_authorized || matched_caller_seed.is_some(); + if authorized { + authorized_accounts.insert(pre_account_id); + } + authorized +} diff --git a/program_methods/guest/src/bin/privacy_preserving_circuit/main.rs b/program_methods/guest/src/bin/privacy_preserving_circuit/main.rs new file mode 100644 index 00000000..9441c27e --- /dev/null +++ b/program_methods/guest/src/bin/privacy_preserving_circuit/main.rs @@ -0,0 +1,23 @@ +use nssa_core::PrivacyPreservingCircuitInput; +use risc0_zkvm::guest::env; + +mod execution_state; +mod output; + +fn main() { + let PrivacyPreservingCircuitInput { + program_outputs, + account_identities, + program_id, + } = env::read(); + + let execution_state = execution_state::ExecutionState::derive_from_outputs( + &account_identities, + program_id, + program_outputs, + ); + + let output = output::compute_circuit_output(execution_state, &account_identities); + + env::commit(&output); +} diff --git a/program_methods/guest/src/bin/privacy_preserving_circuit/output.rs b/program_methods/guest/src/bin/privacy_preserving_circuit/output.rs new file mode 100644 index 00000000..6e302401 --- /dev/null +++ b/program_methods/guest/src/bin/privacy_preserving_circuit/output.rs @@ -0,0 +1,289 @@ +use nssa_core::{ + Commitment, CommitmentSetDigest, DUMMY_COMMITMENT_HASH, EncryptionScheme, InputAccountIdentity, + MembershipProof, Nullifier, NullifierPublicKey, NullifierSecretKey, + PrivacyPreservingCircuitOutput, PrivateAccountKind, SharedSecretKey, + account::{Account, AccountId, Nonce}, + compute_digest_for_path, +}; + +use crate::execution_state::ExecutionState; + +pub fn compute_circuit_output( + execution_state: ExecutionState, + account_identities: &[InputAccountIdentity], +) -> PrivacyPreservingCircuitOutput { + let (block_validity_window, timestamp_validity_window, pda_seed_by_position, states_iter) = + execution_state.into_parts(); + let mut output = PrivacyPreservingCircuitOutput { + public_pre_states: Vec::new(), + public_post_states: Vec::new(), + ciphertexts: Vec::new(), + new_commitments: Vec::new(), + new_nullifiers: Vec::new(), + block_validity_window, + timestamp_validity_window, + }; + + assert_eq!( + account_identities.len(), + states_iter.len(), + "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() + { + match account_identity { + InputAccountIdentity::Public => { + output.public_pre_states.push(pre_state); + output.public_post_states.push(post_state); + } + InputAccountIdentity::PrivateAuthorizedInit { + ssk, + nsk, + identifier, + } => { + let npk = NullifierPublicKey::from(nsk); + let account_id = AccountId::for_regular_private_account(&npk, *identifier); + + assert_eq!(account_id, pre_state.account_id, "AccountId mismatch"); + assert!( + pre_state.is_authorized, + "Pre-state not authorized for authenticated private account" + ); + assert_eq!( + pre_state.account, + Account::default(), + "Found new private account with non default values" + ); + + let new_nullifier = ( + Nullifier::for_account_initialization(&account_id), + DUMMY_COMMITMENT_HASH, + ); + let new_nonce = pre_state.account.nonce.private_account_nonce_increment(nsk); + + emit_private_output( + &mut output, + &mut output_index, + post_state, + &account_id, + &PrivateAccountKind::Regular(*identifier), + ssk, + new_nullifier, + new_nonce, + ); + } + InputAccountIdentity::PrivateAuthorizedUpdate { + ssk, + nsk, + membership_proof, + identifier, + } => { + let npk = NullifierPublicKey::from(nsk); + let account_id = AccountId::for_regular_private_account(&npk, *identifier); + + assert_eq!(account_id, pre_state.account_id, "AccountId mismatch"); + assert!( + pre_state.is_authorized, + "Pre-state not authorized for authenticated private account" + ); + + let new_nullifier = compute_update_nullifier_and_set_digest( + membership_proof, + &pre_state.account, + &account_id, + nsk, + ); + let new_nonce = pre_state.account.nonce.private_account_nonce_increment(nsk); + + emit_private_output( + &mut output, + &mut output_index, + post_state, + &account_id, + &PrivateAccountKind::Regular(*identifier), + ssk, + new_nullifier, + new_nonce, + ); + } + InputAccountIdentity::PrivateUnauthorized { + npk, + ssk, + identifier, + } => { + let account_id = AccountId::for_regular_private_account(npk, *identifier); + + assert_eq!(account_id, pre_state.account_id, "AccountId mismatch"); + assert_eq!( + pre_state.account, + Account::default(), + "Found new private account with non default values", + ); + assert!( + !pre_state.is_authorized, + "Found new private account marked as authorized." + ); + + let new_nullifier = ( + Nullifier::for_account_initialization(&account_id), + DUMMY_COMMITMENT_HASH, + ); + let new_nonce = Nonce::private_account_nonce_init(&account_id); + + emit_private_output( + &mut output, + &mut output_index, + post_state, + &account_id, + &PrivateAccountKind::Regular(*identifier), + ssk, + new_nullifier, + new_nonce, + ); + } + InputAccountIdentity::PrivatePdaInit { + npk: _, + ssk, + identifier, + 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 + // for the binding check; we use `pre_state.account_id` directly for nullifier + // and commitment derivation. + assert!( + !pre_state.is_authorized, + "PrivatePdaInit requires unauthorized pre_state" + ); + assert_eq!( + pre_state.account, + Account::default(), + "New private PDA must be default" + ); + + let new_nullifier = ( + Nullifier::for_account_initialization(&pre_state.account_id), + DUMMY_COMMITMENT_HASH, + ); + let new_nonce = Nonce::private_account_nonce_init(&pre_state.account_id); + + let account_id = pre_state.account_id; + let (authority_program_id, seed) = pda_seed_by_position + .get(&pos) + .expect("PrivatePdaInit position must be in pda_seed_by_position"); + emit_private_output( + &mut output, + &mut output_index, + post_state, + &account_id, + &PrivateAccountKind::Pda { + program_id: *authority_program_id, + seed: *seed, + identifier: *identifier, + }, + ssk, + new_nullifier, + new_nonce, + ); + } + InputAccountIdentity::PrivatePdaUpdate { + ssk, + nsk, + membership_proof, + identifier, + seed: external_seed, + } => { + // With an external seed the binding comes from the circuit input and the + // pre_state is intentionally unauthorized; without one the binding comes from + // a Claim or caller pda_seeds, so the pre_state must already be authorized. + // When `external_seed` is `Some`, execution_state already asserted + // `!pre_state.is_authorized`. + assert!( + pre_state.is_authorized ^ external_seed.is_some(), + "PrivatePdaUpdate requires authorized pre_state or external seed" + ); + + let new_nullifier = compute_update_nullifier_and_set_digest( + membership_proof, + &pre_state.account, + &pre_state.account_id, + nsk, + ); + let new_nonce = pre_state.account.nonce.private_account_nonce_increment(nsk); + + let account_id = pre_state.account_id; + let (authority_program_id, seed) = pda_seed_by_position + .get(&pos) + .expect("PrivatePdaUpdate position must be in pda_seed_by_position"); + emit_private_output( + &mut output, + &mut output_index, + post_state, + &account_id, + &PrivateAccountKind::Pda { + program_id: *authority_program_id, + seed: *seed, + identifier: *identifier, + }, + ssk, + new_nullifier, + new_nonce, + ); + } + } + } + + output +} + +#[expect( + clippy::too_many_arguments, + reason = "All seven 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, + 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 encrypted_account = EncryptionScheme::encrypt( + &post_with_updated_nonce, + kind, + shared_secret, + &commitment_post, + *output_index, + ); + + output.new_commitments.push(commitment_post); + output.ciphertexts.push(encrypted_account); + *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( + membership_proof: &MembershipProof, + pre_account: &Account, + account_id: &AccountId, + nsk: &NullifierSecretKey, +) -> (Nullifier, CommitmentSetDigest) { + let commitment_pre = Commitment::new(account_id, pre_account); + let set_digest = compute_digest_for_path(&commitment_pre, membership_proof); + let nullifier = Nullifier::for_account_update(&commitment_pre, nsk); + (nullifier, set_digest) +} diff --git a/program_methods/guest/src/bin/token.rs b/program_methods/guest/src/bin/token.rs index 421d43ef..68205d77 100644 --- a/program_methods/guest/src/bin/token.rs +++ b/program_methods/guest/src/bin/token.rs @@ -12,6 +12,8 @@ use token_program::core::Instruction; fn main() { let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction, }, @@ -81,5 +83,12 @@ fn main() { } }; - ProgramOutput::new(instruction_words, pre_states_clone, post_states).write(); + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states_clone, + post_states, + ) + .write(); } diff --git a/program_methods/guest/src/bin/vault.rs b/program_methods/guest/src/bin/vault.rs new file mode 100644 index 00000000..c56c1a7f --- /dev/null +++ b/program_methods/guest/src/bin/vault.rs @@ -0,0 +1,94 @@ +//! Vault program which allows users to create vault accounts and transfer funds to them. +//! Funds can later be claimed from the vault accounts by their owners. +//! +//! The program is designed to be used in conjunction with the authenticated transfer program, which +//! performs the actual transfer of funds from the vault accounts. + +use authenticated_transfer_core::Instruction as AuthTransferInstruction; +use nssa_core::program::{ + AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_nssa_inputs, +}; +use vault_core::Instruction; + +fn unchanged_post_states( + pre_states: &[nssa_core::account::AccountWithMetadata], +) -> Vec { + pre_states + .iter() + .map(|pre_state| AccountPostState::new(pre_state.account.clone())) + .collect() +} + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_nssa_inputs::(); + + let pre_states_clone = pre_states.clone(); + let post_states = unchanged_post_states(&pre_states_clone); + + let chained_calls = match instruction { + Instruction::Transfer { + recipient_id, + amount, + } => { + let [sender, recipient_vault] = pre_states + .try_into() + .expect("Transfer requires exactly 2 accounts"); + + let seed = vault_core::compute_vault_seed(recipient_id); + + let mut recipient_vault_for_callee = recipient_vault; + recipient_vault_for_callee.is_authorized = true; + + vec![ + ChainedCall::new( + sender.account.program_owner, + vec![sender, recipient_vault_for_callee], + &AuthTransferInstruction::Transfer { amount }, + ) + .with_pda_seeds(vec![seed]), + ] + } + Instruction::Claim { amount } => { + let [owner, owner_vault] = pre_states + .try_into() + .expect("Claim requires exactly 2 accounts"); + + assert!( + owner.is_authorized, + "Owner must be authorized to claim from the vault" + ); + + let seed = vault_core::compute_vault_seed(owner.account_id); + + let mut owner_vault_for_callee = owner_vault; + owner_vault_for_callee.is_authorized = true; + + vec![ + ChainedCall::new( + owner_vault_for_callee.account.program_owner, + vec![owner_vault_for_callee, owner], + &AuthTransferInstruction::Transfer { amount }, + ) + .with_pda_seeds(vec![seed]), + ] + } + }; + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states_clone, + post_states, + ) + .with_chained_calls(chained_calls) + .write(); +} diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index 85efd00d..6e005c9e 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -68,11 +68,27 @@ pub enum Instruction { /// - User Holding Account for Token A /// - User Holding Account for Token B Either User Holding Account for Token A or Token B is /// authorized. - Swap { + SwapExactInput { swap_amount_in: u128, min_amount_out: u128, token_definition_id_in: AccountId, }, + + /// Swap tokens specifying the exact desired output amount, + /// while maintaining the Pool constant product. + /// + /// Required accounts: + /// - AMM Pool (initialized) + /// - Vault Holding Account for Token A (initialized) + /// - Vault Holding Account for Token B (initialized) + /// - User Holding Account for Token A + /// - User Holding Account for Token B Either User Holding Account for Token A or Token B is + /// authorized. + SwapExactOutput { + exact_amount_out: u128, + max_amount_in: u128, + token_definition_id_in: AccountId, + }, } #[derive(Clone, Default, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] @@ -119,10 +135,10 @@ pub fn compute_pool_pda( definition_token_a_id: AccountId, definition_token_b_id: AccountId, ) -> AccountId { - AccountId::from(( + AccountId::for_public_pda( &amm_program_id, &compute_pool_pda_seed(definition_token_a_id, definition_token_b_id), - )) + ) } #[must_use] @@ -159,10 +175,10 @@ pub fn compute_vault_pda( pool_id: AccountId, definition_token_id: AccountId, ) -> AccountId { - AccountId::from(( + AccountId::for_public_pda( &amm_program_id, &compute_vault_pda_seed(pool_id, definition_token_id), - )) + ) } #[must_use] @@ -183,7 +199,7 @@ pub fn compute_vault_pda_seed(pool_id: AccountId, definition_token_id: AccountId #[must_use] pub fn compute_liquidity_token_pda(amm_program_id: ProgramId, pool_id: AccountId) -> AccountId { - AccountId::from((&amm_program_id, &compute_liquidity_token_pda_seed(pool_id))) + AccountId::for_public_pda(&amm_program_id, &compute_liquidity_token_pda_seed(pool_id)) } #[must_use] diff --git a/programs/amm/src/swap.rs b/programs/amm/src/swap.rs index cb64f5eb..22f3792a 100644 --- a/programs/amm/src/swap.rs +++ b/programs/amm/src/swap.rs @@ -4,21 +4,14 @@ use nssa_core::{ program::{AccountPostState, ChainedCall}, }; -#[expect(clippy::too_many_arguments, reason = "TODO: Fix later")] -#[must_use] -pub fn swap( - pool: AccountWithMetadata, - vault_a: AccountWithMetadata, - vault_b: AccountWithMetadata, - user_holding_a: AccountWithMetadata, - user_holding_b: AccountWithMetadata, - swap_amount_in: u128, - min_amount_out: u128, - token_in_id: AccountId, -) -> (Vec, Vec) { - // Verify vaults are in fact vaults +/// Validates swap setup: checks pool is active, vaults match, and reserves are sufficient. +fn validate_swap_setup( + pool: &AccountWithMetadata, + vault_a: &AccountWithMetadata, + vault_b: &AccountWithMetadata, +) -> PoolDefinition { let pool_def_data = PoolDefinition::try_from(&pool.account.data) - .expect("Swap: AMM Program expects a valid Pool Definition Account"); + .expect("AMM Program expects a valid Pool Definition Account"); assert!(pool_def_data.active, "Pool is inactive"); assert_eq!( @@ -30,16 +23,14 @@ pub fn swap( "Vault B was not provided" ); - // fetch pool reserves - // validates reserves is at least the vaults' balances let vault_a_token_holding = token_core::TokenHolding::try_from(&vault_a.account.data) - .expect("Swap: AMM Program expects a valid Token Holding Account for Vault A"); + .expect("AMM Program expects a valid Token Holding Account for Vault A"); let token_core::TokenHolding::Fungible { definition_id: _, balance: vault_a_balance, } = vault_a_token_holding else { - panic!("Swap: AMM Program expects a valid Fungible Token Holding Account for Vault A"); + panic!("AMM Program expects a valid Fungible Token Holding Account for Vault A"); }; assert!( @@ -48,13 +39,13 @@ pub fn swap( ); let vault_b_token_holding = token_core::TokenHolding::try_from(&vault_b.account.data) - .expect("Swap: AMM Program expects a valid Token Holding Account for Vault B"); + .expect("AMM Program expects a valid Token Holding Account for Vault B"); let token_core::TokenHolding::Fungible { definition_id: _, balance: vault_b_balance, } = vault_b_token_holding else { - panic!("Swap: AMM Program expects a valid Fungible Token Holding Account for Vault B"); + panic!("AMM Program expects a valid Fungible Token Holding Account for Vault B"); }; assert!( @@ -62,6 +53,59 @@ pub fn swap( "Reserve for Token B exceeds vault balance" ); + pool_def_data +} + +/// Creates post-state and returns reserves after swap. +#[expect(clippy::too_many_arguments, reason = "TODO: Fix later")] +#[expect( + clippy::needless_pass_by_value, + reason = "consistent with codebase style" +)] +fn create_swap_post_states( + pool: AccountWithMetadata, + pool_def_data: PoolDefinition, + vault_a: AccountWithMetadata, + vault_b: AccountWithMetadata, + user_holding_a: AccountWithMetadata, + user_holding_b: AccountWithMetadata, + deposit_a: u128, + withdraw_a: u128, + deposit_b: u128, + withdraw_b: u128, +) -> Vec { + let mut pool_post = pool.account; + let pool_post_definition = PoolDefinition { + reserve_a: pool_def_data.reserve_a + deposit_a - withdraw_a, + reserve_b: pool_def_data.reserve_b + deposit_b - withdraw_b, + ..pool_def_data + }; + + pool_post.data = Data::from(&pool_post_definition); + + vec![ + AccountPostState::new(pool_post), + AccountPostState::new(vault_a.account), + AccountPostState::new(vault_b.account), + AccountPostState::new(user_holding_a.account), + AccountPostState::new(user_holding_b.account), + ] +} + +#[expect(clippy::too_many_arguments, reason = "TODO: Fix later")] +#[must_use] +pub fn swap_exact_input( + pool: AccountWithMetadata, + vault_a: AccountWithMetadata, + vault_b: AccountWithMetadata, + user_holding_a: AccountWithMetadata, + user_holding_b: AccountWithMetadata, + swap_amount_in: u128, + min_amount_out: u128, + token_in_id: AccountId, +) -> (Vec, Vec) { + let pool_def_data = validate_swap_setup(&pool, &vault_a, &vault_b); + let (chained_calls, [deposit_a, withdraw_a], [deposit_b, withdraw_b]) = if token_in_id == pool_def_data.definition_token_a_id { let (chained_calls, deposit_a, withdraw_b) = swap_logic( @@ -95,23 +139,18 @@ pub fn swap( panic!("AccountId is not a token type for the pool"); }; - // Update pool account - let mut pool_post = pool.account; - let pool_post_definition = PoolDefinition { - reserve_a: pool_def_data.reserve_a + deposit_a - withdraw_a, - reserve_b: pool_def_data.reserve_b + deposit_b - withdraw_b, - ..pool_def_data - }; - - pool_post.data = Data::from(&pool_post_definition); - - let post_states = vec![ - AccountPostState::new(pool_post), - AccountPostState::new(vault_a.account), - AccountPostState::new(vault_b.account), - AccountPostState::new(user_holding_a.account), - AccountPostState::new(user_holding_b.account), - ]; + let post_states = create_swap_post_states( + pool, + pool_def_data, + vault_a, + vault_b, + user_holding_a, + user_holding_b, + deposit_a, + withdraw_a, + deposit_b, + withdraw_b, + ); (post_states, chained_calls) } @@ -131,7 +170,9 @@ fn swap_logic( // Compute withdraw amount // Maintains pool constant product // k = pool_def_data.reserve_a * pool_def_data.reserve_b; - let withdraw_amount = (reserve_withdraw_vault_amount * swap_amount_in) + let withdraw_amount = reserve_withdraw_vault_amount + .checked_mul(swap_amount_in) + .expect("reserve * amount_in overflows u128") / (reserve_deposit_vault_amount + swap_amount_in); // Slippage check @@ -175,3 +216,135 @@ fn swap_logic( (chained_calls, swap_amount_in, withdraw_amount) } + +#[expect(clippy::too_many_arguments, reason = "TODO: Fix later")] +#[must_use] +pub fn swap_exact_output( + pool: AccountWithMetadata, + vault_a: AccountWithMetadata, + vault_b: AccountWithMetadata, + user_holding_a: AccountWithMetadata, + user_holding_b: AccountWithMetadata, + exact_amount_out: u128, + max_amount_in: u128, + token_in_id: AccountId, +) -> (Vec, Vec) { + let pool_def_data = validate_swap_setup(&pool, &vault_a, &vault_b); + + let (chained_calls, [deposit_a, withdraw_a], [deposit_b, withdraw_b]) = + if token_in_id == pool_def_data.definition_token_a_id { + let (chained_calls, deposit_a, withdraw_b) = exact_output_swap_logic( + user_holding_a.clone(), + vault_a.clone(), + vault_b.clone(), + user_holding_b.clone(), + exact_amount_out, + max_amount_in, + pool_def_data.reserve_a, + pool_def_data.reserve_b, + pool.account_id, + ); + + (chained_calls, [deposit_a, 0], [0, withdraw_b]) + } else if token_in_id == pool_def_data.definition_token_b_id { + let (chained_calls, deposit_b, withdraw_a) = exact_output_swap_logic( + user_holding_b.clone(), + vault_b.clone(), + vault_a.clone(), + user_holding_a.clone(), + exact_amount_out, + max_amount_in, + pool_def_data.reserve_b, + pool_def_data.reserve_a, + pool.account_id, + ); + + (chained_calls, [0, withdraw_a], [deposit_b, 0]) + } else { + panic!("AccountId is not a token type for the pool"); + }; + + let post_states = create_swap_post_states( + pool, + pool_def_data, + vault_a, + vault_b, + user_holding_a, + user_holding_b, + deposit_a, + withdraw_a, + deposit_b, + withdraw_b, + ); + + (post_states, chained_calls) +} + +#[expect(clippy::too_many_arguments, reason = "TODO: Fix later")] +fn exact_output_swap_logic( + user_deposit: AccountWithMetadata, + vault_deposit: AccountWithMetadata, + vault_withdraw: AccountWithMetadata, + user_withdraw: AccountWithMetadata, + exact_amount_out: u128, + max_amount_in: u128, + reserve_deposit_vault_amount: u128, + reserve_withdraw_vault_amount: u128, + pool_id: AccountId, +) -> (Vec, u128, u128) { + // Guard: exact_amount_out must be nonzero + assert_ne!(exact_amount_out, 0, "Exact amount out must be nonzero"); + + // Guard: exact_amount_out must be less than reserve_withdraw_vault_amount + assert!( + exact_amount_out < reserve_withdraw_vault_amount, + "Exact amount out exceeds reserve" + ); + + // Compute deposit amount using ceiling division + // Formula: amount_in = ceil(reserve_in * exact_amount_out / (reserve_out - exact_amount_out)) + let deposit_amount = reserve_deposit_vault_amount + .checked_mul(exact_amount_out) + .expect("reserve * amount_out overflows u128") + .div_ceil(reserve_withdraw_vault_amount - exact_amount_out); + + // Slippage check + assert!( + deposit_amount <= max_amount_in, + "Required input exceeds maximum amount in" + ); + + let token_program_id = user_deposit.account.program_owner; + + let mut chained_calls = Vec::new(); + chained_calls.push(ChainedCall::new( + token_program_id, + vec![user_deposit, vault_deposit], + &token_core::Instruction::Transfer { + amount_to_transfer: deposit_amount, + }, + )); + + let mut vault_withdraw = vault_withdraw; + vault_withdraw.is_authorized = true; + + let pda_seed = compute_vault_pda_seed( + pool_id, + token_core::TokenHolding::try_from(&vault_withdraw.account.data) + .expect("Exact Output Swap Logic: AMM Program expects valid token data") + .definition_id(), + ); + + chained_calls.push( + ChainedCall::new( + token_program_id, + vec![vault_withdraw, user_withdraw], + &token_core::Instruction::Transfer { + amount_to_transfer: exact_amount_out, + }, + ) + .with_pda_seeds(vec![pda_seed]), + ); + + (chained_calls, deposit_amount, exact_amount_out) +} diff --git a/programs/amm/src/tests.rs b/programs/amm/src/tests.rs index 14638f9d..a10a985d 100644 --- a/programs/amm/src/tests.rs +++ b/programs/amm/src/tests.rs @@ -14,7 +14,10 @@ use nssa_core::{ use token_core::{TokenDefinition, TokenHolding}; use crate::{ - add::add_liquidity, new_definition::new_definition, remove::remove_liquidity, swap::swap, + add::add_liquidity, + new_definition::new_definition, + remove::remove_liquidity, + swap::{swap_exact_input, swap_exact_output}, }; const TOKEN_PROGRAM_ID: ProgramId = [15; 8]; @@ -153,6 +156,10 @@ impl BalanceForTests { 200 } + fn max_amount_in() -> u128 { + 166 + } + fn vault_a_add_successful() -> u128 { 1_400 } @@ -243,6 +250,74 @@ impl ChainedCallForTests { ) } + fn cc_swap_exact_output_token_a_test_1() -> ChainedCall { + let swap_amount: u128 = 498; + + ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![ + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::vault_a_init(), + ], + &token_core::Instruction::Transfer { + amount_to_transfer: swap_amount, + }, + ) + } + + fn cc_swap_exact_output_token_b_test_1() -> ChainedCall { + let swap_amount: u128 = 166; + + let mut vault_b_auth = AccountWithMetadataForTests::vault_b_init(); + vault_b_auth.is_authorized = true; + + ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![vault_b_auth, AccountWithMetadataForTests::user_holding_b()], + &token_core::Instruction::Transfer { + amount_to_transfer: swap_amount, + }, + ) + .with_pda_seeds(vec![compute_vault_pda_seed( + IdForTests::pool_definition_id(), + IdForTests::token_b_definition_id(), + )]) + } + + fn cc_swap_exact_output_token_a_test_2() -> ChainedCall { + let swap_amount: u128 = 285; + + let mut vault_a_auth = AccountWithMetadataForTests::vault_a_init(); + vault_a_auth.is_authorized = true; + + ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![vault_a_auth, AccountWithMetadataForTests::user_holding_a()], + &token_core::Instruction::Transfer { + amount_to_transfer: swap_amount, + }, + ) + .with_pda_seeds(vec![compute_vault_pda_seed( + IdForTests::pool_definition_id(), + IdForTests::token_a_definition_id(), + )]) + } + + fn cc_swap_exact_output_token_b_test_2() -> ChainedCall { + let swap_amount: u128 = 200; + + ChainedCall::new( + TOKEN_PROGRAM_ID, + vec![ + AccountWithMetadataForTests::user_holding_b(), + AccountWithMetadataForTests::vault_b_init(), + ], + &token_core::Instruction::Transfer { + amount_to_transfer: swap_amount, + }, + ) + } + fn cc_add_token_a() -> ChainedCall { ChainedCall::new( TOKEN_PROGRAM_ID, @@ -829,6 +904,54 @@ impl AccountWithMetadataForTests { } } + fn pool_definition_swap_exact_output_test_1() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: ProgramId::default(), + balance: 0_u128, + data: Data::from(&PoolDefinition { + definition_token_a_id: IdForTests::token_a_definition_id(), + definition_token_b_id: IdForTests::token_b_definition_id(), + vault_a_id: IdForTests::vault_a_id(), + vault_b_id: IdForTests::vault_b_id(), + liquidity_pool_id: IdForTests::token_lp_definition_id(), + liquidity_pool_supply: BalanceForTests::lp_supply_init(), + reserve_a: 1498_u128, + reserve_b: 334_u128, + fees: 0_u128, + active: true, + }), + nonce: 0_u128.into(), + }, + is_authorized: true, + account_id: IdForTests::pool_definition_id(), + } + } + + fn pool_definition_swap_exact_output_test_2() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: ProgramId::default(), + balance: 0_u128, + data: Data::from(&PoolDefinition { + definition_token_a_id: IdForTests::token_a_definition_id(), + definition_token_b_id: IdForTests::token_b_definition_id(), + vault_a_id: IdForTests::vault_a_id(), + vault_b_id: IdForTests::vault_b_id(), + liquidity_pool_id: IdForTests::token_lp_definition_id(), + liquidity_pool_supply: BalanceForTests::lp_supply_init(), + reserve_a: BalanceForTests::vault_a_swap_test_2(), + reserve_b: BalanceForTests::vault_b_swap_test_2(), + fees: 0_u128, + active: true, + }), + nonce: 0_u128.into(), + }, + is_authorized: true, + account_id: IdForTests::pool_definition_id(), + } + } + fn pool_definition_add_zero_lp() -> AccountWithMetadata { AccountWithMetadata { account: Account { @@ -2400,7 +2523,7 @@ fn call_new_definition_chained_call_successful() { #[should_panic(expected = "AccountId is not a token type for the pool")] #[test] fn call_swap_incorrect_token_type() { - let _post_states = swap( + let _post_states = swap_exact_input( AccountWithMetadataForTests::pool_definition_init(), AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init(), @@ -2415,7 +2538,7 @@ fn call_swap_incorrect_token_type() { #[should_panic(expected = "Vault A was not provided")] #[test] fn call_swap_vault_a_omitted() { - let _post_states = swap( + let _post_states = swap_exact_input( AccountWithMetadataForTests::pool_definition_init(), AccountWithMetadataForTests::vault_a_with_wrong_id(), AccountWithMetadataForTests::vault_b_init(), @@ -2430,7 +2553,7 @@ fn call_swap_vault_a_omitted() { #[should_panic(expected = "Vault B was not provided")] #[test] fn call_swap_vault_b_omitted() { - let _post_states = swap( + let _post_states = swap_exact_input( AccountWithMetadataForTests::pool_definition_init(), AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_with_wrong_id(), @@ -2445,7 +2568,7 @@ fn call_swap_vault_b_omitted() { #[should_panic(expected = "Reserve for Token A exceeds vault balance")] #[test] fn call_swap_reserves_vault_mismatch_1() { - let _post_states = swap( + let _post_states = swap_exact_input( AccountWithMetadataForTests::pool_definition_init(), AccountWithMetadataForTests::vault_a_init_low(), AccountWithMetadataForTests::vault_b_init(), @@ -2460,7 +2583,7 @@ fn call_swap_reserves_vault_mismatch_1() { #[should_panic(expected = "Reserve for Token B exceeds vault balance")] #[test] fn call_swap_reserves_vault_mismatch_2() { - let _post_states = swap( + let _post_states = swap_exact_input( AccountWithMetadataForTests::pool_definition_init(), AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init_low(), @@ -2475,7 +2598,7 @@ fn call_swap_reserves_vault_mismatch_2() { #[should_panic(expected = "Pool is inactive")] #[test] fn call_swap_ianctive() { - let _post_states = swap( + let _post_states = swap_exact_input( AccountWithMetadataForTests::pool_definition_inactive(), AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init(), @@ -2490,7 +2613,7 @@ fn call_swap_ianctive() { #[should_panic(expected = "Withdraw amount is less than minimal amount out")] #[test] fn call_swap_below_min_out() { - let _post_states = swap( + let _post_states = swap_exact_input( AccountWithMetadataForTests::pool_definition_init(), AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init(), @@ -2504,7 +2627,7 @@ fn call_swap_below_min_out() { #[test] fn call_swap_chained_call_successful_1() { - let (post_states, chained_calls) = swap( + let (post_states, chained_calls) = swap_exact_input( AccountWithMetadataForTests::pool_definition_init(), AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init(), @@ -2536,7 +2659,7 @@ fn call_swap_chained_call_successful_1() { #[test] fn call_swap_chained_call_successful_2() { - let (post_states, chained_calls) = swap( + let (post_states, chained_calls) = swap_exact_input( AccountWithMetadataForTests::pool_definition_init(), AccountWithMetadataForTests::vault_a_init(), AccountWithMetadataForTests::vault_b_init(), @@ -2566,6 +2689,281 @@ fn call_swap_chained_call_successful_2() { ); } +#[should_panic(expected = "AccountId is not a token type for the pool")] +#[test] +fn call_swap_exact_output_incorrect_token_type() { + let _post_states = swap_exact_output( + AccountWithMetadataForTests::pool_definition_init(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + BalanceForTests::add_max_amount_a(), + BalanceForTests::max_amount_in(), + IdForTests::token_lp_definition_id(), + ); +} + +#[should_panic(expected = "Vault A was not provided")] +#[test] +fn call_swap_exact_output_vault_a_omitted() { + let _post_states = swap_exact_output( + AccountWithMetadataForTests::pool_definition_init(), + AccountWithMetadataForTests::vault_a_with_wrong_id(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + BalanceForTests::add_max_amount_a(), + BalanceForTests::max_amount_in(), + IdForTests::token_a_definition_id(), + ); +} + +#[should_panic(expected = "Vault B was not provided")] +#[test] +fn call_swap_exact_output_vault_b_omitted() { + let _post_states = swap_exact_output( + AccountWithMetadataForTests::pool_definition_init(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_with_wrong_id(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + BalanceForTests::add_max_amount_a(), + BalanceForTests::max_amount_in(), + IdForTests::token_a_definition_id(), + ); +} + +#[should_panic(expected = "Reserve for Token A exceeds vault balance")] +#[test] +fn call_swap_exact_output_reserves_vault_mismatch_1() { + let _post_states = swap_exact_output( + AccountWithMetadataForTests::pool_definition_init(), + AccountWithMetadataForTests::vault_a_init_low(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + BalanceForTests::add_max_amount_a(), + BalanceForTests::max_amount_in(), + IdForTests::token_a_definition_id(), + ); +} + +#[should_panic(expected = "Reserve for Token B exceeds vault balance")] +#[test] +fn call_swap_exact_output_reserves_vault_mismatch_2() { + let _post_states = swap_exact_output( + AccountWithMetadataForTests::pool_definition_init(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init_low(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + BalanceForTests::add_max_amount_a(), + BalanceForTests::max_amount_in(), + IdForTests::token_a_definition_id(), + ); +} + +#[should_panic(expected = "Pool is inactive")] +#[test] +fn call_swap_exact_output_inactive() { + let _post_states = swap_exact_output( + AccountWithMetadataForTests::pool_definition_inactive(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + BalanceForTests::add_max_amount_a(), + BalanceForTests::max_amount_in(), + IdForTests::token_a_definition_id(), + ); +} + +#[should_panic(expected = "Required input exceeds maximum amount in")] +#[test] +fn call_swap_exact_output_exceeds_max_in() { + let _post_states = swap_exact_output( + AccountWithMetadataForTests::pool_definition_init(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + 166_u128, + 100_u128, + IdForTests::token_a_definition_id(), + ); +} + +#[should_panic(expected = "Exact amount out must be nonzero")] +#[test] +fn call_swap_exact_output_zero() { + let _post_states = swap_exact_output( + AccountWithMetadataForTests::pool_definition_init(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + 0_u128, + 500_u128, + IdForTests::token_a_definition_id(), + ); +} + +#[should_panic(expected = "Exact amount out exceeds reserve")] +#[test] +fn call_swap_exact_output_exceeds_reserve() { + let _post_states = swap_exact_output( + AccountWithMetadataForTests::pool_definition_init(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + BalanceForTests::vault_b_reserve_init(), + BalanceForTests::max_amount_in(), + IdForTests::token_a_definition_id(), + ); +} + +#[test] +fn call_swap_exact_output_chained_call_successful() { + let (post_states, chained_calls) = swap_exact_output( + AccountWithMetadataForTests::pool_definition_init(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + BalanceForTests::max_amount_in(), + BalanceForTests::vault_b_reserve_init(), + IdForTests::token_a_definition_id(), + ); + + let pool_post = post_states[0].clone(); + + assert!( + AccountWithMetadataForTests::pool_definition_swap_exact_output_test_1().account + == *pool_post.account() + ); + + let chained_call_a = chained_calls[0].clone(); + let chained_call_b = chained_calls[1].clone(); + + assert_eq!( + chained_call_a, + ChainedCallForTests::cc_swap_exact_output_token_a_test_1() + ); + assert_eq!( + chained_call_b, + ChainedCallForTests::cc_swap_exact_output_token_b_test_1() + ); +} + +#[test] +fn call_swap_exact_output_chained_call_successful_2() { + let (post_states, chained_calls) = swap_exact_output( + AccountWithMetadataForTests::pool_definition_init(), + AccountWithMetadataForTests::vault_a_init(), + AccountWithMetadataForTests::vault_b_init(), + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + 285, + 300, + IdForTests::token_b_definition_id(), + ); + + let pool_post = post_states[0].clone(); + + assert!( + AccountWithMetadataForTests::pool_definition_swap_exact_output_test_2().account + == *pool_post.account() + ); + + let chained_call_a = chained_calls[1].clone(); + let chained_call_b = chained_calls[0].clone(); + + assert_eq!( + chained_call_a, + ChainedCallForTests::cc_swap_exact_output_token_a_test_2() + ); + assert_eq!( + chained_call_b, + ChainedCallForTests::cc_swap_exact_output_token_b_test_2() + ); +} + +// Without the fix, `reserve_a * exact_amount_out` silently wraps to 0 in release mode, +// making `deposit_amount = 0`. The slippage check `0 <= max_amount_in` always passes, +// so an attacker receives `exact_amount_out` tokens while paying nothing. +#[should_panic(expected = "reserve * amount_out overflows u128")] +#[test] +fn swap_exact_output_overflow_protection() { + // reserve_a chosen so that reserve_a * 2 overflows u128: + // (u128::MAX / 2 + 1) * 2 = u128::MAX + 1 → wraps to 0 + let large_reserve: u128 = u128::MAX / 2 + 1; + let reserve_b: u128 = 1_000; + + let pool = AccountWithMetadata { + account: Account { + program_owner: ProgramId::default(), + balance: 0, + data: Data::from(&PoolDefinition { + definition_token_a_id: IdForTests::token_a_definition_id(), + definition_token_b_id: IdForTests::token_b_definition_id(), + vault_a_id: IdForTests::vault_a_id(), + vault_b_id: IdForTests::vault_b_id(), + liquidity_pool_id: IdForTests::token_lp_definition_id(), + liquidity_pool_supply: 1, + reserve_a: large_reserve, + reserve_b, + fees: 0, + active: true, + }), + nonce: 0_u128.into(), + }, + is_authorized: true, + account_id: IdForTests::pool_definition_id(), + }; + + let vault_a = AccountWithMetadata { + account: Account { + program_owner: TOKEN_PROGRAM_ID, + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: IdForTests::token_a_definition_id(), + balance: large_reserve, + }), + nonce: 0_u128.into(), + }, + is_authorized: true, + account_id: IdForTests::vault_a_id(), + }; + + let vault_b = AccountWithMetadata { + account: Account { + program_owner: TOKEN_PROGRAM_ID, + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id: IdForTests::token_b_definition_id(), + balance: reserve_b, + }), + nonce: 0_u128.into(), + }, + is_authorized: true, + account_id: IdForTests::vault_b_id(), + }; + + let _result = swap_exact_output( + pool, + vault_a, + vault_b, + AccountWithMetadataForTests::user_holding_a(), + AccountWithMetadataForTests::user_holding_b(), + 2, // exact_amount_out: small, valid (< reserve_b) + 1, // max_amount_in: tiny — real deposit would be enormous, but + // overflow wraps it to 0, making 0 <= 1 pass silently + IdForTests::token_a_definition_id(), + ); +} + #[test] fn new_definition_lp_asymmetric_amounts() { let (post_states, chained_calls) = new_definition( @@ -2638,7 +3036,7 @@ fn new_definition_lp_symmetric_amounts() { fn state_for_amm_tests() -> V03State { let initial_data = []; - let mut state = V03State::new_with_genesis_accounts(&initial_data, &[]); + let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0); state.force_insert_account( IdForExeTests::pool_definition_id(), AccountsForExeTests::pool_definition_init(), @@ -2681,7 +3079,7 @@ fn state_for_amm_tests() -> V03State { fn state_for_amm_tests_with_new_def() -> V03State { let initial_data = []; - let mut state = V03State::new_with_genesis_accounts(&initial_data, &[]); + let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0); state.force_insert_account( IdForExeTests::token_a_definition_id(), AccountsForExeTests::token_a_definition_account(), @@ -3064,7 +3462,7 @@ fn simple_amm_add() { fn simple_amm_swap_1() { let mut state = state_for_amm_tests(); - let instruction = amm_core::Instruction::Swap { + let instruction = amm_core::Instruction::SwapExactInput { swap_amount_in: BalanceForExeTests::swap_amount_in(), min_amount_out: BalanceForExeTests::swap_min_amount_out(), token_definition_id_in: IdForExeTests::token_b_definition_id(), @@ -3115,7 +3513,7 @@ fn simple_amm_swap_1() { fn simple_amm_swap_2() { let mut state = state_for_amm_tests(); - let instruction = amm_core::Instruction::Swap { + let instruction = amm_core::Instruction::SwapExactInput { swap_amount_in: BalanceForExeTests::swap_amount_in(), min_amount_out: BalanceForExeTests::swap_min_amount_out(), token_definition_id_in: IdForExeTests::token_a_definition_id(), diff --git a/programs/associated_token_account/core/src/lib.rs b/programs/associated_token_account/core/src/lib.rs index 994c632b..77900a2c 100644 --- a/programs/associated_token_account/core/src/lib.rs +++ b/programs/associated_token_account/core/src/lib.rs @@ -49,7 +49,7 @@ pub enum Instruction { pub fn compute_ata_seed(owner_id: AccountId, definition_id: AccountId) -> PdaSeed { use risc0_zkvm::sha::{Impl, Sha256}; - let mut bytes = [0u8; 64]; + let mut bytes = [0_u8; 64]; bytes[0..32].copy_from_slice(&owner_id.to_bytes()); bytes[32..64].copy_from_slice(&definition_id.to_bytes()); PdaSeed::new( @@ -61,7 +61,7 @@ pub fn compute_ata_seed(owner_id: AccountId, definition_id: AccountId) -> PdaSee } pub fn get_associated_token_account_id(ata_program_id: &ProgramId, seed: &PdaSeed) -> AccountId { - AccountId::from((ata_program_id, seed)) + AccountId::for_public_pda(ata_program_id, seed) } /// Verify the ATA's address matches `(ata_program_id, owner, definition)` and return diff --git a/programs/authenticated_transfer/core/Cargo.toml b/programs/authenticated_transfer/core/Cargo.toml new file mode 100644 index 00000000..0331bd64 --- /dev/null +++ b/programs/authenticated_transfer/core/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "authenticated_transfer_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +serde.workspace = true diff --git a/programs/authenticated_transfer/core/src/lib.rs b/programs/authenticated_transfer/core/src/lib.rs new file mode 100644 index 00000000..14edac5e --- /dev/null +++ b/programs/authenticated_transfer/core/src/lib.rs @@ -0,0 +1,17 @@ +//! Core data structures for the Authenticated Transfer Program. + +use serde::{Deserialize, Serialize}; + +/// Instruction type for the Authenticated Transfer program. +#[derive(Serialize, Deserialize)] +pub enum Instruction { + /// Transfer `amount` of native balance from sender to recipient. + /// + /// Required accounts: `[sender, recipient]`. + Transfer { amount: u128 }, + + /// Initialize a new account under the ownership of this program. + /// + /// Required accounts: `[account_to_initialize]`. + Initialize, +} diff --git a/programs/clock/core/Cargo.toml b/programs/clock/core/Cargo.toml new file mode 100644 index 00000000..53a43b6d --- /dev/null +++ b/programs/clock/core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "clock_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +nssa_core.workspace = true +borsh.workspace = true diff --git a/programs/clock/core/src/lib.rs b/programs/clock/core/src/lib.rs new file mode 100644 index 00000000..5fc03633 --- /dev/null +++ b/programs/clock/core/src/lib.rs @@ -0,0 +1,42 @@ +//! Core data structures and constants for the Clock Program. + +use borsh::{BorshDeserialize, BorshSerialize}; +use nssa_core::{Timestamp, account::AccountId}; + +pub const CLOCK_01_PROGRAM_ACCOUNT_ID: AccountId = + AccountId::new(*b"/LEZ/ClockProgramAccount/0000001"); + +pub const CLOCK_10_PROGRAM_ACCOUNT_ID: AccountId = + AccountId::new(*b"/LEZ/ClockProgramAccount/0000010"); + +pub const CLOCK_50_PROGRAM_ACCOUNT_ID: AccountId = + AccountId::new(*b"/LEZ/ClockProgramAccount/0000050"); + +/// All clock program account ID in the order expected by the clock program. +pub const CLOCK_PROGRAM_ACCOUNT_IDS: [AccountId; 3] = [ + CLOCK_01_PROGRAM_ACCOUNT_ID, + CLOCK_10_PROGRAM_ACCOUNT_ID, + CLOCK_50_PROGRAM_ACCOUNT_ID, +]; + +/// The instruction type for the Clock Program. The sequencer passes the current block timestamp. +pub type Instruction = Timestamp; + +/// The data stored in a clock account. +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct ClockAccountData { + pub block_id: u64, + pub timestamp: Timestamp, +} + +impl ClockAccountData { + #[must_use] + pub fn to_bytes(self) -> Vec { + borsh::to_vec(&self).expect("ClockAccountData serialization should not fail") + } + + #[must_use] + pub fn from_bytes(bytes: &[u8]) -> Self { + borsh::from_slice(bytes).expect("ClockAccountData deserialization should not fail") + } +} diff --git a/programs/faucet/core/Cargo.toml b/programs/faucet/core/Cargo.toml new file mode 100644 index 00000000..aa8826ea --- /dev/null +++ b/programs/faucet/core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "faucet_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +nssa_core.workspace = true +serde = { workspace = true, default-features = false } diff --git a/programs/faucet/core/src/lib.rs b/programs/faucet/core/src/lib.rs new file mode 100644 index 00000000..da9861e6 --- /dev/null +++ b/programs/faucet/core/src/lib.rs @@ -0,0 +1,29 @@ +pub use nssa_core::program::PdaSeed; +use nssa_core::{account::AccountId, program::ProgramId}; +use serde::{Deserialize, Serialize}; + +const FAUCET_SEED_DOMAIN_SEPARATOR: [u8; 32] = *b"/LEZ/v0.3/FaucetSeed/0000000000/"; + +#[derive(Serialize, Deserialize)] +pub enum Instruction { + /// Transfers native tokens from system faucet to recipient's vault. + /// + /// Required accounts (2): + /// - Faucet PDA account + /// - Recipient vault PDA account + Transfer { + vault_program_id: ProgramId, + recipient_id: AccountId, + amount: u128, + }, +} + +#[must_use] +pub const fn compute_faucet_seed() -> PdaSeed { + PdaSeed::new(FAUCET_SEED_DOMAIN_SEPARATOR) +} + +#[must_use] +pub fn compute_faucet_account_id(faucet_program_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&faucet_program_id, &compute_faucet_seed()) +} diff --git a/programs/vault/core/Cargo.toml b/programs/vault/core/Cargo.toml new file mode 100644 index 00000000..fd3cdf96 --- /dev/null +++ b/programs/vault/core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "vault_core" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +nssa_core.workspace = true +serde = { workspace = true, default-features = false } +risc0-zkvm.workspace = true diff --git a/programs/vault/core/src/lib.rs b/programs/vault/core/src/lib.rs new file mode 100644 index 00000000..8937e087 --- /dev/null +++ b/programs/vault/core/src/lib.rs @@ -0,0 +1,53 @@ +pub use nssa_core::program::PdaSeed; +use nssa_core::{account::AccountId, program::ProgramId}; +use serde::{Deserialize, Serialize}; + +const VAULT_SEED_DOMAIN_SEPARATOR: &[u8] = b"/LEZ/v0.3/VaultSeed/00000000000/"; + +const _: () = assert!( + VAULT_SEED_DOMAIN_SEPARATOR.len() == 32, + "Domain separator must be exactly 32 bytes long" +); + +#[derive(Serialize, Deserialize)] +pub enum Instruction { + /// Transfers native tokens from sender to recipient's vault. + /// + /// Required accounts (3): + /// - Sender account + /// - Recipient account + /// - Recipient vault PDA account + Transfer { + recipient_id: AccountId, + amount: u128, + }, + + /// Claims native tokens from owner's vault into owner's account. + /// + /// Required accounts (2): + /// - Owner account + /// - Owner vault PDA account + Claim { amount: u128 }, +} + +#[must_use] +pub fn compute_vault_seed(owner_id: AccountId) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut bytes = [0_u8; 64]; + bytes[..32].copy_from_slice(VAULT_SEED_DOMAIN_SEPARATOR); + bytes[32..64].copy_from_slice(&owner_id.to_bytes()); + + PdaSeed::new( + Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .expect("Hash output must be exactly 32 bytes long"), + ) +} + +#[must_use] +pub fn compute_vault_account_id(vault_program_id: ProgramId, owner_id: AccountId) -> AccountId { + let seed = compute_vault_seed(owner_id); + AccountId::for_public_pda(&vault_program_id, &seed) +} diff --git a/sequencer/core/Cargo.toml b/sequencer/core/Cargo.toml index e1ff0895..5f74fbde 100644 --- a/sequencer/core/Cargo.toml +++ b/sequencer/core/Cargo.toml @@ -13,8 +13,10 @@ nssa_core.workspace = true common.workspace = true storage.workspace = true mempool.workspace = true -bedrock_client.workspace = true +logos-blockchain-zone-sdk.workspace = true testnet_initial_state.workspace = true +faucet_core.workspace = true +vault_core.workspace = true anyhow.workspace = true serde.workspace = true @@ -30,7 +32,6 @@ rand.workspace = true borsh.workspace = true bytesize.workspace = true url.workspace = true -jsonrpsee = { workspace = true, features = ["ws-client"] } [features] default = [] @@ -40,3 +41,5 @@ mock = [] [dev-dependencies] futures.workspace = true +test_program_methods.workspace = true +nssa = { workspace = true, features = ["test-utils"] } diff --git a/sequencer/core/src/block_publisher.rs b/sequencer/core/src/block_publisher.rs new file mode 100644 index 00000000..9f4c8235 --- /dev/null +++ b/sequencer/core/src/block_publisher.rs @@ -0,0 +1,136 @@ +use std::{sync::Arc, time::Duration}; + +use anyhow::{Context as _, Result, anyhow}; +use common::block::Block; +use log::warn; +pub use logos_blockchain_core::mantle::ops::channel::MsgId; +pub use logos_blockchain_key_management_system_service::keys::Ed25519Key; +pub use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint; +use logos_blockchain_zone_sdk::{ + CommonHttpClient, + adapter::NodeHttpClient, + sequencer::{Event, SequencerConfig as ZoneSdkSequencerConfig, SequencerHandle, ZoneSequencer}, + state::InscriptionInfo, +}; +use tokio::task::JoinHandle; + +use crate::config::BedrockConfig; + +/// Sink for `Event::Published` checkpoints emitted by the drive task. +/// Caller is responsible for persistence (e.g. writing to rocksdb). +pub type CheckpointSink = Box; + +/// 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; + +#[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")] +pub trait BlockPublisherTrait: Clone { + async fn new( + config: &BedrockConfig, + bedrock_signing_key: Ed25519Key, + resubmit_interval: Duration, + initial_checkpoint: Option, + on_checkpoint: CheckpointSink, + on_finalized_block: FinalizedBlockSink, + ) -> 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) -> Result<()>; +} + +/// Real block publisher backed by zone-sdk's `ZoneSequencer`. +#[derive(Clone)] +pub struct ZoneSdkPublisher { + handle: SequencerHandle, + // 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(); + } +} + +impl BlockPublisherTrait for ZoneSdkPublisher { + async fn new( + config: &BedrockConfig, + bedrock_signing_key: Ed25519Key, + resubmit_interval: Duration, + initial_checkpoint: Option, + on_checkpoint: CheckpointSink, + on_finalized_block: FinalizedBlockSink, + ) -> 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, + ..ZoneSdkSequencerConfig::default() + }; + + let (mut sequencer, mut handle) = ZoneSequencer::init_with_config( + config.channel_id, + bedrock_signing_key, + node, + zone_sdk_config, + initial_checkpoint, + ); + + let drive_task = tokio::spawn(async move { + loop { + let Some(event) = sequencer.next_event().await else { + continue; + }; + match event { + Event::Published { checkpoint, .. } => on_checkpoint(checkpoint), + Event::TxsFinalized { inscriptions, .. } + | Event::FinalizedInscriptions { inscriptions } => { + if let Some(max_block_id) = max_block_id_from_inscriptions(&inscriptions) { + on_finalized_block(max_block_id); + } + } + Event::ChannelUpdate { .. } | Event::Ready => {} + } + } + }); + + handle.wait_ready().await; + + Ok(Self { + handle, + _drive_task: Arc::new(DriveTaskGuard(drive_task)), + }) + } + + async fn publish_block(&self, block: &Block) -> Result<()> { + let data = borsh::to_vec(block).context("Failed to serialize block")?; + self.handle + .publish_message(data) + .await + .map_err(|e| anyhow!("zone-sdk publish failed: {e}"))?; + Ok(()) + } +} + +/// Deserialize each inscription payload as a `Block` and return the highest +/// `block_id`. Bad payloads are logged and skipped. +fn max_block_id_from_inscriptions(inscriptions: &[InscriptionInfo]) -> Option { + inscriptions + .iter() + .filter_map( + |inscription| match borsh::from_slice::(&inscription.payload) { + Ok(block) => Some(block.header.block_id), + Err(err) => { + warn!("Failed to deserialize finalized inscription as Block: {err:#}"); + None + } + }, + ) + .max() +} diff --git a/sequencer/core/src/block_settlement_client.rs b/sequencer/core/src/block_settlement_client.rs deleted file mode 100644 index 2f036b98..00000000 --- a/sequencer/core/src/block_settlement_client.rs +++ /dev/null @@ -1,127 +0,0 @@ -use anyhow::{Context as _, Result}; -use bedrock_client::BedrockClient; -pub use common::block::Block; -pub use logos_blockchain_core::mantle::{MantleTx, SignedMantleTx, ops::channel::MsgId}; -use logos_blockchain_core::mantle::{ - Op, OpProof, Transaction as _, TxHash, ledger, - ops::channel::{ChannelId, inscribe::InscriptionOp}, -}; -pub use logos_blockchain_key_management_system_service::keys::Ed25519Key; -use logos_blockchain_key_management_system_service::keys::Ed25519PublicKey; - -use crate::config::BedrockConfig; - -#[expect(async_fn_in_trait, reason = "We don't care about Send/Sync here")] -pub trait BlockSettlementClientTrait: Clone { - //// Create a new client. - fn new(config: &BedrockConfig, signing_key: Ed25519Key) -> Result; - - /// Get the bedrock channel ID used by this client. - fn bedrock_channel_id(&self) -> ChannelId; - - /// Get the bedrock signing key used by this client. - fn bedrock_signing_key(&self) -> &Ed25519Key; - - /// Post a transaction to the node. - async fn submit_inscribe_tx_to_bedrock(&self, tx: SignedMantleTx) -> Result<()>; - - /// Create and sign a transaction for inscribing data. - fn create_inscribe_tx(&self, block: &Block) -> Result<(SignedMantleTx, MsgId)> { - let inscription_data = borsh::to_vec(block)?; - log::debug!( - "The size of the block {} is {} bytes", - block.header.block_id, - inscription_data.len() - ); - let verifying_key_bytes = self.bedrock_signing_key().public_key().to_bytes(); - let verifying_key = - Ed25519PublicKey::from_bytes(&verifying_key_bytes).expect("valid ed25519 public key"); - - let inscribe_op = InscriptionOp { - channel_id: self.bedrock_channel_id(), - inscription: inscription_data, - parent: block.bedrock_parent_id.into(), - signer: verifying_key, - }; - let inscribe_op_id = inscribe_op.id(); - - let ledger_tx = ledger::Tx::new(vec![], vec![]); - - let inscribe_tx = MantleTx { - ops: vec![Op::ChannelInscribe(inscribe_op)], - ledger_tx, - // Altruistic test config - storage_gas_price: 0, - execution_gas_price: 0, - }; - - let tx_hash = inscribe_tx.hash(); - let signature_bytes = self - .bedrock_signing_key() - .sign_payload(tx_hash.as_signing_bytes().as_ref()) - .to_bytes(); - let signature = - logos_blockchain_key_management_system_service::keys::Ed25519Signature::from_bytes( - &signature_bytes, - ); - - let signed_mantle_tx = SignedMantleTx { - ops_proofs: vec![OpProof::Ed25519Sig(signature)], - ledger_tx_proof: empty_ledger_signature(&tx_hash), - mantle_tx: inscribe_tx, - }; - Ok((signed_mantle_tx, inscribe_op_id)) - } -} - -/// A component that posts block data to logos blockchain. -#[derive(Clone)] -pub struct BlockSettlementClient { - client: BedrockClient, - signing_key: Ed25519Key, - channel_id: ChannelId, -} - -impl BlockSettlementClientTrait for BlockSettlementClient { - fn new(config: &BedrockConfig, signing_key: Ed25519Key) -> Result { - let client = - BedrockClient::new(config.backoff, config.node_url.clone(), config.auth.clone()) - .context("Failed to initialize bedrock client")?; - Ok(Self { - client, - signing_key, - channel_id: config.channel_id, - }) - } - - async fn submit_inscribe_tx_to_bedrock(&self, tx: SignedMantleTx) -> Result<()> { - let (parent_id, msg_id) = match tx.mantle_tx.ops.first() { - Some(Op::ChannelInscribe(inscribe)) => (inscribe.parent, inscribe.id()), - _ => panic!("Expected ChannelInscribe op"), - }; - self.client - .post_transaction(tx) - .await - .context("Failed to post transaction to Bedrock after retries")? - .context("Failed to post transaction to Bedrock with non-retryable error")?; - - log::debug!("Posted block to Bedrock with parent id {parent_id:?} and msg id: {msg_id:?}"); - - Ok(()) - } - - fn bedrock_channel_id(&self) -> ChannelId { - self.channel_id - } - - fn bedrock_signing_key(&self) -> &Ed25519Key { - &self.signing_key - } -} - -fn empty_ledger_signature( - tx_hash: &TxHash, -) -> logos_blockchain_key_management_system_service::keys::ZkSignature { - logos_blockchain_key_management_system_service::keys::ZkKey::multi_sign(&[], tx_hash.as_ref()) - .expect("multi-sign with empty key set works") -} diff --git a/sequencer/core/src/block_store.rs b/sequencer/core/src/block_store.rs index 9c4c875a..ada6d306 100644 --- a/sequencer/core/src/block_store.rs +++ b/sequencer/core/src/block_store.rs @@ -1,16 +1,19 @@ -use std::{collections::HashMap, path::Path}; +use std::{collections::HashMap, path::Path, sync::Arc}; -use anyhow::Result; +use anyhow::{Context as _, Result}; use common::{ HashType, block::{Block, BlockMeta, MantleMsgId}, transaction::NSSATransaction, }; +use log::info; +use logos_blockchain_zone_sdk::sequencer::SequencerCheckpoint; use nssa::V03State; -use storage::{error::DbError, sequencer::RocksDBIO}; +pub use storage::DbResult; +use storage::sequencer::RocksDBIO; pub struct SequencerStore { - dbio: RocksDBIO, + dbio: Arc, // TODO: Consider adding the hashmap to the database for faster recovery. tx_hash_to_block_map: HashMap, genesis_id: u64, @@ -18,21 +21,25 @@ pub struct SequencerStore { } impl SequencerStore { - /// Starting database at the start of new chain. - /// Creates files if necessary. - /// - /// ATTENTION: Will overwrite genesis block. - pub fn open_db_with_genesis( - location: &Path, - genesis_block: &Block, - genesis_msg_id: MantleMsgId, - signing_key: nssa::PrivateKey, - ) -> Result { - let tx_hash_to_block_map = block_to_transactions_map(genesis_block); - - let dbio = RocksDBIO::open_or_create(location, genesis_block, genesis_msg_id)?; - + /// Open existing database at the given location. Fails if no database is found. + pub fn open_db(location: &Path, signing_key: nssa::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; + + 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, @@ -42,19 +49,56 @@ impl SequencerStore { }) } - pub fn get_block_at_id(&self, id: u64) -> Result, DbError> { + /// Starting database at the start of new chain. + /// Creates files if necessary. + /// + /// ATTENTION: Will overwrite genesis block. + pub fn create_db_with_genesis( + location: &Path, + genesis_block: &Block, + genesis_msg_id: MantleMsgId, + genesis_state: &V03State, + signing_key: nssa::PrivateKey, + ) -> DbResult { + let dbio = Arc::new(RocksDBIO::create( + location, + genesis_block, + genesis_msg_id, + genesis_state, + )?); + let genesis_id = dbio.get_meta_first_block_in_db()?; + let tx_hash_to_block_map = block_to_transactions_map(genesis_block); + + 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. + #[must_use] + pub fn dbio(&self) -> Arc { + Arc::clone(&self.dbio) + } + + pub fn get_block_at_id(&self, id: u64) -> DbResult> { self.dbio.get_block(id) } - pub fn delete_block_at_id(&mut self, block_id: u64) -> Result<()> { - Ok(self.dbio.delete_block(block_id)?) + pub fn delete_block_at_id(&mut self, block_id: u64) -> DbResult<()> { + self.dbio.delete_block(block_id) } - pub fn mark_block_as_finalized(&mut self, block_id: u64) -> Result<()> { - Ok(self.dbio.mark_block_as_finalized(block_id)?) + pub fn mark_block_as_finalized(&mut self, block_id: u64) -> DbResult<()> { + self.dbio.mark_block_as_finalized(block_id) } /// 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 { let block_id = *self.tx_hash_to_block_map.get(&hash)?; let block = self @@ -72,20 +116,22 @@ impl SequencerStore { ); } - pub fn latest_block_meta(&self) -> Result { - Ok(self.dbio.latest_block_meta()?) + pub fn latest_block_meta(&self) -> DbResult { + self.dbio.latest_block_meta() } + #[must_use] pub const fn genesis_id(&self) -> u64 { self.genesis_id } + #[must_use] pub const fn signing_key(&self) -> &nssa::PrivateKey { &self.signing_key } - pub fn get_all_blocks(&self) -> impl Iterator> { - self.dbio.get_all_blocks().map(|res| Ok(res?)) + pub fn get_all_blocks(&self) -> impl Iterator> { + self.dbio.get_all_blocks() } pub(crate) fn update( @@ -93,15 +139,31 @@ impl SequencerStore { block: &Block, msg_id: MantleMsgId, state: &V03State, - ) -> Result<()> { + ) -> DbResult<()> { let new_transactions_map = block_to_transactions_map(block); self.dbio.atomic_update(block, msg_id, state)?; self.tx_hash_to_block_map.extend(new_transactions_map); Ok(()) } - pub fn get_nssa_state(&self) -> Option { - self.dbio.get_nssa_state().ok() + pub fn get_nssa_state(&self) -> DbResult { + self.dbio.get_nssa_state() + } + + pub fn get_zone_checkpoint(&self) -> Result> { + let Some(bytes) = self.dbio.get_zone_sdk_checkpoint_bytes()? else { + return Ok(None); + }; + let checkpoint: SequencerCheckpoint = serde_json::from_slice(&bytes) + .context("Failed to deserialize stored zone-sdk checkpoint")?; + Ok(Some(checkpoint)) + } + + 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)?; + Ok(()) } } @@ -139,9 +201,14 @@ mod tests { let genesis_block = genesis_block_hashable_data.into_pending_block(&signing_key, [0; 32]); // Start an empty node store - let mut node_store = - SequencerStore::open_db_with_genesis(path, &genesis_block, [0; 32], signing_key) - .unwrap(); + let mut node_store = SequencerStore::create_db_with_genesis( + path, + &genesis_block, + [0; 32], + &testnet_initial_state::initial_state(), + signing_key, + ) + .unwrap(); let tx = common::test_utils::produce_dummy_empty_transaction(); let block = common::test_utils::produce_dummy_block(1, None, vec![tx.clone()]); @@ -150,7 +217,7 @@ mod tests { let retrieved_tx = node_store.get_transaction_by_hash(tx.hash()); assert_eq!(None, retrieved_tx); // Add the block with the transaction - let dummy_state = V03State::new_with_genesis_accounts(&[], &[]); + let dummy_state = V03State::new_with_genesis_accounts(&[], vec![], 0); node_store.update(&block, [1; 32], &dummy_state).unwrap(); // Try again let retrieved_tx = node_store.get_transaction_by_hash(tx.hash()); @@ -174,9 +241,14 @@ mod tests { let genesis_block = genesis_block_hashable_data.into_pending_block(&signing_key, [0; 32]); let genesis_hash = genesis_block.header.hash; - let node_store = - SequencerStore::open_db_with_genesis(path, &genesis_block, [0; 32], signing_key) - .unwrap(); + let node_store = SequencerStore::create_db_with_genesis( + path, + &genesis_block, + [0; 32], + &testnet_initial_state::initial_state(), + signing_key, + ) + .unwrap(); // Verify that initially the latest block hash equals genesis hash let latest_meta = node_store.latest_block_meta().unwrap(); @@ -199,9 +271,14 @@ mod tests { }; let genesis_block = genesis_block_hashable_data.into_pending_block(&signing_key, [0; 32]); - let mut node_store = - SequencerStore::open_db_with_genesis(path, &genesis_block, [0; 32], signing_key) - .unwrap(); + let mut node_store = SequencerStore::create_db_with_genesis( + path, + &genesis_block, + [0; 32], + &testnet_initial_state::initial_state(), + signing_key, + ) + .unwrap(); // Add a new block let tx = common::test_utils::produce_dummy_empty_transaction(); @@ -209,7 +286,7 @@ mod tests { let block_hash = block.header.hash; let block_msg_id = [1; 32]; - let dummy_state = V03State::new_with_genesis_accounts(&[], &[]); + let dummy_state = V03State::new_with_genesis_accounts(&[], vec![], 0); node_store .update(&block, block_msg_id, &dummy_state) .unwrap(); @@ -235,16 +312,21 @@ mod tests { }; let genesis_block = genesis_block_hashable_data.into_pending_block(&signing_key, [0; 32]); - let mut node_store = - SequencerStore::open_db_with_genesis(path, &genesis_block, [0; 32], signing_key) - .unwrap(); + let mut node_store = SequencerStore::create_db_with_genesis( + path, + &genesis_block, + [0; 32], + &testnet_initial_state::initial_state(), + signing_key, + ) + .unwrap(); // Add a new block with Pending status let tx = common::test_utils::produce_dummy_empty_transaction(); let block = common::test_utils::produce_dummy_block(1, None, vec![tx]); let block_id = block.header.block_id; - let dummy_state = V03State::new_with_genesis_accounts(&[], &[]); + let dummy_state = V03State::new_with_genesis_accounts(&[], vec![], 0); node_store.update(&block, [1; 32], &dummy_state).unwrap(); // Verify initial status is Pending @@ -264,4 +346,49 @@ mod tests { common::block::BedrockStatus::Finalized )); } + + #[test] + fn open_existing_db_caches_transactions() { + let temp_dir = tempdir().unwrap(); + let path = temp_dir.path(); + + let signing_key = sequencer_sign_key_for_testing(); + + let genesis_block_hashable_data = HashableBlockData { + block_id: 0, + prev_block_hash: HashType([0; 32]), + timestamp: 0, + transactions: vec![], + }; + + let genesis_block = genesis_block_hashable_data.into_pending_block(&signing_key, [0; 32]); + let tx = common::test_utils::produce_dummy_empty_transaction(); + { + // Create a scope to drop the first store after creating the db + let mut node_store = SequencerStore::create_db_with_genesis( + path, + &genesis_block, + [0; 32], + &testnet_initial_state::initial_state(), + signing_key.clone(), + ) + .unwrap(); + + // Add a new block + let block = common::test_utils::produce_dummy_block(1, None, vec![tx.clone()]); + node_store + .update( + &block, + [1; 32], + &V03State::new_with_genesis_accounts(&[], vec![], 0), + ) + .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); + } } diff --git a/sequencer/core/src/config.rs b/sequencer/core/src/config.rs index 2fb101aa..371ebc89 100644 --- a/sequencer/core/src/config.rs +++ b/sequencer/core/src/config.rs @@ -6,27 +6,33 @@ use std::{ }; use anyhow::Result; -use bedrock_client::BackoffConfig; use bytesize::ByteSize; use common::config::BasicAuth; use humantime_serde; use logos_blockchain_core::mantle::ops::channel::ChannelId; +use nssa::AccountId; use serde::{Deserialize, Serialize}; -use testnet_initial_state::{PrivateAccountPublicInitialData, PublicAccountPublicInitialData}; use url::Url; +/// A transaction to be applied at genesis to supply initial balances. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GenesisAction { + SupplyAccount { + account_id: AccountId, + balance: u128, + }, +} + // TODO: Provide default values #[derive(Clone, Serialize, Deserialize)] pub struct SequencerConfig { /// Home dir of sequencer storage. pub home: PathBuf, - /// Genesis id. - pub genesis_id: u64, - /// If `True`, then adds random sequence of bytes to genesis block. - pub is_genesis_random: bool, - /// Maximum number of transactions in block. + /// Maximum number of user transactions in a block (excludes the mandatory clock transaction). pub max_num_tx_in_block: usize, - /// Maximum block size (includes header and transactions). + /// Maximum block size (includes header, user transactions, and the mandatory clock + /// transaction). #[serde(default = "default_max_block_size")] pub max_block_size: ByteSize, /// Mempool maximum size. @@ -41,19 +47,13 @@ pub struct SequencerConfig { pub signing_key: [u8; 32], /// Bedrock configuration options. pub bedrock_config: BedrockConfig, - /// Indexer RPC URL. - pub indexer_rpc_url: Url, - #[serde(skip_serializing_if = "Option::is_none")] - pub initial_public_accounts: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub initial_private_accounts: Option>, + /// Genesis configuration. + #[serde(default)] + pub genesis: Vec, } #[derive(Clone, Serialize, Deserialize)] pub struct BedrockConfig { - /// Fibonacci backoff retry strategy configuration. - #[serde(default)] - pub backoff: BackoffConfig, /// Bedrock channel ID. pub channel_id: ChannelId, /// Bedrock Url. diff --git a/sequencer/core/src/lib.rs b/sequencer/core/src/lib.rs index 16667051..c6606145 100644 --- a/sequencer/core/src/lib.rs +++ b/sequencer/core/src/lib.rs @@ -1,53 +1,43 @@ use std::{path::Path, time::Instant}; use anyhow::{Context as _, Result, anyhow}; -use bedrock_client::SignedMantleTx; -#[cfg(feature = "testnet")] -use common::PINATA_BASE58; use common::{ HashType, block::{BedrockStatus, Block, HashableBlockData}, - transaction::NSSATransaction, + transaction::{NSSATransaction, clock_invocation}, }; -use config::SequencerConfig; +use config::{GenesisAction, SequencerConfig}; use log::{error, info, warn}; use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key}; use mempool::{MemPool, MemPoolHandle}; #[cfg(feature = "mock")] pub use mock::SequencerCoreWithMockClients; -use nssa::V03State; -use nssa_core::{BlockId, Timestamp}; +use nssa::{AccountId, PublicTransaction, program::Program, public_transaction::Message}; +use nssa_core::GENESIS_BLOCK_ID; pub use storage::error::DbError; -use testnet_initial_state::initial_state; use crate::{ - block_settlement_client::{BlockSettlementClient, BlockSettlementClientTrait, MsgId}, + block_publisher::{BlockPublisherTrait, ZoneSdkPublisher}, block_store::SequencerStore, - indexer_client::{IndexerClient, IndexerClientTrait}, }; -pub mod block_settlement_client; +pub mod block_publisher; pub mod block_store; pub mod config; -pub mod indexer_client; #[cfg(feature = "mock")] pub mod mock; -pub struct SequencerCore< - BC: BlockSettlementClientTrait = BlockSettlementClient, - IC: IndexerClientTrait = IndexerClient, -> { +pub struct SequencerCore { state: nssa::V03State, store: SequencerStore, mempool: MemPool, sequencer_config: SequencerConfig, chain_height: u64, - block_settlement_client: BC, - indexer_client: IC, + block_publisher: BP, } -impl SequencerCore { +impl SequencerCore { /// Starts the sequencer using the provided configuration. /// If an existing database is found, the sequencer state is loaded from it and /// assumed to represent the correct latest state consistent with Bedrock-finalized data. @@ -56,97 +46,110 @@ impl SequencerCore (Self, MemPoolHandle) { - let hashable_data = HashableBlockData { - block_id: config.genesis_id, - transactions: vec![], - prev_block_hash: HashType([0; 32]), - timestamp: 0, - }; - let signing_key = nssa::PrivateKey::try_new(config.signing_key).unwrap(); - let genesis_parent_msg_id = [0; 32]; - let genesis_block = hashable_data.into_pending_block(&signing_key, genesis_parent_msg_id); 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 block_settlement_client = BC::new(&config.bedrock_config, bedrock_signing_key) - .expect("Failed to initialize Block Settlement Client"); + 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() + ) + }); + let state = store + .get_nssa_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) + } else { + warn!( + "Database not found at {}, starting from genesis", + db_path.display() + ); - let indexer_client = IC::new(&config.indexer_rpc_url) - .await - .expect("Failed to create Indexer Client"); + // TODO: Remove msg_id from BlockMeta — it is no longer needed now that + // zone-sdk manages L1 settlement state via its own checkpoint. + let genesis_msg_id = [0; 32]; + let genesis_parent_msg_id = [0; 32]; + let (genesis_state, genesis_txs) = build_genesis_state(&config); - let (_tx, genesis_msg_id) = block_settlement_client - .create_inscribe_tx(&genesis_block) - .expect("Failed to create inscribe tx for genesis block"); + let hashable_data = HashableBlockData { + block_id: GENESIS_BLOCK_ID, + transactions: genesis_txs, + prev_block_hash: HashType([0; 32]), + timestamp: 0, + }; + let genesis_block = + hashable_data.into_pending_block(&signing_key, genesis_parent_msg_id); + + let store = SequencerStore::create_db_with_genesis( + &db_path, + &genesis_block, + genesis_msg_id, + &genesis_state, + signing_key, + ) + .expect("Failed to create database with genesis block"); + + (store, genesis_state, genesis_block) + }; - // Sequencer should panic if unable to open db, - // as fixing this issue may require actions non-native to program scope - let store = SequencerStore::open_db_with_genesis( - &config.home.join("rocksdb"), - &genesis_block, - genesis_msg_id.into(), - signing_key, - ) - .unwrap(); let latest_block_meta = store .latest_block_meta() .expect("Failed to read latest block meta from store"); - #[cfg_attr(not(feature = "testnet"), allow(unused_mut))] - let mut state = if let Some(state) = store.get_nssa_state() { - info!("Found local database. Loading state and pending blocks from it."); - state - } else { - info!( - "No database found when starting the sequencer. Creating a fresh new with the initial data" - ); + let initial_checkpoint = store + .get_zone_checkpoint() + .expect("Failed to load zone-sdk checkpoint"); + let is_fresh_start = initial_checkpoint.is_none(); - let initial_commitments: Option> = config - .initial_private_accounts - .clone() - .map(|initial_commitments| { - initial_commitments - .iter() - .map(|init_comm_data| { - let npk = &init_comm_data.npk; - - let mut acc = init_comm_data.account.clone(); - - acc.program_owner = - nssa::program::Program::authenticated_transfer_program().id(); - - nssa_core::Commitment::new(npk, &acc) - }) - .collect() - }); - - let init_accs: Option> = config - .initial_public_accounts - .clone() - .map(|initial_accounts| { - initial_accounts - .iter() - .map(|acc_data| (acc_data.account_id, acc_data.balance)) - .collect() - }); - - // If initial commitments or accounts are present in config, need to construct state - // from them - if initial_commitments.is_some() || init_accs.is_some() { - V03State::new_with_genesis_accounts( - &init_accs.unwrap_or_default(), - &initial_commitments.unwrap_or_default(), - ) - } else { - initial_state() + let dbio_for_checkpoint = store.dbio(); + let on_checkpoint: 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; + } + }; + if let Err(err) = dbio_for_checkpoint.put_zone_sdk_checkpoint_bytes(&bytes) { + error!("Failed to persist zone-sdk checkpoint: {err:#}"); } - }; + }); - #[cfg(feature = "testnet")] - state.add_pinata_program(PINATA_BASE58.parse().unwrap()); + let dbio_for_finalized = store.dbio(); + let on_finalized_block: block_publisher::FinalizedBlockSink = Box::new(move |block_id| { + if let Err(err) = dbio_for_finalized.clean_pending_blocks_up_to(block_id) { + error!("Failed to mark pending blocks finalized up to {block_id}: {err:#}"); + } + }); + + let block_publisher = BP::new( + &config.bedrock_config, + bedrock_signing_key, + config.retry_pending_blocks_timeout, + initial_checkpoint, + on_checkpoint, + on_finalized_block, + ) + .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 && let Err(err) = block_publisher.publish_block(&genesis_block).await { + error!("Failed to publish genesis block: {err:#}"); + } let (mempool, mempool_handle) = MemPool::new(config.mempool_max_size); @@ -156,57 +159,33 @@ impl SequencerCore Result { - match &tx { - NSSATransaction::Public(tx) => self - .state - .transition_from_public_transaction(tx, block_id, timestamp), - NSSATransaction::PrivacyPreserving(tx) => self - .state - .transition_from_privacy_preserving_transaction(tx, block_id, timestamp), - NSSATransaction::ProgramDeployment(tx) => self - .state - .transition_from_program_deployment_transaction(tx), - } - .inspect_err(|err| warn!("Error at transition {err:#?}"))?; - - Ok(tx) - } - + /// Produces a new block from mempool transactions and publishes it via zone-sdk. pub async fn produce_new_block(&mut self) -> Result { - let (tx, _msg_id) = self - .produce_new_block_with_mempool_transactions() - .context("Failed to produce new block with mempool transactions")?; - match self - .block_settlement_client - .submit_inscribe_tx_to_bedrock(tx) - .await - { - Ok(()) => {} - Err(err) => { - error!("Failed to post block data to Bedrock with error: {err:#}"); - } + let block = self + .build_block_from_mempool() + .context("Failed to build block from mempool transactions")?; + + // TODO: Remove msg_id from store.update — it is no longer needed now that + // zone-sdk manages L1 settlement state via its own checkpoint. + let placeholder_msg_id = [0_u8; 32]; + + if let Err(err) = self.block_publisher.publish_block(&block).await { + error!("Failed to publish block to Bedrock with error: {err:#}"); } + self.store.update(&block, placeholder_msg_id, &self.state)?; Ok(self.chain_height) } - /// Produces new block from transactions in mempool and packs it into a `SignedMantleTx`. - pub fn produce_new_block_with_mempool_transactions( - &mut self, - ) -> Result<(SignedMantleTx, MsgId)> { + /// Builds a new block from transactions in the mempool. + /// Does NOT publish or store the block — the caller is responsible for that. + pub fn build_block_from_mempool(&mut self) -> Result { let now = Instant::now(); let new_block_height = self.next_block_id(); @@ -224,12 +203,20 @@ impl SequencerCore SequencerCore { - valid_transactions.push(valid_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; - } - } + 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", ); - // TODO: Probably need to handle unsuccessful transaction execution? + continue; } + }; + + self.state.apply_state_diff(validated_diff); + + 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 + .transition_from_public_transaction(&clock_tx, new_block_height, new_block_timestamp) + .context("Clock transaction failed. Aborting block production.")?; + valid_transactions.push(clock_nssa_tx); + let hashable_data = HashableBlockData { block_id: new_block_height, transactions: valid_transactions, @@ -279,21 +275,12 @@ impl SequencerCore SequencerCore &nssa::V03State { @@ -321,22 +308,19 @@ impl SequencerCore Result<()> { - self.get_pending_blocks()? - .iter() - .map(|block| block.header.block_id) - .min() - .map_or(Ok(()), |first_pending_block_id| { - info!("Clearing pending blocks up to id: {last_finalized_block_id}"); - // TODO: Delete blocks instead of marking them as finalized. - // Current approach is used because we still have `GetBlockDataRequest`. - (first_pending_block_id..=last_finalized_block_id) - .try_for_each(|id| self.store.mark_block_as_finalized(id)) - }) + /// 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. + // 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<()> { + info!("Clearing pending blocks up to id: {last_finalized_block_id}"); + self.store + .dbio() + .clean_pending_blocks_up_to(last_finalized_block_id)?; + Ok(()) } /// Returns the list of stored pending blocks. @@ -344,18 +328,14 @@ impl SequencerCore>>()? + .collect::>>()? .into_iter() .filter(|block| matches!(block.bedrock_status, BedrockStatus::Pending)) .collect()) } - pub fn block_settlement_client(&self) -> BC { - self.block_settlement_client.clone() - } - - pub fn indexer_client(&self) -> IC { - self.indexer_client.clone() + pub fn block_publisher(&self) -> BP { + self.block_publisher.clone() } fn next_block_id(&self) -> u64 { @@ -365,6 +345,61 @@ impl SequencerCore (nssa::V03State, Vec) { + #[cfg(not(feature = "testnet"))] + let mut state = testnet_initial_state::initial_state(); + + #[cfg(feature = "testnet")] + let mut state = 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), + }) + .chain(std::iter::once(clock_invocation(0))) + .inspect(|tx| { + state + .transition_from_public_transaction(tx, GENESIS_BLOCK_ID, 0) + .expect("Failed to execute genesis transaction"); + }) + .map(NSSATransaction::Public) + .collect(); + + (state, genesis_txs) +} + +fn build_supply_account_genesis_transaction( + account_id: &AccountId, + balance: u128, +) -> PublicTransaction { + let faucet_program_id = Program::faucet().id(); + let vault_program_id = Program::vault().id(); + let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, *account_id); + + let message = Message::try_new( + faucet_program_id, + vec![nssa::system_faucet_account_id(), recipient_vault_id], + vec![], + faucet_core::Instruction::Transfer { + vault_program_id, + recipient_id: *account_id, + amount: balance, + }, + ) + .expect("Failed to serialize genesis transfer instruction"); + let witness_set = nssa::public_transaction::WitnessSet::from_raw_parts(vec![]); + + PublicTransaction::new(message, witness_set) +} + /// Load signing key from file or generate a new one if it doesn't exist. fn load_or_create_signing_key(path: &Path) -> Result { if path.exists() { @@ -394,13 +429,20 @@ mod tests { use std::{pin::pin, time::Duration}; - use bedrock_client::BackoffConfig; - use common::{test_utils::sequencer_sign_key_for_testing, transaction::NSSATransaction}; + use common::{ + HashType, + block::HashableBlockData, + test_utils::sequencer_sign_key_for_testing, + transaction::{NSSATransaction, clock_invocation}, + }; use logos_blockchain_core::mantle::ops::channel::ChannelId; use mempool::MemPoolHandle; + use tempfile::tempdir; use testnet_initial_state::{initial_accounts, initial_pub_accounts_private_keys}; use crate::{ + block_store::SequencerStore, + build_genesis_state, config::{BedrockConfig, SequencerConfig}, mock::SequencerCoreWithMockClients, }; @@ -411,26 +453,18 @@ mod tests { SequencerConfig { home, - genesis_id: 1, - is_genesis_random: false, 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 { - backoff: BackoffConfig { - start_delay: Duration::from_millis(100), - max_retries: 5, - }, 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), - indexer_rpc_url: "ws://localhost:8779".parse().unwrap(), - initial_public_accounts: None, - initial_private_accounts: None, + genesis: vec![], } } @@ -456,9 +490,7 @@ mod tests { let tx = common::test_utils::produce_dummy_empty_transaction(); mempool_handle.push(tx).await.unwrap(); - sequencer - .produce_new_block_with_mempool_transactions() - .unwrap(); + sequencer.produce_new_block().await.unwrap(); (sequencer, mempool_handle) } @@ -469,7 +501,7 @@ mod tests { let (sequencer, _mempool_handle) = SequencerCoreWithMockClients::start_from_config(config.clone()).await; - assert_eq!(sequencer.chain_height, config.genesis_id); + assert_eq!(sequencer.chain_height, 1); assert_eq!(sequencer.sequencer_config.max_num_tx_in_block, 10); let acc1_account_id = initial_accounts()[0].account_id; @@ -482,6 +514,57 @@ mod tests { 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 = nssa::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, [0; 32]); + + let expected_msg_id = [7; 32]; + SequencerStore::create_db_with_genesis( + &config.home.join("rocksdb"), + &genesis_block, + expected_msg_id, + &genesis_state, + signing_key, + ) + .unwrap(); + + let (sequencer, _mempool_handle) = + SequencerCoreWithMockClients::start_from_config(config).await; + let latest_meta = sequencer.store.latest_block_meta().unwrap(); + + assert_eq!(latest_meta.msg_id, expected_msg_id); + assert_eq!(sequencer.chain_height, 1); + } + + #[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; + } + #[test] fn transaction_pre_check_pass() { let tx = common::test_utils::produce_dummy_empty_transaction(); @@ -524,7 +607,7 @@ mod tests { let tx = tx.transaction_stateless_check().unwrap(); // Signature is not from sender. Execution fails - let result = sequencer.execute_check_transaction_on_state(tx, 0, 0); + let result = tx.execute_check_on_state(&mut sequencer.state, 0, 0); assert!(matches!( result, @@ -550,7 +633,9 @@ mod tests { // Passed pre-check assert!(result.is_ok()); - let result = sequencer.execute_check_transaction_on_state(result.unwrap(), 0, 0); + let result = result + .unwrap() + .execute_check_on_state(&mut sequencer.state, 0, 0); let is_failed_at_balance_mismatch = matches!( result.err().unwrap(), nssa::error::NssaError::ProgramExecutionFailed(_) @@ -572,8 +657,7 @@ mod tests { acc1, 0, acc2, 100, &sign_key1, ); - sequencer - .execute_check_transaction_on_state(tx, 0, 0) + tx.execute_check_on_state(&mut sequencer.state, 0, 0) .unwrap(); let bal_from = sequencer.state.get_account_by_id(acc1).balance; @@ -602,23 +686,21 @@ mod tests { assert!(poll.is_pending()); // Empty the mempool by producing a block - sequencer - .produce_new_block_with_mempool_transactions() - .unwrap(); + sequencer.produce_new_block().await.unwrap(); // Resolve the pending push assert!(push_fut.await.is_ok()); } #[tokio::test] - async fn produce_new_block_with_mempool_transactions() { + 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(tx).await.unwrap(); - let result = sequencer.produce_new_block_with_mempool_transactions(); + let result = sequencer.build_block_from_mempool(); assert!(result.is_ok()); assert_eq!(sequencer.chain_height, genesis_height + 1); } @@ -643,17 +725,21 @@ mod tests { mempool_handle.push(tx_replay).await.unwrap(); // Create block - sequencer - .produce_new_block_with_mempool_transactions() - .unwrap(); + sequencer.produce_new_block().await.unwrap(); let block = sequencer .store .get_block_at_id(sequencer.chain_height) .unwrap() .unwrap(); - // Only one should be included in the block - assert_eq!(block.body.transactions, vec![tx.clone()]); + // Only one user tx should be included; the clock tx is always appended last. + assert_eq!( + block.body.transactions, + vec![ + tx.clone(), + NSSATransaction::Public(clock_invocation(block.header.timestamp)) + ] + ); } #[tokio::test] @@ -671,27 +757,35 @@ mod tests { // The transaction should be included the first time mempool_handle.push(tx.clone()).await.unwrap(); - sequencer - .produce_new_block_with_mempool_transactions() - .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()]); + assert_eq!( + block.body.transactions, + vec![ + tx.clone(), + NSSATransaction::Public(clock_invocation(block.header.timestamp)) + ] + ); // Add same transaction should fail mempool_handle.push(tx.clone()).await.unwrap(); - sequencer - .produce_new_block_with_mempool_transactions() - .unwrap(); + sequencer.produce_new_block().await.unwrap(); let block = sequencer .store .get_block_at_id(sequencer.chain_height) .unwrap() .unwrap(); - assert!(block.body.transactions.is_empty()); + // The replay is rejected, so only the clock tx is in the block. + assert_eq!( + block.body.transactions, + vec![NSSATransaction::Public(clock_invocation( + block.header.timestamp + ))] + ); } #[tokio::test] @@ -718,15 +812,19 @@ mod tests { ); mempool_handle.push(tx.clone()).await.unwrap(); - sequencer - .produce_new_block_with_mempool_transactions() - .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()]); + assert_eq!( + block.body.transactions, + vec![ + tx.clone(), + NSSATransaction::Public(clock_invocation(block.header.timestamp)) + ] + ); } // Instantiating a new sequencer from the same config. This should load the existing block @@ -752,15 +850,9 @@ mod tests { let config = setup_sequencer_config(); let (mut sequencer, _mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; - sequencer - .produce_new_block_with_mempool_transactions() - .unwrap(); - sequencer - .produce_new_block_with_mempool_transactions() - .unwrap(); - sequencer - .produce_new_block_with_mempool_transactions() - .unwrap(); + 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); } @@ -769,15 +861,9 @@ mod tests { let config = setup_sequencer_config(); let (mut sequencer, _mempool_handle) = SequencerCoreWithMockClients::start_from_config(config).await; - sequencer - .produce_new_block_with_mempool_transactions() - .unwrap(); - sequencer - .produce_new_block_with_mempool_transactions() - .unwrap(); - sequencer - .produce_new_block_with_mempool_transactions() - .unwrap(); + sequencer.produce_new_block().await.unwrap(); + sequencer.produce_new_block().await.unwrap(); + sequencer.produce_new_block().await.unwrap(); let last_finalized_block = 3; sequencer @@ -810,9 +896,7 @@ mod tests { ); mempool_handle.push(tx).await.unwrap(); - sequencer - .produce_new_block_with_mempool_transactions() - .unwrap(); + sequencer.produce_new_block().await.unwrap(); // Get the metadata of the last block produced sequencer.store.latest_block_meta().unwrap() @@ -835,9 +919,7 @@ mod tests { mempool_handle.push(tx.clone()).await.unwrap(); // Step 4: Produce new block - sequencer - .produce_new_block_with_mempool_transactions() - .unwrap(); + sequencer.produce_new_block().await.unwrap(); // Step 5: Verify the new block has correct previous block metadata let new_block = sequencer @@ -850,63 +932,132 @@ mod tests { 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.bedrock_parent_id, expected_prev_meta.msg_id, - "New block's bedrock_parent_id should match the stored metadata msg_id" - ); assert_eq!( new_block.body.transactions, - vec![tx], - "New block should contain the submitted transaction" + vec![ + tx, + NSSATransaction::Public(clock_invocation(new_block.header.timestamp)) + ], + "New block should contain the submitted transaction and the clock invocation" ); } #[tokio::test] - async fn start_from_config_uses_db_height_not_config_genesis() { - let mut config = setup_sequencer_config(); - let original_genesis_id = config.genesis_id; + async fn transactions_touching_clock_account_are_dropped_from_block() { + let (mut sequencer, mempool_handle) = common_setup().await; - // Step 1: Create initial database and produce some blocks - let expected_chain_height = { - let (mut sequencer, mempool_handle) = - SequencerCoreWithMockClients::start_from_config(config.clone()).await; - - // Verify we start with the genesis_id from config - assert_eq!(sequencer.chain_height, original_genesis_id); - - // Produce multiple blocks to advance chain height - let tx = common::test_utils::produce_dummy_empty_transaction(); - mempool_handle.push(tx).await.unwrap(); - sequencer - .produce_new_block_with_mempool_transactions() - .unwrap(); - - let tx = common::test_utils::produce_dummy_empty_transaction(); - mempool_handle.push(tx).await.unwrap(); - sequencer - .produce_new_block_with_mempool_transactions() - .unwrap(); - - // Return the current chain height (should be genesis_id + 2) - sequencer.chain_height + // 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 = nssa::public_transaction::Message::try_new( + nssa::program::Program::clock().id(), + nssa::CLOCK_PROGRAM_ACCOUNT_IDS.to_vec(), + vec![], + 42_u64, + ) + .unwrap(); + NSSATransaction::Public(nssa::PublicTransaction::new( + message, + nssa::public_transaction::WitnessSet::from_raw_parts(vec![]), + )) }; + mempool_handle + .push(NSSATransaction::Public(clock_invocation(0))) + .await + .unwrap(); + mempool_handle.push(crafted_clock_tx).await.unwrap(); + sequencer.produce_new_block().await.unwrap(); - // Step 2: Modify the config to have a DIFFERENT genesis_id - let different_genesis_id = original_genesis_id + 100; - config.genesis_id = different_genesis_id; + let block = sequencer + .store + .get_block_at_id(sequencer.chain_height) + .unwrap() + .unwrap(); - // Step 3: Restart sequencer with the modified config (different genesis_id) - let (sequencer, _mempool_handle) = - SequencerCoreWithMockClients::start_from_config(config.clone()).await; - - // Step 4: Verify chain_height comes from database, NOT from the new config.genesis_id + // Both transactions were dropped. Only the system-appended clock tx remains. assert_eq!( - sequencer.chain_height, expected_chain_height, - "Chain height should be loaded from database metadata, not config.genesis_id" + block.body.transactions, + vec![NSSATransaction::Public(clock_invocation( + block.header.timestamp + ))] ); - assert_ne!( - sequencer.chain_height, different_genesis_id, - "Chain height should NOT match the modified config.genesis_id" + } + + #[tokio::test] + async fn user_tx_that_chain_calls_clock_is_dropped() { + let (mut sequencer, mempool_handle) = common_setup().await; + + // Deploy the clock_chain_caller test program. + let deploy_tx = + NSSATransaction::ProgramDeployment(nssa::ProgramDeploymentTransaction::new( + nssa::program_deployment_transaction::Message::new( + test_program_methods::CLOCK_CHAIN_CALLER_ELF.to_vec(), + ), + )); + mempool_handle.push(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 = + nssa::program::Program::new(test_program_methods::CLOCK_CHAIN_CALLER_ELF.to_vec()) + .unwrap() + .id(); + let clock_program_id = nssa::program::Program::clock().id(); + let timestamp: u64 = 0; + + let message = nssa::public_transaction::Message::try_new( + clock_chain_caller_id, + nssa::CLOCK_PROGRAM_ACCOUNT_IDS.to_vec(), + vec![], // no signers + (clock_program_id, timestamp), + ) + .unwrap(); + let user_tx = NSSATransaction::Public(nssa::PublicTransaction::new( + message, + nssa::public_transaction::WitnessSet::from_raw_parts(vec![]), + )); + + mempool_handle.push(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![NSSATransaction::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 = nssa::CLOCK_01_PROGRAM_ACCOUNT_ID; + 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(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" ); } } diff --git a/sequencer/core/src/mock.rs b/sequencer/core/src/mock.rs index 45a682e2..ebe6ea5d 100644 --- a/sequencer/core/src/mock.rs +++ b/sequencer/core/src/mock.rs @@ -1,76 +1,34 @@ -use anyhow::{Result, anyhow}; -use bedrock_client::SignedMantleTx; -use logos_blockchain_core::mantle::ops::channel::ChannelId; +use std::time::Duration; + +use anyhow::Result; +use common::block::Block; use logos_blockchain_key_management_system_service::keys::Ed25519Key; -use url::Url; use crate::{ - block_settlement_client::BlockSettlementClientTrait, config::BedrockConfig, - indexer_client::IndexerClientTrait, + block_publisher::{ + BlockPublisherTrait, CheckpointSink, FinalizedBlockSink, SequencerCheckpoint, + }, + config::BedrockConfig, }; -pub type SequencerCoreWithMockClients = - crate::SequencerCore; +pub type SequencerCoreWithMockClients = crate::SequencerCore; #[derive(Clone)] -pub struct MockBlockSettlementClient { - bedrock_channel_id: ChannelId, - bedrock_signing_key: Ed25519Key, -} +pub struct MockBlockPublisher; -impl BlockSettlementClientTrait for MockBlockSettlementClient { - fn new(config: &BedrockConfig, signing_key: Ed25519Key) -> Result { - Ok(Self { - bedrock_channel_id: config.channel_id, - bedrock_signing_key: signing_key, - }) +impl BlockPublisherTrait for MockBlockPublisher { + async fn new( + _config: &BedrockConfig, + _bedrock_signing_key: Ed25519Key, + _resubmit_interval: Duration, + _initial_checkpoint: Option, + _on_checkpoint: CheckpointSink, + _on_finalized_block: FinalizedBlockSink, + ) -> Result { + Ok(Self) } - fn bedrock_channel_id(&self) -> ChannelId { - self.bedrock_channel_id - } - - fn bedrock_signing_key(&self) -> &Ed25519Key { - &self.bedrock_signing_key - } - - async fn submit_inscribe_tx_to_bedrock(&self, _tx: SignedMantleTx) -> Result<()> { + async fn publish_block(&self, _block: &Block) -> Result<()> { Ok(()) } } - -#[derive(Clone)] -pub struct MockBlockSettlementClientWithError { - bedrock_channel_id: ChannelId, - bedrock_signing_key: Ed25519Key, -} - -impl BlockSettlementClientTrait for MockBlockSettlementClientWithError { - fn new(config: &BedrockConfig, signing_key: Ed25519Key) -> Result { - Ok(Self { - bedrock_channel_id: config.channel_id, - bedrock_signing_key: signing_key, - }) - } - - fn bedrock_channel_id(&self) -> ChannelId { - self.bedrock_channel_id - } - - fn bedrock_signing_key(&self) -> &Ed25519Key { - &self.bedrock_signing_key - } - - async fn submit_inscribe_tx_to_bedrock(&self, _tx: SignedMantleTx) -> Result<()> { - Err(anyhow!("Mock error")) - } -} - -#[derive(Copy, Clone)] -pub struct MockIndexerClient; - -impl IndexerClientTrait for MockIndexerClient { - async fn new(_indexer_url: &Url) -> Result { - Ok(Self) - } -} diff --git a/sequencer/service/Cargo.toml b/sequencer/service/Cargo.toml index 6fee808c..beed6be2 100644 --- a/sequencer/service/Cargo.toml +++ b/sequencer/service/Cargo.toml @@ -14,7 +14,6 @@ mempool.workspace = true sequencer_core = { workspace = true, features = ["testnet"] } sequencer_service_protocol.workspace = true sequencer_service_rpc = { workspace = true, features = ["server"] } -indexer_service_rpc = { workspace = true, features = ["client"] } clap = { workspace = true, features = ["derive", "env"] } anyhow.workspace = true diff --git a/sequencer/service/configs/debug/sequencer_config.json b/sequencer/service/configs/debug/sequencer_config.json index d11cb789..bfe963ae 100644 --- a/sequencer/service/configs/debug/sequencer_config.json +++ b/sequencer/service/configs/debug/sequencer_config.json @@ -1,7 +1,5 @@ { "home": ".", - "genesis_id": 1, - "is_genesis_random": true, "max_num_tx_in_block": 20, "max_block_size": "1 MiB", "mempool_max_size": 1000, @@ -16,117 +14,29 @@ "node_url": "http://localhost:8080" }, "indexer_rpc_url": "ws://localhost:8779", - "initial_accounts": [ + "genesis": [ { - "account_id": "jZvdpERLqEkzk6CAz6vDuDJ1wx5aoyFpDa1VFmRvuPX", - "balance": 10000 - }, - { - "account_id": "3jQfsyRyvVpBfdkZegf8QpjfcDq1M5RAXB4H4eJ4kTtf", - "balance": 20000 - } - ], - "initial_commitments": [ - { - "npk": [ - 139, - 19, - 158, - 11, - 155, - 231, - 85, - 206, - 132, - 228, - 220, - 114, - 145, - 89, - 113, - 156, - 238, - 142, - 242, - 74, - 182, - 91, - 43, - 100, - 6, - 190, - 31, - 15, - 31, - 88, - 96, - 204 - ], - "account": { - "program_owner": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "balance": 10000, - "data": [], - "nonce": 0 + "supply_account": { + "account_id": "CbgR6tj5kWx5oziiFptM7jMvrQeYY3Mzaao6ciuhSr2r", + "balance": 10000 } }, { - "npk": [ - 173, - 134, - 33, - 223, - 54, - 226, - 10, - 71, - 215, - 254, - 143, - 172, - 24, - 244, - 243, - 208, - 65, - 112, - 118, - 70, - 217, - 240, - 69, - 100, - 129, - 3, - 121, - 25, - 213, - 132, - 42, - 45 - ], - "account": { - "program_owner": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "balance": 20000, - "data": [], - "nonce": 0 + "supply_account": { + "account_id": "2RHZhw9h534Zr3eq2RGhQete2Hh667foECzXPmSkGni2", + "balance": 20000 + } + }, + { + "supply_account": { + "account_id": "5T7EJX45Vi8rYgVjeiiQbdgM2Eun4Hq5S8uMiupiwWY1", + "balance": 10000 + } + }, + { + "supply_account": { + "account_id": "2ByFEVzfMKC13jvk1nZuFVGYoDVW1iAxvvFnqboV1aAg", + "balance": 20000 } } ], diff --git a/sequencer/service/configs/docker/sequencer_config.json b/sequencer/service/configs/docker/sequencer_config.json index b41711f7..c9d0e6a6 100644 --- a/sequencer/service/configs/docker/sequencer_config.json +++ b/sequencer/service/configs/docker/sequencer_config.json @@ -1,7 +1,5 @@ { "home": "/var/lib/sequencer_service", - "genesis_id": 1, - "is_genesis_random": true, "max_num_tx_in_block": 20, "max_block_size": "1 MiB", "mempool_max_size": 10000, @@ -16,117 +14,29 @@ "node_url": "http://localhost:18080" }, "indexer_rpc_url": "ws://localhost:8779", - "initial_accounts": [ + "genesis": [ { - "account_id": "jZvdpERLqEkzk6CAz6vDuDJ1wx5aoyFpDa1VFmRvuPX", - "balance": 10000 - }, - { - "account_id": "3jQfsyRyvVpBfdkZegf8QpjfcDq1M5RAXB4H4eJ4kTtf", - "balance": 20000 - } - ], - "initial_commitments": [ - { - "npk": [ - 139, - 19, - 158, - 11, - 155, - 231, - 85, - 206, - 132, - 228, - 220, - 114, - 145, - 89, - 113, - 156, - 238, - 142, - 242, - 74, - 182, - 91, - 43, - 100, - 6, - 190, - 31, - 15, - 31, - 88, - 96, - 204 - ], - "account": { - "program_owner": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "balance": 10000, - "data": [], - "nonce": 0 + "supply_account": { + "account_id": "CbgR6tj5kWx5oziiFptM7jMvrQeYY3Mzaao6ciuhSr2r", + "balance": 10000 } }, { - "npk": [ - 173, - 134, - 33, - 223, - 54, - 226, - 10, - 71, - 215, - 254, - 143, - 172, - 24, - 244, - 243, - 208, - 65, - 112, - 118, - 70, - 217, - 240, - 69, - 100, - 129, - 3, - 121, - 25, - 213, - 132, - 42, - 45 - ], - "account": { - "program_owner": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "balance": 20000, - "data": [], - "nonce": 0 + "supply_account": { + "account_id": "2RHZhw9h534Zr3eq2RGhQete2Hh667foECzXPmSkGni2", + "balance": 20000 + } + }, + { + "supply_account": { + "account_id": "5T7EJX45Vi8rYgVjeiiQbdgM2Eun4Hq5S8uMiupiwWY1", + "balance": 10000 + } + }, + { + "supply_account": { + "account_id": "2ByFEVzfMKC13jvk1nZuFVGYoDVW1iAxvvFnqboV1aAg", + "balance": 20000 } } ], diff --git a/sequencer/service/src/lib.rs b/sequencer/service/src/lib.rs index 5373b31f..319b75ad 100644 --- a/sequencer/service/src/lib.rs +++ b/sequencer/service/src/lib.rs @@ -5,15 +5,13 @@ use bytesize::ByteSize; use common::transaction::NSSATransaction; use futures::never::Never; use jsonrpsee::server::ServerHandle; -#[cfg(not(feature = "standalone"))] -use log::warn; use log::{error, info}; use mempool::MemPoolHandle; +#[cfg(not(feature = "standalone"))] +use sequencer_core::SequencerCore; #[cfg(feature = "standalone")] use sequencer_core::SequencerCoreWithMockClients as SequencerCore; pub use sequencer_core::config::*; -#[cfg(not(feature = "standalone"))] -use sequencer_core::{SequencerCore, block_settlement_client::BlockSettlementClientTrait as _}; use sequencer_service_rpc::RpcServer as _; use tokio::{sync::Mutex, task::JoinHandle}; @@ -29,8 +27,6 @@ pub struct SequencerHandle { /// Option because of `Drop` which forbids to simply move out of `self` in `stopped()`. server_handle: Option, main_loop_handle: JoinHandle>, - retry_pending_blocks_loop_handle: JoinHandle>, - listen_for_bedrock_blocks_loop_handle: JoinHandle>, } impl SequencerHandle { @@ -38,15 +34,11 @@ impl SequencerHandle { addr: SocketAddr, server_handle: ServerHandle, main_loop_handle: JoinHandle>, - retry_pending_blocks_loop_handle: JoinHandle>, - listen_for_bedrock_blocks_loop_handle: JoinHandle>, ) -> Self { Self { addr, server_handle: Some(server_handle), main_loop_handle, - retry_pending_blocks_loop_handle, - listen_for_bedrock_blocks_loop_handle, } } @@ -60,8 +52,6 @@ impl SequencerHandle { addr: _, server_handle, main_loop_handle, - retry_pending_blocks_loop_handle, - listen_for_bedrock_blocks_loop_handle, } = &mut self; let server_handle = server_handle.take().expect("Server handle is set"); @@ -75,16 +65,6 @@ impl SequencerHandle { .context("Main loop task panicked")? .context("Main loop exited unexpectedly") } - res = retry_pending_blocks_loop_handle => { - res - .context("Retry pending blocks loop task panicked")? - .context("Retry pending blocks loop exited unexpectedly") - } - res = listen_for_bedrock_blocks_loop_handle => { - res - .context("Listen for bedrock blocks loop task panicked")? - .context("Listen for bedrock blocks loop exited unexpectedly") - } } } @@ -98,14 +78,10 @@ impl SequencerHandle { addr: _, server_handle, main_loop_handle, - retry_pending_blocks_loop_handle, - listen_for_bedrock_blocks_loop_handle, } = self; let stopped = server_handle.as_ref().is_none_or(ServerHandle::is_stopped) - || main_loop_handle.is_finished() - || retry_pending_blocks_loop_handle.is_finished() - || listen_for_bedrock_blocks_loop_handle.is_finished(); + || main_loop_handle.is_finished(); !stopped } @@ -121,13 +97,9 @@ impl Drop for SequencerHandle { addr: _, server_handle, main_loop_handle, - retry_pending_blocks_loop_handle, - listen_for_bedrock_blocks_loop_handle, } = self; main_loop_handle.abort(); - retry_pending_blocks_loop_handle.abort(); - listen_for_bedrock_blocks_loop_handle.abort(); let Some(handle) = server_handle else { return; @@ -141,7 +113,6 @@ impl Drop for SequencerHandle { pub async fn run(config: SequencerConfig, port: u16) -> Result { let block_timeout = config.block_create_timeout; - let retry_pending_blocks_timeout = config.retry_pending_blocks_timeout; let max_block_size = config.max_block_size; let (sequencer_core, mempool_handle) = SequencerCore::start_from_config(config).await; @@ -159,34 +130,10 @@ pub async fn run(config: SequencerConfig, port: u16) -> Result .await?; info!("RPC server started"); - #[cfg(not(feature = "standalone"))] - { - info!("Submitting stored pending blocks"); - retry_pending_blocks(&seq_core_wrapped) - .await - .expect("Failed to submit pending blocks on startup"); - } - info!("Starting main sequencer loop"); - let main_loop_handle = tokio::spawn(main_loop(Arc::clone(&seq_core_wrapped), block_timeout)); + let main_loop_handle = tokio::spawn(main_loop(seq_core_wrapped, block_timeout)); - info!("Starting pending block retry loop"); - let retry_pending_blocks_loop_handle = tokio::spawn(retry_pending_blocks_loop( - Arc::clone(&seq_core_wrapped), - retry_pending_blocks_timeout, - )); - - info!("Starting bedrock block listening loop"); - let listen_for_bedrock_blocks_loop_handle = - tokio::spawn(listen_for_bedrock_blocks_loop(seq_core_wrapped)); - - Ok(SequencerHandle::new( - addr, - server_handle, - main_loop_handle, - retry_pending_blocks_loop_handle, - listen_for_bedrock_blocks_loop_handle, - )) + Ok(SequencerHandle::new(addr, server_handle, main_loop_handle)) } async fn run_server( @@ -235,118 +182,3 @@ async fn main_loop(seq_core: Arc>, block_timeout: Duration) info!("Waiting for new transactions"); } } - -#[cfg(not(feature = "standalone"))] -async fn retry_pending_blocks(seq_core: &Arc>) -> Result<()> { - use std::time::Instant; - - use log::debug; - - let (mut pending_blocks, block_settlement_client) = { - let sequencer_core = seq_core.lock().await; - let client = sequencer_core.block_settlement_client(); - let pending_blocks = sequencer_core - .get_pending_blocks() - .expect("Sequencer should be able to retrieve pending blocks"); - (pending_blocks, client) - }; - - pending_blocks.sort_by(|block1, block2| block1.header.block_id.cmp(&block2.header.block_id)); - - if !pending_blocks.is_empty() { - info!( - "Resubmitting blocks from {} to {}", - pending_blocks.first().unwrap().header.block_id, - pending_blocks.last().unwrap().header.block_id - ); - } - - for block in &pending_blocks { - debug!( - "Resubmitting pending block with id {}", - block.header.block_id - ); - // TODO: We could cache the inscribe tx for each pending block to avoid re-creating it - // on every retry. - let now = Instant::now(); - let (tx, _msg_id) = block_settlement_client - .create_inscribe_tx(block) - .context("Failed to create inscribe tx for pending block")?; - - debug!("Create inscribe: {:?}", now.elapsed()); - - let now = Instant::now(); - if let Err(e) = block_settlement_client - .submit_inscribe_tx_to_bedrock(tx) - .await - { - warn!( - "Failed to resubmit block with id {} with error {e:#}", - block.header.block_id - ); - } - debug!("Post: {:?}", now.elapsed()); - } - Ok(()) -} - -#[cfg(not(feature = "standalone"))] -async fn retry_pending_blocks_loop( - seq_core: Arc>, - retry_pending_blocks_timeout: Duration, -) -> Result { - loop { - tokio::time::sleep(retry_pending_blocks_timeout).await; - retry_pending_blocks(&seq_core).await?; - } -} - -#[cfg(not(feature = "standalone"))] -async fn listen_for_bedrock_blocks_loop(seq_core: Arc>) -> Result { - use indexer_service_rpc::RpcClient as _; - - let indexer_client = seq_core.lock().await.indexer_client(); - - let retry_delay = Duration::from_secs(5); - - loop { - // TODO: Subscribe from the first pending block ID? - let mut subscription = indexer_client - .subscribe_to_finalized_blocks() - .await - .context("Failed to subscribe to finalized blocks")?; - - while let Some(block_id) = subscription.next().await { - let block_id = block_id.context("Failed to get next block from subscription")?; - - info!("Received new L2 block with ID {block_id}"); - - seq_core - .lock() - .await - .clean_finalized_blocks_from_db(block_id) - .with_context(|| { - format!("Failed to clean finalized blocks from DB for block ID {block_id}") - })?; - } - - warn!( - "Block subscription closed unexpectedly, reason: {:?}, retrying after {retry_delay:?}", - subscription.close_reason() - ); - tokio::time::sleep(retry_delay).await; - } -} - -#[cfg(feature = "standalone")] -async fn listen_for_bedrock_blocks_loop(_seq_core: Arc>) -> Result { - std::future::pending::>().await -} - -#[cfg(feature = "standalone")] -async fn retry_pending_blocks_loop( - _seq_core: Arc>, - _retry_pending_blocks_timeout: Duration, -) -> Result { - std::future::pending::>().await -} diff --git a/sequencer/service/src/service.rs b/sequencer/service/src/service.rs index 71645363..0bb8e1dd 100644 --- a/sequencer/service/src/service.rs +++ b/sequencer/service/src/service.rs @@ -8,10 +8,7 @@ use jsonrpsee::{ use log::warn; use mempool::MemPoolHandle; use nssa::{self, program::Program}; -use sequencer_core::{ - DbError, SequencerCore, block_settlement_client::BlockSettlementClientTrait, - indexer_client::IndexerClientTrait, -}; +use sequencer_core::{DbError, SequencerCore, block_publisher::BlockPublisherTrait}; use sequencer_service_protocol::{ Account, AccountId, Block, BlockId, Commitment, HashType, MembershipProof, Nonce, ProgramId, }; @@ -19,15 +16,15 @@ use tokio::sync::Mutex; const NOT_FOUND_ERROR_CODE: i32 = -31999; -pub struct SequencerService { - sequencer: Arc>>, +pub struct SequencerService { + sequencer: Arc>>, mempool_handle: MemPoolHandle, max_block_size: u64, } -impl SequencerService { +impl SequencerService { pub const fn new( - sequencer: Arc>>, + sequencer: Arc>>, mempool_handle: MemPoolHandle, max_block_size: u64, ) -> Self { @@ -40,8 +37,8 @@ impl SequencerService - sequencer_service_rpc::RpcServer for SequencerService +impl sequencer_service_rpc::RpcServer + for SequencerService { async fn send_transaction(&self, tx: NSSATransaction) -> Result { // Reserve ~200 bytes for block header overhead diff --git a/storage/src/cells/mod.rs b/storage/src/cells/mod.rs new file mode 100644 index 00000000..76c1ff8c --- /dev/null +++ b/storage/src/cells/mod.rs @@ -0,0 +1,96 @@ +use std::sync::Arc; + +use borsh::{BorshDeserialize, BorshSerialize}; +use rocksdb::{BoundColumnFamily, DBWithThreadMode, MultiThreaded, WriteBatch}; + +use crate::{DbResult, error::DbError}; + +pub mod shared_cells; + +pub trait SimpleStorableCell { + const CF_NAME: &'static str; + const CELL_NAME: &'static str; + type KeyParams; + + fn key_constructor(_params: Self::KeyParams) -> DbResult> { + borsh::to_vec(&Self::CELL_NAME).map_err(|err| { + DbError::borsh_cast_message( + err, + Some(format!("Failed to serialize {:?}", Self::CELL_NAME)), + ) + }) + } + + fn column_ref(db: &DBWithThreadMode) -> Arc> { + db.cf_handle(Self::CF_NAME) + .unwrap_or_else(|| panic!("Column family {:?} must be present", Self::CF_NAME)) + } +} + +pub trait SimpleReadableCell: SimpleStorableCell + BorshDeserialize { + fn get(db: &DBWithThreadMode, params: Self::KeyParams) -> DbResult { + let res = Self::get_opt(db, params)?; + + res.ok_or_else(|| DbError::db_interaction_error(format!("{:?} not found", Self::CELL_NAME))) + } + + fn get_opt( + db: &DBWithThreadMode, + params: Self::KeyParams, + ) -> DbResult> { + let cf_ref = Self::column_ref(db); + let res = db + .get_cf(&cf_ref, Self::key_constructor(params)?) + .map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some(format!("Failed to read {:?}", Self::CELL_NAME)), + ) + })?; + + res.map(|data| { + borsh::from_slice::(&data).map_err(|err| { + DbError::borsh_cast_message( + err, + Some(format!("Failed to deserialize {:?}", Self::CELL_NAME)), + ) + }) + }) + .transpose() + } +} + +pub trait SimpleWritableCell: SimpleStorableCell + BorshSerialize { + fn value_constructor(&self) -> DbResult>; + + fn put(&self, db: &DBWithThreadMode, params: Self::KeyParams) -> DbResult<()> { + let cf_ref = Self::column_ref(db); + db.put_cf( + &cf_ref, + Self::key_constructor(params)?, + self.value_constructor()?, + ) + .map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some(format!("Failed to write {:?}", Self::CELL_NAME)), + ) + })?; + Ok(()) + } + + fn put_batch( + &self, + db: &DBWithThreadMode, + params: Self::KeyParams, + write_batch: &mut WriteBatch, + ) -> DbResult<()> { + let cf_ref = Self::column_ref(db); + write_batch.put_cf( + &cf_ref, + Self::key_constructor(params)?, + self.value_constructor()?, + ); + Ok(()) + } +} diff --git a/storage/src/cells/shared_cells.rs b/storage/src/cells/shared_cells.rs new file mode 100644 index 00000000..1efd0e35 --- /dev/null +++ b/storage/src/cells/shared_cells.rs @@ -0,0 +1,97 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use common::block::Block; + +use crate::{ + BLOCK_CELL_NAME, CF_BLOCK_NAME, CF_META_NAME, DB_META_FIRST_BLOCK_IN_DB_KEY, + DB_META_FIRST_BLOCK_SET_KEY, DB_META_LAST_BLOCK_IN_DB_KEY, DbResult, + cells::{SimpleReadableCell, SimpleStorableCell, SimpleWritableCell}, + error::DbError, +}; + +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct LastBlockCell(pub u64); + +impl SimpleStorableCell for LastBlockCell { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_LAST_BLOCK_IN_DB_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for LastBlockCell {} + +impl SimpleWritableCell for LastBlockCell { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize last block id".to_owned())) + }) + } +} + +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct FirstBlockSetCell(pub bool); + +impl SimpleStorableCell for FirstBlockSetCell { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_FIRST_BLOCK_SET_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for FirstBlockSetCell {} + +impl SimpleWritableCell for FirstBlockSetCell { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize first block set flag".to_owned()), + ) + }) + } +} + +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct FirstBlockCell(pub u64); + +impl SimpleStorableCell for FirstBlockCell { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_FIRST_BLOCK_IN_DB_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for FirstBlockCell {} + +impl SimpleWritableCell for FirstBlockCell { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize first block id".to_owned())) + }) + } +} + +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct BlockCell(pub Block); + +impl SimpleStorableCell for BlockCell { + type KeyParams = u64; + + const CELL_NAME: &'static str = BLOCK_CELL_NAME; + const CF_NAME: &'static str = CF_BLOCK_NAME; + + fn key_constructor(params: Self::KeyParams) -> DbResult> { + // ToDo: Replace with increasing ordering serialization + borsh::to_vec(¶ms).map_err(|err| { + DbError::borsh_cast_message( + err, + Some(format!( + "Failed to serialize {:?} key params", + Self::CELL_NAME + )), + ) + }) + } +} + +impl SimpleReadableCell for BlockCell {} diff --git a/storage/src/indexer/indexer_cells.rs b/storage/src/indexer/indexer_cells.rs new file mode 100644 index 00000000..615902bd --- /dev/null +++ b/storage/src/indexer/indexer_cells.rs @@ -0,0 +1,266 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use nssa::V03State; + +use crate::{ + CF_META_NAME, DbResult, + cells::{SimpleReadableCell, SimpleStorableCell, SimpleWritableCell}, + 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, + }, +}; + +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct LastObservedL1LibHeaderCell(pub [u8; 32]); + +impl SimpleStorableCell for LastObservedL1LibHeaderCell { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_LAST_OBSERVED_L1_LIB_HEADER_ID_IN_DB_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for LastObservedL1LibHeaderCell {} + +impl SimpleWritableCell for LastObservedL1LibHeaderCell { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize last observed l1 header".to_owned()), + ) + }) + } +} + +#[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); + +impl SimpleStorableCell for BreakpointCellOwned { + type KeyParams = u64; + + const CELL_NAME: &'static str = BREAKPOINT_CELL_NAME; + const CF_NAME: &'static str = CF_BREAKPOINT_NAME; + + fn key_constructor(params: Self::KeyParams) -> DbResult> { + borsh::to_vec(¶ms).map_err(|err| { + DbError::borsh_cast_message( + err, + Some(format!( + "Failed to serialize {:?} key params", + Self::CELL_NAME + )), + ) + }) + } +} + +impl SimpleReadableCell for BreakpointCellOwned {} + +#[derive(BorshSerialize)] +pub struct BreakpointCellRef<'state>(pub &'state V03State); + +impl SimpleStorableCell for BreakpointCellRef<'_> { + type KeyParams = u64; + + const CELL_NAME: &'static str = BREAKPOINT_CELL_NAME; + const CF_NAME: &'static str = CF_BREAKPOINT_NAME; + + fn key_constructor(params: Self::KeyParams) -> DbResult> { + borsh::to_vec(¶ms).map_err(|err| { + DbError::borsh_cast_message( + err, + Some(format!( + "Failed to serialize {:?} key params", + Self::CELL_NAME + )), + ) + }) + } +} + +impl SimpleWritableCell for BreakpointCellRef<'_> { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize breakpoint".to_owned())) + }) + } +} + +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct BlockHashToBlockIdMapCell(pub u64); + +impl SimpleStorableCell for BlockHashToBlockIdMapCell { + type KeyParams = [u8; 32]; + + const CELL_NAME: &'static str = BLOCK_HASH_CELL_NAME; + const CF_NAME: &'static str = CF_HASH_TO_ID; + + fn key_constructor(params: Self::KeyParams) -> DbResult> { + borsh::to_vec(¶ms).map_err(|err| { + DbError::borsh_cast_message( + err, + Some(format!( + "Failed to serialize {:?} key params", + Self::CELL_NAME + )), + ) + }) + } +} + +impl SimpleReadableCell for BlockHashToBlockIdMapCell {} + +impl SimpleWritableCell for BlockHashToBlockIdMapCell { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize block id".to_owned())) + }) + } +} + +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct TxHashToBlockIdMapCell(pub u64); + +impl SimpleStorableCell for TxHashToBlockIdMapCell { + type KeyParams = [u8; 32]; + + const CELL_NAME: &'static str = TX_HASH_CELL_NAME; + const CF_NAME: &'static str = CF_TX_TO_ID; + + fn key_constructor(params: Self::KeyParams) -> DbResult> { + borsh::to_vec(¶ms).map_err(|err| { + DbError::borsh_cast_message( + err, + Some(format!( + "Failed to serialize {:?} key params", + Self::CELL_NAME + )), + ) + }) + } +} + +impl SimpleReadableCell for TxHashToBlockIdMapCell {} + +impl SimpleWritableCell for TxHashToBlockIdMapCell { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize block id".to_owned())) + }) + } +} + +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct AccNumTxCell(pub u64); + +impl SimpleStorableCell for AccNumTxCell { + type KeyParams = [u8; 32]; + + const CELL_NAME: &'static str = ACC_NUM_CELL_NAME; + const CF_NAME: &'static str = CF_ACC_META; + + fn key_constructor(params: Self::KeyParams) -> DbResult> { + borsh::to_vec(¶ms).map_err(|err| { + DbError::borsh_cast_message( + err, + Some(format!( + "Failed to serialize {:?} key params", + Self::CELL_NAME + )), + ) + }) + } +} + +impl SimpleReadableCell for AccNumTxCell {} + +impl SimpleWritableCell for AccNumTxCell { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize number of transactions".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)] +pub struct ZoneSdkIndexerCursorCellOwned(pub Vec); + +impl SimpleStorableCell for ZoneSdkIndexerCursorCellOwned { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_ZONE_SDK_INDEXER_CURSOR_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for ZoneSdkIndexerCursorCellOwned {} + +#[derive(BorshSerialize)] +pub struct ZoneSdkIndexerCursorCellRef<'bytes>(pub &'bytes [u8]); + +impl SimpleStorableCell for ZoneSdkIndexerCursorCellRef<'_> { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_ZONE_SDK_INDEXER_CURSOR_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleWritableCell for ZoneSdkIndexerCursorCellRef<'_> { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize zone-sdk indexer cursor cell".to_owned()), + ) + }) + } +} + +#[cfg(test)] +mod uniform_tests { + use crate::{ + cells::SimpleStorableCell as _, + indexer::indexer_cells::{BreakpointCellOwned, BreakpointCellRef}, + }; + + #[test] + fn breakpoint_ref_and_owned_is_aligned() { + assert_eq!(BreakpointCellRef::CELL_NAME, BreakpointCellOwned::CELL_NAME); + assert_eq!(BreakpointCellRef::CF_NAME, BreakpointCellOwned::CF_NAME); + assert_eq!( + BreakpointCellRef::key_constructor(1000).unwrap(), + BreakpointCellOwned::key_constructor(1000).unwrap() + ); + } +} diff --git a/storage/src/indexer/mod.rs b/storage/src/indexer/mod.rs index 6a9a67b6..97be70e5 100644 --- a/storage/src/indexer/mod.rs +++ b/storage/src/indexer/mod.rs @@ -1,49 +1,39 @@ use std::{path::Path, sync::Arc}; -use common::block::Block; -use nssa::V03State; +use common::{ + block::Block, + transaction::{NSSATransaction, clock_invocation}, +}; +use nssa::{GENESIS_BLOCK_ID, V03State}; use rocksdb::{ BoundColumnFamily, ColumnFamilyDescriptor, DBWithThreadMode, MultiThreaded, Options, }; -use crate::error::DbError; +use crate::{BREAKPOINT_INTERVAL, CF_BLOCK_NAME, CF_META_NAME, DBIO, DbResult, error::DbError}; +pub mod indexer_cells; pub mod read_multiple; pub mod read_once; pub mod write_atomic; pub mod write_non_atomic; -/// Maximal size of stored blocks in base. -/// -/// Used to control db size. -/// -/// Currently effectively unbounded. -pub const BUFF_SIZE_ROCKSDB: usize = usize::MAX; - -/// Size of stored blocks cache in memory. -/// -/// Keeping small to not run out of memory. -pub const CACHE_SIZE: usize = 1000; - -/// Key base for storing metainformation about id of first block in db. -pub const DB_META_FIRST_BLOCK_IN_DB_KEY: &str = "first_block_in_db"; -/// Key base for storing metainformation about id of last current block in db. -pub const DB_META_LAST_BLOCK_IN_DB_KEY: &str = "last_block_in_db"; /// 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 which describe if first block has been set. -pub const DB_META_FIRST_BLOCK_SET_KEY: &str = "first_block_set"; /// 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"; -/// Interval between state breakpoints. -pub const BREAKPOINT_INTERVAL: u8 = 100; +/// Cell name for a breakpoint. +pub const BREAKPOINT_CELL_NAME: &str = "breakpoint"; +/// Cell name for a block hash to block id map. +pub const BLOCK_HASH_CELL_NAME: &str = "block hash"; +/// Cell name for a tx hash to block id map. +pub const TX_HASH_CELL_NAME: &str = "tx hash"; +/// Cell name for a account number of transactions. +pub const ACC_NUM_CELL_NAME: &str = "acc id"; -/// Name of block column family. -pub const CF_BLOCK_NAME: &str = "cf_block"; -/// Name of meta column family. -pub const CF_META_NAME: &str = "cf_meta"; /// Name of breakpoint column family. pub const CF_BREAKPOINT_NAME: &str = "cf_breakpoint"; /// Name of hash to id map column family. @@ -55,18 +45,19 @@ pub const CF_ACC_META: &str = "cf_acc_meta"; /// Name of account id to tx hash map column family. pub const CF_ACC_TO_TX: &str = "cf_acc_to_tx"; -pub type DbResult = Result; - pub struct RocksDBIO { pub db: DBWithThreadMode, } +impl DBIO for RocksDBIO { + fn db(&self) -> &DBWithThreadMode { + &self.db + } +} + impl RocksDBIO { - pub fn open_or_create( - path: &Path, - genesis_block: &Block, - initial_state: &V03State, - ) -> DbResult { + // TODO: Remove initial state when it will be included in genesis block + pub fn open_or_create(path: &Path, initial_state: &V03State) -> DbResult { let mut cf_opts = Options::default(); cf_opts.set_max_write_buffer_number(16); // ToDo: Add more column families for different data @@ -93,17 +84,9 @@ impl RocksDBIO { let dbio = Self { db }; - let is_start_set = dbio.get_meta_is_first_block_set()?; - if !is_start_set { - let block_id = genesis_block.header.block_id; - dbio.put_meta_last_block_in_db(block_id)?; - dbio.put_meta_first_block_in_db_batch(genesis_block)?; - dbio.put_meta_is_first_block_set()?; - - // First breakpoint setup - dbio.put_breakpoint(0, initial_state)?; - dbio.put_meta_last_breakpoint_id(0)?; - } + // First breakpoint setup + dbio.put_breakpoint(0, initial_state)?; + dbio.put_meta_last_breakpoint_id(0)?; Ok(dbio) } @@ -161,26 +144,63 @@ impl RocksDBIO { // State pub fn calculate_state_for_id(&self, block_id: u64) -> DbResult { - let last_block = self.get_meta_last_block_in_db()?; + let last_block_id = self.get_meta_last_block_id_in_db()?.unwrap_or(0); - if block_id <= last_block { - let br_id = closest_breakpoint_id(block_id); - let mut breakpoint = self.get_breakpoint(br_id)?; + if block_id > last_block_id { + return Err(DbError::db_interaction_error( + "Block on this id not found".to_owned(), + )); + } - // ToDo: update it to handle any genesis id - // right now works correctly only if genesis_id < BREAKPOINT_INTERVAL - let start = if br_id != 0 { - u64::from(BREAKPOINT_INTERVAL) - .checked_mul(br_id) - .expect("Reached maximum breakpoint id") - } else { - self.get_meta_first_block_in_db()? - }; + let br_id = closest_breakpoint_id(block_id); + let mut breakpoint = self.get_breakpoint(br_id)?; - for block in self.get_block_batch_seq( - start.checked_add(1).expect("Will be lesser that u64::MAX")..=block_id, - )? { - for transaction in block.body.transactions { + let start = u64::from(BREAKPOINT_INTERVAL) + .checked_mul(br_id) + .expect("Reached maximum breakpoint id"); + + for mut block in self.get_block_batch_seq( + start.checked_add(1).expect("Will be lesser that u64::MAX")..=block_id, + )? { + let expected_clock = NSSATransaction::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 { + NSSATransaction::Public(public_tx) => public_tx, + NSSATransaction::PrivacyPreserving(_) + | NSSATransaction::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| { @@ -201,16 +221,31 @@ impl RocksDBIO { } } - Ok(breakpoint) - } else { - Err(DbError::db_interaction_error( - "Block on this id not found".to_owned(), - )) + let NSSATransaction::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:?}" + )) + })?; } + + Ok(breakpoint) } pub fn final_state(&self) -> DbResult { - self.calculate_state_for_id(self.get_meta_last_block_in_db()?) + let last_block_id = self.get_meta_last_block_id_in_db()?.unwrap_or(0); + self.calculate_state_for_id(last_block_id) } } @@ -224,13 +259,14 @@ 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 nssa::{AccountId, PublicKey}; use tempfile::tempdir; use super::*; fn genesis_block() -> Block { - common::test_utils::produce_dummy_block(1, None, vec![]) + produce_dummy_block(1, None, vec![]) } fn acc1_sign_key() -> nssa::PrivateKey { @@ -256,26 +292,29 @@ mod tests { let dbio = RocksDBIO::open_or_create( temdir_path, - &genesis_block(), - &nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[]), + &nssa::V03State::new_with_genesis_accounts( + &[(acc1(), 10000), (acc2(), 20000)], + vec![], + 0, + ), ) .unwrap(); - let last_id = dbio.get_meta_last_block_in_db().unwrap(); - let first_id = dbio.get_meta_first_block_in_db().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().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, 1); - assert_eq!(first_id, 1); + 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, 0); - assert_eq!(last_block.header.hash, genesis_block().header.hash); + 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()) @@ -293,24 +332,30 @@ mod tests { let dbio = RocksDBIO::open_or_create( temdir_path, - &genesis_block(), - &nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[]), + &nssa::V03State::new_with_genesis_accounts( + &[(acc1(), 10000), (acc2(), 20000)], + vec![], + 0, + ), ) .unwrap(); - let prev_hash = genesis_block().header.hash; + 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 = common::test_utils::produce_dummy_block(2, Some(prev_hash), vec![transfer_tx]); + 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_in_db().unwrap(); - let first_id = dbio.get_meta_first_block_in_db().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() @@ -322,11 +367,11 @@ mod tests { let final_state = dbio.final_state().unwrap(); assert_eq!(last_id, 2); - assert_eq!(first_id, 1); + assert_eq!(first_id, Some(1)); assert_eq!(last_observed_l1_header, [1; 32]); assert!(is_first_set); - assert_eq!(last_br_id, 0); - assert_ne!(last_block.header.hash, genesis_block().header.hash); + 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, @@ -346,8 +391,11 @@ mod tests { let dbio = RocksDBIO::open_or_create( temdir_path, - &genesis_block(), - &nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[]), + &nssa::V03State::new_with_genesis_accounts( + &[(acc1(), 10000), (acc2(), 20000)], + vec![], + 0, + ), ) .unwrap(); @@ -355,11 +403,11 @@ mod tests { let to = acc2(); let sign_key = acc1_sign_key(); - for i in 1..=BREAKPOINT_INTERVAL { - let last_id = dbio.get_meta_last_block_in_db().unwrap(); - let last_block = dbio.get_block(last_id).unwrap().unwrap(); - - let prev_hash = last_block.header.hash; + 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, @@ -368,16 +416,12 @@ mod tests { 1, &sign_key, ); - let block = common::test_utils::produce_dummy_block( - (i + 1).into(), - Some(prev_hash), - vec![transfer_tx], - ); + 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_in_db().unwrap(); - let first_id = dbio.get_meta_first_block_in_db().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(); @@ -386,19 +430,19 @@ mod tests { let final_state = dbio.final_state().unwrap(); assert_eq!(last_id, 101); - assert_eq!(first_id, 1); + assert_eq!(first_id, Some(1)); assert!(is_first_set); - assert_eq!(last_br_id, 1); + 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, - 100 + 101 ); assert_eq!( final_state.get_account_by_id(acc2()).balance - prev_breakpoint.get_account_by_id(acc2()).balance, - 100 + 101 ); assert_eq!( breakpoint.get_account_by_id(acc1()).balance @@ -419,8 +463,11 @@ mod tests { let dbio = RocksDBIO::open_or_create( temdir_path, - &genesis_block(), - &nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[]), + &nssa::V03State::new_with_genesis_accounts( + &[(acc1(), 10000), (acc2(), 20000)], + vec![], + 0, + ), ) .unwrap(); @@ -428,31 +475,27 @@ mod tests { let to = acc2(); let sign_key = acc1_sign_key(); - let last_id = dbio.get_meta_last_block_in_db().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, 0, to, 1, &sign_key); - let block = common::test_utils::produce_dummy_block(2, Some(prev_hash), vec![transfer_tx]); + 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_in_db().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 = common::test_utils::produce_dummy_block(3, Some(prev_hash), vec![transfer_tx]); + 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_in_db().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; @@ -461,10 +504,10 @@ mod tests { let control_tx_hash1 = transfer_tx.hash(); - let block = common::test_utils::produce_dummy_block(4, Some(prev_hash), vec![transfer_tx]); + 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_in_db().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; @@ -473,7 +516,7 @@ mod tests { let control_tx_hash2 = transfer_tx.hash(); - let block = common::test_utils::produce_dummy_block(5, Some(prev_hash), vec![transfer_tx]); + 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(); @@ -487,10 +530,10 @@ mod tests { .unwrap() .unwrap(); - assert_eq!(control_block_id1, 2); - assert_eq!(control_block_id2, 3); - assert_eq!(control_block_id3, 4); - assert_eq!(control_block_id4, 5); + 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] @@ -502,8 +545,11 @@ mod tests { let dbio = RocksDBIO::open_or_create( temdir_path, - &genesis_block(), - &nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[]), + &nssa::V03State::new_with_genesis_accounts( + &[(acc1(), 10000), (acc2(), 20000)], + vec![], + 0, + ), ) .unwrap(); @@ -511,56 +557,52 @@ mod tests { let to = acc2(); let sign_key = acc1_sign_key(); - let last_id = dbio.get_meta_last_block_in_db().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, 0, to, 1, &sign_key); - let block = common::test_utils::produce_dummy_block(2, Some(prev_hash), vec![transfer_tx]); + 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_in_db().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 = common::test_utils::produce_dummy_block(3, Some(prev_hash), vec![transfer_tx]); + 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_in_db().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 = common::test_utils::produce_dummy_block(4, Some(prev_hash), vec![transfer_tx]); + 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_in_db().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 = common::test_utils::produce_dummy_block(5, Some(prev_hash), vec![transfer_tx]); + 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 6 (i.e., starting from 5 going backwards), limit 4 - // This should return blocks 5, 4, 3, 2 in descending order - let mut batch_res = dbio.get_block_batch(Some(6), 4).unwrap(); + // 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]> = @@ -570,9 +612,9 @@ mod tests { let block_hashes_mem_limited = &block_hashes_mem[1..]; - // Get blocks before ID 6, limit 3 - // This should return blocks 5, 4, 3 in descending order - let mut batch_res_limited = dbio.get_block_batch(Some(6), 3).unwrap(); + // 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 @@ -588,7 +630,7 @@ mod tests { .map(|block| block.header.block_id) .collect::>(); - assert_eq!(block_batch_ids, vec![1, 2, 3, 4, 5]); + assert_eq!(block_batch_ids, vec![1, 2, 3, 4]); } #[test] @@ -598,8 +640,11 @@ mod tests { let dbio = RocksDBIO::open_or_create( temdir_path, - &genesis_block(), - &nssa::V03State::new_with_genesis_accounts(&[(acc1(), 10000), (acc2(), 20000)], &[]), + &nssa::V03State::new_with_genesis_accounts( + &[(acc1(), 10000), (acc2(), 20000)], + vec![], + 0, + ), ) .unwrap(); @@ -609,10 +654,6 @@ mod tests { let mut tx_hash_res = vec![]; - let last_id = dbio.get_meta_last_block_in_db().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, 0, to, 1, &sign_key); let transfer_tx2 = @@ -620,15 +661,11 @@ mod tests { tx_hash_res.push(transfer_tx1.hash().0); tx_hash_res.push(transfer_tx2.hash().0); - let block = common::test_utils::produce_dummy_block( - 2, - Some(prev_hash), - vec![transfer_tx1, transfer_tx2], - ); + 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_in_db().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; @@ -639,15 +676,11 @@ mod tests { tx_hash_res.push(transfer_tx1.hash().0); tx_hash_res.push(transfer_tx2.hash().0); - let block = common::test_utils::produce_dummy_block( - 3, - Some(prev_hash), - vec![transfer_tx1, transfer_tx2], - ); + 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_in_db().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; @@ -658,15 +691,11 @@ mod tests { tx_hash_res.push(transfer_tx1.hash().0); tx_hash_res.push(transfer_tx2.hash().0); - let block = common::test_utils::produce_dummy_block( - 4, - Some(prev_hash), - vec![transfer_tx1, transfer_tx2], - ); + 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_in_db().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; @@ -674,7 +703,7 @@ mod tests { common::test_utils::create_transaction_native_token_transfer(from, 6, to, 1, &sign_key); tx_hash_res.push(transfer_tx.hash().0); - let block = common::test_utils::produce_dummy_block(5, Some(prev_hash), vec![transfer_tx]); + let block = produce_dummy_block(4, Some(prev_hash), vec![transfer_tx]); dbio.put_block(&block, [4; 32]).unwrap(); diff --git a/storage/src/indexer/read_multiple.rs b/storage/src/indexer/read_multiple.rs index 866fc7b0..d91e9627 100644 --- a/storage/src/indexer/read_multiple.rs +++ b/storage/src/indexer/read_multiple.rs @@ -12,7 +12,10 @@ impl RocksDBIO { before_id.saturating_sub(1) } else { // Get the latest block ID - self.get_meta_last_block_in_db()? + let Some(last) = self.get_meta_last_block_id_in_db()? else { + return Ok(vec![]); // No blocks in the database + }; + last }; for i in 0..limit { diff --git a/storage/src/indexer/read_once.rs b/storage/src/indexer/read_once.rs index 74d1afe9..6e79adc4 100644 --- a/storage/src/indexer/read_once.rs +++ b/storage/src/indexer/read_once.rs @@ -1,272 +1,76 @@ -use super::{ - Block, DB_META_FIRST_BLOCK_IN_DB_KEY, DB_META_FIRST_BLOCK_SET_KEY, - DB_META_LAST_BLOCK_IN_DB_KEY, DB_META_LAST_BREAKPOINT_ID, - DB_META_LAST_OBSERVED_L1_LIB_HEADER_ID_IN_DB_KEY, DbError, DbResult, RocksDBIO, V03State, +use super::{Block, DbResult, RocksDBIO, V03State}; +use crate::{ + DBIO as _, + cells::shared_cells::{BlockCell, FirstBlockCell, FirstBlockSetCell, LastBlockCell}, + indexer::indexer_cells::{ + AccNumTxCell, BlockHashToBlockIdMapCell, BreakpointCellOwned, LastBreakpointIdCell, + LastObservedL1LibHeaderCell, TxHashToBlockIdMapCell, ZoneSdkIndexerCursorCellOwned, + }, }; #[expect(clippy::multiple_inherent_impl, reason = "Readability")] impl RocksDBIO { // Meta - pub fn get_meta_first_block_in_db(&self) -> DbResult { - let cf_meta = self.meta_column(); - let res = self - .db - .get_cf( - &cf_meta, - borsh::to_vec(&DB_META_FIRST_BLOCK_IN_DB_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_FIRST_BLOCK_IN_DB_KEY".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - if let Some(data) = res { - Ok(borsh::from_slice::(&data).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to deserialize first block".to_owned()), - ) - })?) - } else { - Err(DbError::db_interaction_error( - "First block not found".to_owned(), - )) - } + pub fn get_meta_first_block_id_in_db(&self) -> DbResult> { + self.get_opt::(()) + .map(|opt| opt.map(|cell| cell.0)) } - pub fn get_meta_last_block_in_db(&self) -> DbResult { - let cf_meta = self.meta_column(); - let res = self - .db - .get_cf( - &cf_meta, - borsh::to_vec(&DB_META_LAST_BLOCK_IN_DB_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_LAST_BLOCK_IN_DB_KEY".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - if let Some(data) = res { - Ok(borsh::from_slice::(&data).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to deserialize last block".to_owned()), - ) - })?) - } else { - Err(DbError::db_interaction_error( - "Last block not found".to_owned(), - )) - } + pub fn get_meta_last_block_id_in_db(&self) -> DbResult> { + self.get_opt::(()) + .map(|opt| opt.map(|cell| cell.0)) } pub fn get_meta_last_observed_l1_lib_header_in_db(&self) -> DbResult> { - let cf_meta = self.meta_column(); - let res = self - .db - .get_cf( - &cf_meta, - borsh::to_vec(&DB_META_LAST_OBSERVED_L1_LIB_HEADER_ID_IN_DB_KEY).map_err( - |err| { - DbError::borsh_cast_message( - err, - Some( - "Failed to serialize DB_META_LAST_OBSERVED_L1_LIB_HEADER_ID_IN_DB_KEY" - .to_owned(), - ), - ) - }, - )?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - res.map(|data| { - borsh::from_slice::<[u8; 32]>(&data).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to deserialize last l1 lib header".to_owned()), - ) - }) - }) - .transpose() + self.get_opt::(()) + .map(|opt| opt.map(|val| val.0)) } pub fn get_meta_is_first_block_set(&self) -> DbResult { - let cf_meta = self.meta_column(); - let res = self - .db - .get_cf( - &cf_meta, - borsh::to_vec(&DB_META_FIRST_BLOCK_SET_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_FIRST_BLOCK_SET_KEY".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - Ok(res.is_some()) + Ok(self.get_opt::(())?.is_some()) } - pub fn get_meta_last_breakpoint_id(&self) -> DbResult { - let cf_meta = self.meta_column(); - let res = self - .db - .get_cf( - &cf_meta, - borsh::to_vec(&DB_META_LAST_BREAKPOINT_ID).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_LAST_BREAKPOINT_ID".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - if let Some(data) = res { - Ok(borsh::from_slice::(&data).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to deserialize last breakpoint id".to_owned()), - ) - })?) - } else { - Err(DbError::db_interaction_error( - "Last breakpoint id not found".to_owned(), - )) - } + pub fn get_meta_last_breakpoint_id(&self) -> DbResult> { + self.get_opt::(()) + .map(|opt| opt.map(|cell| cell.0)) } // Block pub fn get_block(&self, block_id: u64) -> DbResult> { - let cf_block = self.block_column(); - let res = self - .db - .get_cf( - &cf_block, - borsh::to_vec(&block_id).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize block id".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - if let Some(data) = res { - Ok(Some(borsh::from_slice::(&data).map_err(|serr| { - DbError::borsh_cast_message( - serr, - Some("Failed to deserialize block data".to_owned()), - ) - })?)) - } else { - Ok(None) - } + self.get_opt::(block_id) + .map(|opt| opt.map(|val| val.0)) } // State pub fn get_breakpoint(&self, br_id: u64) -> DbResult { - let cf_br = self.breakpoint_column(); - let res = self - .db - .get_cf( - &cf_br, - borsh::to_vec(&br_id).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize breakpoint id".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - if let Some(data) = res { - Ok(borsh::from_slice::(&data).map_err(|serr| { - DbError::borsh_cast_message( - serr, - Some("Failed to deserialize breakpoint data".to_owned()), - ) - })?) - } else { - Err(DbError::db_interaction_error( - "Breakpoint on this id not found".to_owned(), - )) - } + self.get::(br_id).map(|cell| cell.0) } // Mappings pub fn get_block_id_by_hash(&self, hash: [u8; 32]) -> DbResult> { - let cf_hti = self.hash_to_id_column(); - let res = self - .db - .get_cf( - &cf_hti, - borsh::to_vec(&hash).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize block hash".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - if let Some(data) = res { - Ok(Some(borsh::from_slice::(&data).map_err(|serr| { - DbError::borsh_cast_message(serr, Some("Failed to deserialize block id".to_owned())) - })?)) - } else { - Ok(None) - } + self.get_opt::(hash) + .map(|opt| opt.map(|cell| cell.0)) } pub fn get_block_id_by_tx_hash(&self, tx_hash: [u8; 32]) -> DbResult> { - let cf_tti = self.tx_hash_to_id_column(); - let res = self - .db - .get_cf( - &cf_tti, - borsh::to_vec(&tx_hash).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize transaction hash".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - if let Some(data) = res { - Ok(Some(borsh::from_slice::(&data).map_err(|serr| { - DbError::borsh_cast_message(serr, Some("Failed to deserialize block id".to_owned())) - })?)) - } else { - Ok(None) - } + self.get_opt::(tx_hash) + .map(|opt| opt.map(|cell| cell.0)) } // Accounts meta pub(crate) fn get_acc_meta_num_tx(&self, acc_id: [u8; 32]) -> DbResult> { - let cf_ameta = self.account_meta_column(); - let res = self.db.get_cf(&cf_ameta, acc_id).map_err(|rerr| { - DbError::rocksdb_cast_message(rerr, Some("Failed to read from acc meta cf".to_owned())) - })?; + self.get_opt::(acc_id) + .map(|opt| opt.map(|cell| cell.0)) + } - res.map(|data| { - borsh::from_slice::(&data).map_err(|serr| { - DbError::borsh_cast_message(serr, Some("Failed to deserialize num tx".to_owned())) - }) - }) - .transpose() + pub fn get_zone_sdk_indexer_cursor_bytes(&self) -> DbResult>> { + Ok(self + .get_opt::(())? + .map(|cell| cell.0)) } } diff --git a/storage/src/indexer/write_atomic.rs b/storage/src/indexer/write_atomic.rs index 161d763a..7e05791f 100644 --- a/storage/src/indexer/write_atomic.rs +++ b/storage/src/indexer/write_atomic.rs @@ -2,10 +2,14 @@ use std::collections::HashMap; use rocksdb::WriteBatch; -use super::{ - Arc, BREAKPOINT_INTERVAL, Block, BoundColumnFamily, DB_META_FIRST_BLOCK_IN_DB_KEY, - DB_META_FIRST_BLOCK_SET_KEY, DB_META_LAST_BLOCK_IN_DB_KEY, DB_META_LAST_BREAKPOINT_ID, - DB_META_LAST_OBSERVED_L1_LIB_HEADER_ID_IN_DB_KEY, DbError, DbResult, RocksDBIO, +use super::{BREAKPOINT_INTERVAL, Block, DbError, DbResult, RocksDBIO}; +use crate::{ + DBIO as _, + cells::shared_cells::{FirstBlockCell, FirstBlockSetCell, LastBlockCell}, + indexer::indexer_cells::{ + AccNumTxCell, BlockHashToBlockIdMapCell, LastBreakpointIdCell, LastObservedL1LibHeaderCell, + TxHashToBlockIdMapCell, + }, }; #[expect(clippy::multiple_inherent_impl, reason = "Readability")] @@ -18,22 +22,27 @@ impl RocksDBIO { num_tx: u64, write_batch: &mut WriteBatch, ) -> DbResult<()> { - let cf_ameta = self.account_meta_column(); + self.put_batch(&AccNumTxCell(num_tx), acc_id, write_batch) + } - write_batch.put_cf( - &cf_ameta, - borsh::to_vec(&acc_id).map_err(|err| { - DbError::borsh_cast_message(err, Some("Failed to serialize account id".to_owned())) - })?, - borsh::to_vec(&num_tx).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize acc metadata".to_owned()), - ) - })?, - ); + // Mappings - Ok(()) + pub fn put_block_id_by_hash_batch( + &self, + hash: [u8; 32], + block_id: u64, + write_batch: &mut WriteBatch, + ) -> DbResult<()> { + self.put_batch(&BlockHashToBlockIdMapCell(block_id), hash, write_batch) + } + + pub fn put_block_id_by_tx_hash_batch( + &self, + tx_hash: [u8; 32], + block_id: u64, + write_batch: &mut WriteBatch, + ) -> DbResult<()> { + self.put_batch(&TxHashToBlockIdMapCell(block_id), tx_hash, write_batch) } // Account @@ -134,28 +143,12 @@ impl RocksDBIO { // Meta - pub fn put_meta_first_block_in_db_batch(&self, block: &Block) -> DbResult<()> { - let cf_meta = self.meta_column(); - self.db - .put_cf( - &cf_meta, - borsh::to_vec(&DB_META_FIRST_BLOCK_IN_DB_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_FIRST_BLOCK_IN_DB_KEY".to_owned()), - ) - })?, - borsh::to_vec(&block.header.block_id).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize first block id".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - self.put_block(block, [0; 32])?; - Ok(()) + pub fn put_meta_first_block_in_db_batch( + &self, + block: &Block, + write_batch: &mut WriteBatch, + ) -> DbResult<()> { + self.put_batch(&FirstBlockCell(block.header.block_id), (), write_batch) } pub fn put_meta_last_block_in_db_batch( @@ -163,23 +156,7 @@ impl RocksDBIO { block_id: u64, write_batch: &mut WriteBatch, ) -> DbResult<()> { - let cf_meta = self.meta_column(); - write_batch.put_cf( - &cf_meta, - borsh::to_vec(&DB_META_LAST_BLOCK_IN_DB_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_LAST_BLOCK_IN_DB_KEY".to_owned()), - ) - })?, - borsh::to_vec(&block_id).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize last block id".to_owned()), - ) - })?, - ); - Ok(()) + self.put_batch(&LastBlockCell(block_id), (), write_batch) } pub fn put_meta_last_observed_l1_lib_header_in_db_batch( @@ -187,26 +164,7 @@ impl RocksDBIO { l1_lib_header: [u8; 32], write_batch: &mut WriteBatch, ) -> DbResult<()> { - let cf_meta = self.meta_column(); - write_batch.put_cf( - &cf_meta, - borsh::to_vec(&DB_META_LAST_OBSERVED_L1_LIB_HEADER_ID_IN_DB_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some( - "Failed to serialize DB_META_LAST_OBSERVED_L1_LIB_HEADER_ID_IN_DB_KEY" - .to_owned(), - ), - ) - })?, - borsh::to_vec(&l1_lib_header).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize last l1 block header".to_owned()), - ) - })?, - ); - Ok(()) + self.put_batch(&LastObservedL1LibHeaderCell(l1_lib_header), (), write_batch) } pub fn put_meta_last_breakpoint_id_batch( @@ -214,47 +172,18 @@ impl RocksDBIO { br_id: u64, write_batch: &mut WriteBatch, ) -> DbResult<()> { - let cf_meta = self.meta_column(); - write_batch.put_cf( - &cf_meta, - borsh::to_vec(&DB_META_LAST_BREAKPOINT_ID).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_LAST_BREAKPOINT_ID".to_owned()), - ) - })?, - borsh::to_vec(&br_id).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize last block id".to_owned()), - ) - })?, - ); - Ok(()) + self.put_batch(&LastBreakpointIdCell(br_id), (), write_batch) } pub fn put_meta_is_first_block_set_batch(&self, write_batch: &mut WriteBatch) -> DbResult<()> { - let cf_meta = self.meta_column(); - write_batch.put_cf( - &cf_meta, - borsh::to_vec(&DB_META_FIRST_BLOCK_SET_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_FIRST_BLOCK_SET_KEY".to_owned()), - ) - })?, - [1_u8; 1], - ); - Ok(()) + self.put_batch(&FirstBlockSetCell(true), (), write_batch) } // Block pub fn put_block(&self, block: &Block, l1_lib_header: [u8; 32]) -> DbResult<()> { let cf_block = self.block_column(); - let cf_hti = self.hash_to_id_column(); - let cf_tti: Arc> = self.tx_hash_to_id_column(); - let last_curr_block = self.get_meta_last_block_in_db()?; + let last_curr_block = self.get_meta_last_block_id_in_db()?.unwrap_or(0); let mut write_batch = WriteBatch::default(); write_batch.put_cf( @@ -271,34 +200,27 @@ impl RocksDBIO { 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)?; } + if last_curr_block == 0 { + self.put_meta_first_block_in_db_batch(block, &mut write_batch)?; + self.put_meta_is_first_block_set_batch(&mut write_batch)?; + } - write_batch.put_cf( - &cf_hti, - borsh::to_vec(&block.header.hash).map_err(|err| { - DbError::borsh_cast_message(err, Some("Failed to serialize block hash".to_owned())) - })?, - borsh::to_vec(&block.header.block_id).map_err(|err| { - DbError::borsh_cast_message(err, Some("Failed to serialize block id".to_owned())) - })?, - ); + self.put_block_id_by_hash_batch( + block.header.hash.into(), + block.header.block_id, + &mut write_batch, + )?; let mut acc_to_tx_map: HashMap<[u8; 32], Vec<[u8; 32]>> = HashMap::new(); for tx in &block.body.transactions { let tx_hash = tx.hash(); - write_batch.put_cf( - &cf_tti, - borsh::to_vec(&tx_hash).map_err(|err| { - DbError::borsh_cast_message(err, Some("Failed to serialize tx hash".to_owned())) - })?, - borsh::to_vec(&block.header.block_id).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize block id".to_owned()), - ) - })?, - ); + self.put_block_id_by_tx_hash_batch( + tx_hash.into(), + block.header.block_id, + &mut write_batch, + )?; let acc_ids = tx .affected_public_account_ids() diff --git a/storage/src/indexer/write_non_atomic.rs b/storage/src/indexer/write_non_atomic.rs index 17c1be18..7ddab1dd 100644 --- a/storage/src/indexer/write_non_atomic.rs +++ b/storage/src/indexer/write_non_atomic.rs @@ -1,7 +1,11 @@ -use super::{ - BREAKPOINT_INTERVAL, DB_META_FIRST_BLOCK_SET_KEY, DB_META_LAST_BLOCK_IN_DB_KEY, - DB_META_LAST_BREAKPOINT_ID, DB_META_LAST_OBSERVED_L1_LIB_HEADER_ID_IN_DB_KEY, DbError, - DbResult, RocksDBIO, V03State, +use super::{BREAKPOINT_INTERVAL, DbError, DbResult, RocksDBIO, V03State}; +use crate::{ + DBIO as _, + cells::shared_cells::{FirstBlockSetCell, LastBlockCell}, + indexer::indexer_cells::{ + BreakpointCellRef, LastBreakpointIdCell, LastObservedL1LibHeaderCell, + ZoneSdkIndexerCursorCellRef, + }, }; #[expect(clippy::multiple_inherent_impl, reason = "Readability")] @@ -9,124 +13,39 @@ impl RocksDBIO { // Meta pub fn put_meta_last_block_in_db(&self, block_id: u64) -> DbResult<()> { - let cf_meta = self.meta_column(); - self.db - .put_cf( - &cf_meta, - borsh::to_vec(&DB_META_LAST_BLOCK_IN_DB_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_LAST_BLOCK_IN_DB_KEY".to_owned()), - ) - })?, - borsh::to_vec(&block_id).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize last block id".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - Ok(()) + self.put(&LastBlockCell(block_id), ()) } pub fn put_meta_last_observed_l1_lib_header_in_db( &self, l1_lib_header: [u8; 32], ) -> DbResult<()> { - let cf_meta = self.meta_column(); - self.db - .put_cf( - &cf_meta, - borsh::to_vec(&DB_META_LAST_OBSERVED_L1_LIB_HEADER_ID_IN_DB_KEY).map_err( - |err| { - DbError::borsh_cast_message( - err, - Some( - "Failed to serialize DB_META_LAST_OBSERVED_L1_LIB_HEADER_ID_IN_DB_KEY" - .to_owned(), - ), - ) - }, - )?, - borsh::to_vec(&l1_lib_header).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize last l1 block header".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - Ok(()) + self.put(&LastObservedL1LibHeaderCell(l1_lib_header), ()) } pub fn put_meta_last_breakpoint_id(&self, br_id: u64) -> DbResult<()> { - let cf_meta = self.meta_column(); - self.db - .put_cf( - &cf_meta, - borsh::to_vec(&DB_META_LAST_BREAKPOINT_ID).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_LAST_BREAKPOINT_ID".to_owned()), - ) - })?, - borsh::to_vec(&br_id).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize last block id".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - Ok(()) + self.put(&LastBreakpointIdCell(br_id), ()) } pub fn put_meta_is_first_block_set(&self) -> DbResult<()> { - let cf_meta = self.meta_column(); - self.db - .put_cf( - &cf_meta, - borsh::to_vec(&DB_META_FIRST_BLOCK_SET_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_FIRST_BLOCK_SET_KEY".to_owned()), - ) - })?, - [1_u8; 1], - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - Ok(()) + self.put(&FirstBlockSetCell(true), ()) + } + + pub fn put_zone_sdk_indexer_cursor_bytes(&self, bytes: &[u8]) -> DbResult<()> { + self.put(&ZoneSdkIndexerCursorCellRef(bytes), ()) } // State pub fn put_breakpoint(&self, br_id: u64, breakpoint: &V03State) -> DbResult<()> { - let cf_br = self.breakpoint_column(); - - self.db - .put_cf( - &cf_br, - borsh::to_vec(&br_id).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize breakpoint id".to_owned()), - ) - })?, - borsh::to_vec(breakpoint).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize breakpoint data".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None)) + self.put(&BreakpointCellRef(breakpoint), br_id) } pub fn put_next_breakpoint(&self) -> DbResult<()> { - let last_block = self.get_meta_last_block_in_db()?; + 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 diff --git a/storage/src/lib.rs b/storage/src/lib.rs index 05c4a374..2edb0ee3 100644 --- a/storage/src/lib.rs +++ b/storage/src/lib.rs @@ -1,3 +1,69 @@ +use rocksdb::{DBWithThreadMode, MultiThreaded, WriteBatch}; + +use crate::{ + cells::{SimpleReadableCell, SimpleWritableCell}, + error::DbError, +}; + +pub mod cells; pub mod error; pub mod indexer; pub mod sequencer; + +/// Maximal size of stored blocks in base. +/// +/// Used to control db size. +/// +/// Currently effectively unbounded. +pub const BUFF_SIZE_ROCKSDB: usize = usize::MAX; + +/// Size of stored blocks cache in memory. +/// +/// Keeping small to not run out of memory. +pub const CACHE_SIZE: usize = 1000; + +/// Key base for storing metainformation which describe if first block has been set. +pub const DB_META_FIRST_BLOCK_SET_KEY: &str = "first_block_set"; +/// Key base for storing metainformation about id of first block in db. +pub const DB_META_FIRST_BLOCK_IN_DB_KEY: &str = "first_block_in_db"; +/// Key base for storing metainformation about id of last current block in db. +pub const DB_META_LAST_BLOCK_IN_DB_KEY: &str = "last_block_in_db"; + +/// Cell name for a block. +pub const BLOCK_CELL_NAME: &str = "block"; + +/// Interval between state breakpoints. +pub const BREAKPOINT_INTERVAL: u8 = 100; + +/// Name of block column family. +pub const CF_BLOCK_NAME: &str = "cf_block"; +/// Name of meta column family. +pub const CF_META_NAME: &str = "cf_meta"; + +pub type DbResult = Result; + +/// Minimal requirements for DB IO. +pub trait DBIO { + fn db(&self) -> &DBWithThreadMode; + + fn get(&self, params: T::KeyParams) -> DbResult { + T::get(self.db(), params) + } + + fn get_opt(&self, params: T::KeyParams) -> DbResult> { + T::get_opt(self.db(), params) + } + + fn put(&self, cell: &T, params: T::KeyParams) -> DbResult<()> { + cell.put(self.db(), params) + } + + fn put_batch( + &self, + cell: &T, + params: T::KeyParams, + write_batch: &mut WriteBatch, + ) -> DbResult<()> { + cell.put_batch(self.db(), params, write_batch) + } +} diff --git a/storage/src/sequencer.rs b/storage/src/sequencer.rs deleted file mode 100644 index 17d0e73e..00000000 --- a/storage/src/sequencer.rs +++ /dev/null @@ -1,596 +0,0 @@ -use std::{path::Path, sync::Arc}; - -use common::block::{BedrockStatus, Block, BlockMeta, MantleMsgId}; -use nssa::V03State; -use rocksdb::{ - BoundColumnFamily, ColumnFamilyDescriptor, DBWithThreadMode, MultiThreaded, Options, WriteBatch, -}; - -use crate::error::DbError; - -/// Maximal size of stored blocks in base. -/// -/// Used to control db size. -/// -/// Currently effectively unbounded. -pub const BUFF_SIZE_ROCKSDB: usize = usize::MAX; - -/// Size of stored blocks cache in memory. -/// -/// Keeping small to not run out of memory. -pub const CACHE_SIZE: usize = 1000; - -/// Key base for storing metainformation about id of first block in db. -pub const DB_META_FIRST_BLOCK_IN_DB_KEY: &str = "first_block_in_db"; -/// Key base for storing metainformation about id of last current block in db. -pub const DB_META_LAST_BLOCK_IN_DB_KEY: &str = "last_block_in_db"; -/// Key base for storing metainformation which describe if first block has been set. -pub const DB_META_FIRST_BLOCK_SET_KEY: &str = "first_block_set"; -/// Key base for storing metainformation about the last finalized block on Bedrock. -pub const DB_META_LAST_FINALIZED_BLOCK_ID: &str = "last_finalized_block_id"; -/// Key base for storing metainformation about the latest block meta. -pub const DB_META_LATEST_BLOCK_META_KEY: &str = "latest_block_meta"; - -/// Key base for storing the NSSA state. -pub const DB_NSSA_STATE_KEY: &str = "nssa_state"; - -/// Name of block column family. -pub const CF_BLOCK_NAME: &str = "cf_block"; -/// Name of meta column family. -pub const CF_META_NAME: &str = "cf_meta"; -/// Name of state column family. -pub const CF_NSSA_STATE_NAME: &str = "cf_nssa_state"; - -pub type DbResult = Result; - -pub struct RocksDBIO { - pub db: DBWithThreadMode, -} - -impl RocksDBIO { - pub fn open_or_create( - path: &Path, - genesis_block: &Block, - genesis_msg_id: MantleMsgId, - ) -> DbResult { - let mut cf_opts = Options::default(); - cf_opts.set_max_write_buffer_number(16); - // ToDo: Add more column families for different data - let cfb = ColumnFamilyDescriptor::new(CF_BLOCK_NAME, cf_opts.clone()); - let cfmeta = ColumnFamilyDescriptor::new(CF_META_NAME, cf_opts.clone()); - let cfstate = ColumnFamilyDescriptor::new(CF_NSSA_STATE_NAME, cf_opts.clone()); - - let mut db_opts = Options::default(); - db_opts.create_missing_column_families(true); - db_opts.create_if_missing(true); - let db = DBWithThreadMode::::open_cf_descriptors( - &db_opts, - path, - vec![cfb, cfmeta, cfstate], - ) - .map_err(|err| DbError::RocksDbError { - error: err, - additional_info: Some("Failed to open or create DB".to_owned()), - })?; - - let dbio = Self { db }; - - let is_start_set = dbio.get_meta_is_first_block_set()?; - if !is_start_set { - let block_id = genesis_block.header.block_id; - dbio.put_meta_first_block_in_db(genesis_block, genesis_msg_id)?; - dbio.put_meta_is_first_block_set()?; - dbio.put_meta_last_block_in_db(block_id)?; - dbio.put_meta_last_finalized_block_id(None)?; - dbio.put_meta_latest_block_meta(&BlockMeta { - id: genesis_block.header.block_id, - hash: genesis_block.header.hash, - msg_id: genesis_msg_id, - })?; - } - - Ok(dbio) - } - - pub fn destroy(path: &Path) -> DbResult<()> { - let mut cf_opts = Options::default(); - cf_opts.set_max_write_buffer_number(16); - // ToDo: Add more column families for different data - let _cfb = ColumnFamilyDescriptor::new(CF_BLOCK_NAME, cf_opts.clone()); - let _cfmeta = ColumnFamilyDescriptor::new(CF_META_NAME, cf_opts.clone()); - let _cfstate = ColumnFamilyDescriptor::new(CF_NSSA_STATE_NAME, cf_opts.clone()); - - let mut db_opts = Options::default(); - db_opts.create_missing_column_families(true); - db_opts.create_if_missing(true); - DBWithThreadMode::::destroy(&db_opts, path) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None)) - } - - pub fn meta_column(&self) -> Arc> { - self.db.cf_handle(CF_META_NAME).unwrap() - } - - pub fn block_column(&self) -> Arc> { - self.db.cf_handle(CF_BLOCK_NAME).unwrap() - } - - pub fn nssa_state_column(&self) -> Arc> { - self.db.cf_handle(CF_NSSA_STATE_NAME).unwrap() - } - - pub fn get_meta_first_block_in_db(&self) -> DbResult { - let cf_meta = self.meta_column(); - let res = self - .db - .get_cf( - &cf_meta, - borsh::to_vec(&DB_META_FIRST_BLOCK_IN_DB_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_FIRST_BLOCK_IN_DB_KEY".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - if let Some(data) = res { - Ok(borsh::from_slice::(&data).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to deserialize first block".to_owned()), - ) - })?) - } else { - Err(DbError::db_interaction_error( - "First block not found".to_owned(), - )) - } - } - - pub fn get_meta_last_block_in_db(&self) -> DbResult { - let cf_meta = self.meta_column(); - let res = self - .db - .get_cf( - &cf_meta, - borsh::to_vec(&DB_META_LAST_BLOCK_IN_DB_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_LAST_BLOCK_IN_DB_KEY".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - if let Some(data) = res { - Ok(borsh::from_slice::(&data).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to deserialize last block".to_owned()), - ) - })?) - } else { - Err(DbError::db_interaction_error( - "Last block not found".to_owned(), - )) - } - } - - pub fn get_meta_is_first_block_set(&self) -> DbResult { - let cf_meta = self.meta_column(); - let res = self - .db - .get_cf( - &cf_meta, - borsh::to_vec(&DB_META_FIRST_BLOCK_SET_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_FIRST_BLOCK_SET_KEY".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - Ok(res.is_some()) - } - - pub fn put_nssa_state_in_db(&self, state: &V03State, batch: &mut WriteBatch) -> DbResult<()> { - let cf_nssa_state = self.nssa_state_column(); - batch.put_cf( - &cf_nssa_state, - borsh::to_vec(&DB_NSSA_STATE_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_NSSA_STATE_KEY".to_owned()), - ) - })?, - borsh::to_vec(state).map_err(|err| { - DbError::borsh_cast_message(err, Some("Failed to serialize NSSA state".to_owned())) - })?, - ); - - Ok(()) - } - - pub fn put_meta_first_block_in_db(&self, block: &Block, msg_id: MantleMsgId) -> DbResult<()> { - let cf_meta = self.meta_column(); - self.db - .put_cf( - &cf_meta, - borsh::to_vec(&DB_META_FIRST_BLOCK_IN_DB_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_FIRST_BLOCK_IN_DB_KEY".to_owned()), - ) - })?, - borsh::to_vec(&block.header.block_id).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize first block id".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - let mut batch = WriteBatch::default(); - self.put_block(block, msg_id, true, &mut batch)?; - self.db.write(batch).map_err(|rerr| { - DbError::rocksdb_cast_message( - rerr, - Some("Failed to write first block in db".to_owned()), - ) - })?; - - Ok(()) - } - - pub fn put_meta_last_block_in_db(&self, block_id: u64) -> DbResult<()> { - let cf_meta = self.meta_column(); - self.db - .put_cf( - &cf_meta, - borsh::to_vec(&DB_META_LAST_BLOCK_IN_DB_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_LAST_BLOCK_IN_DB_KEY".to_owned()), - ) - })?, - borsh::to_vec(&block_id).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize last block id".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - Ok(()) - } - - fn put_meta_last_block_in_db_batch( - &self, - block_id: u64, - batch: &mut WriteBatch, - ) -> DbResult<()> { - let cf_meta = self.meta_column(); - batch.put_cf( - &cf_meta, - borsh::to_vec(&DB_META_LAST_BLOCK_IN_DB_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_LAST_BLOCK_IN_DB_KEY".to_owned()), - ) - })?, - borsh::to_vec(&block_id).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize last block id".to_owned()), - ) - })?, - ); - Ok(()) - } - - pub fn put_meta_last_finalized_block_id(&self, block_id: Option) -> DbResult<()> { - let cf_meta = self.meta_column(); - self.db - .put_cf( - &cf_meta, - borsh::to_vec(&DB_META_LAST_FINALIZED_BLOCK_ID).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_LAST_FINALIZED_BLOCK_ID".to_owned()), - ) - })?, - borsh::to_vec(&block_id).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize last block id".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - Ok(()) - } - - pub fn put_meta_is_first_block_set(&self) -> DbResult<()> { - let cf_meta = self.meta_column(); - self.db - .put_cf( - &cf_meta, - borsh::to_vec(&DB_META_FIRST_BLOCK_SET_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_FIRST_BLOCK_SET_KEY".to_owned()), - ) - })?, - [1_u8; 1], - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - Ok(()) - } - - fn put_meta_latest_block_meta(&self, block_meta: &BlockMeta) -> DbResult<()> { - let cf_meta = self.meta_column(); - self.db - .put_cf( - &cf_meta, - borsh::to_vec(&DB_META_LATEST_BLOCK_META_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_LATEST_BLOCK_META_KEY".to_owned()), - ) - })?, - borsh::to_vec(&block_meta).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize latest block meta".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - Ok(()) - } - - fn put_meta_latest_block_meta_batch( - &self, - block_meta: &BlockMeta, - batch: &mut WriteBatch, - ) -> DbResult<()> { - let cf_meta = self.meta_column(); - batch.put_cf( - &cf_meta, - borsh::to_vec(&DB_META_LATEST_BLOCK_META_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_LATEST_BLOCK_META_KEY".to_owned()), - ) - })?, - borsh::to_vec(&block_meta).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize latest block meta".to_owned()), - ) - })?, - ); - Ok(()) - } - - pub fn latest_block_meta(&self) -> DbResult { - let cf_meta = self.meta_column(); - let res = self - .db - .get_cf( - &cf_meta, - borsh::to_vec(&DB_META_LATEST_BLOCK_META_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize DB_META_LATEST_BLOCK_META_KEY".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - if let Some(data) = res { - Ok(borsh::from_slice::(&data).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to deserialize latest block meta".to_owned()), - ) - })?) - } else { - Err(DbError::db_interaction_error( - "Latest block meta not found".to_owned(), - )) - } - } - - pub fn put_block( - &self, - block: &Block, - msg_id: MantleMsgId, - first: bool, - batch: &mut WriteBatch, - ) -> DbResult<()> { - let cf_block = self.block_column(); - - if !first { - 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, - msg_id, - }, - batch, - )?; - } - } - - batch.put_cf( - &cf_block, - borsh::to_vec(&block.header.block_id).map_err(|err| { - DbError::borsh_cast_message(err, Some("Failed to serialize block id".to_owned())) - })?, - borsh::to_vec(block).map_err(|err| { - DbError::borsh_cast_message(err, Some("Failed to serialize block data".to_owned())) - })?, - ); - Ok(()) - } - - pub fn get_block(&self, block_id: u64) -> DbResult> { - let cf_block = self.block_column(); - let res = self - .db - .get_cf( - &cf_block, - borsh::to_vec(&block_id).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize block id".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - if let Some(data) = res { - Ok(Some(borsh::from_slice::(&data).map_err(|serr| { - DbError::borsh_cast_message( - serr, - Some("Failed to deserialize block data".to_owned()), - ) - })?)) - } else { - Ok(None) - } - } - - pub fn get_nssa_state(&self) -> DbResult { - let cf_nssa_state = self.nssa_state_column(); - let res = self - .db - .get_cf( - &cf_nssa_state, - borsh::to_vec(&DB_NSSA_STATE_KEY).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize block id".to_owned()), - ) - })?, - ) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - if let Some(data) = res { - Ok(borsh::from_slice::(&data).map_err(|serr| { - DbError::borsh_cast_message( - serr, - Some("Failed to deserialize block data".to_owned()), - ) - })?) - } else { - Err(DbError::db_interaction_error( - "NSSA state not found".to_owned(), - )) - } - } - - pub fn delete_block(&self, block_id: u64) -> DbResult<()> { - let cf_block = self.block_column(); - let key = borsh::to_vec(&block_id).map_err(|err| { - DbError::borsh_cast_message(err, Some("Failed to serialize block id".to_owned())) - })?; - - if self - .db - .get_cf(&cf_block, &key) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))? - .is_none() - { - return Err(DbError::db_interaction_error(format!( - "Block with id {block_id} not found" - ))); - } - - self.db - .delete_cf(&cf_block, key) - .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; - - Ok(()) - } - - pub fn mark_block_as_finalized(&self, block_id: u64) -> 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; - - let cf_block = self.block_column(); - self.db - .put_cf( - &cf_block, - borsh::to_vec(&block_id).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize block id".to_owned()), - ) - })?, - borsh::to_vec(&block).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to serialize block data".to_owned()), - ) - })?, - ) - .map_err(|rerr| { - DbError::rocksdb_cast_message( - rerr, - Some(format!("Failed to mark block {block_id} as finalized")), - ) - })?; - - Ok(()) - } - - pub fn get_all_blocks(&self) -> impl Iterator> { - let cf_block = self.block_column(); - self.db - .iterator_cf(&cf_block, rocksdb::IteratorMode::Start) - .map(|res| { - let (_key, value) = res.map_err(|rerr| { - DbError::rocksdb_cast_message( - rerr, - Some("Failed to get key value pair".to_owned()), - ) - })?; - - borsh::from_slice::(&value).map_err(|err| { - DbError::borsh_cast_message( - err, - Some("Failed to deserialize block data".to_owned()), - ) - }) - }) - } - - pub fn atomic_update( - &self, - block: &Block, - msg_id: MantleMsgId, - state: &V03State, - ) -> DbResult<()> { - let block_id = block.header.block_id; - let mut batch = WriteBatch::default(); - self.put_block(block, msg_id, false, &mut batch)?; - self.put_nssa_state_in_db(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}")), - ) - }) - } -} diff --git a/storage/src/sequencer/mod.rs b/storage/src/sequencer/mod.rs new file mode 100644 index 00000000..be5e5cfe --- /dev/null +++ b/storage/src/sequencer/mod.rs @@ -0,0 +1,399 @@ +use std::{path::Path, sync::Arc}; + +use common::block::{BedrockStatus, Block, BlockMeta, MantleMsgId}; +use nssa::V03State; +use rocksdb::{ + BoundColumnFamily, ColumnFamilyDescriptor, DBWithThreadMode, MultiThreaded, Options, WriteBatch, +}; + +use crate::{ + CF_BLOCK_NAME, CF_META_NAME, DB_META_FIRST_BLOCK_IN_DB_KEY, DBIO, DbResult, + cells::shared_cells::{BlockCell, FirstBlockCell, FirstBlockSetCell, LastBlockCell}, + error::DbError, + sequencer::sequencer_cells::{ + LastFinalizedBlockIdCell, LatestBlockMetaCellOwned, LatestBlockMetaCellRef, + NSSAStateCellOwned, NSSAStateCellRef, ZoneSdkCheckpointCellOwned, ZoneSdkCheckpointCellRef, + }, +}; + +pub mod sequencer_cells; + +/// Key base for storing metainformation about the last finalized block on Bedrock. +pub const DB_META_LAST_FINALIZED_BLOCK_ID: &str = "last_finalized_block_id"; +/// Key base for storing metainformation about the latest block meta. +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 NSSA state. +pub const DB_NSSA_STATE_KEY: &str = "nssa_state"; + +/// Name of state column family. +pub const CF_NSSA_STATE_NAME: &str = "cf_nssa_state"; + +pub struct RocksDBIO { + pub db: DBWithThreadMode, +} + +impl DBIO for RocksDBIO { + fn db(&self) -> &DBWithThreadMode { + &self.db + } +} + +impl RocksDBIO { + pub fn open(path: &Path) -> DbResult { + let db_opts = Options::default(); + Self::open_inner(path, &db_opts) + } + + pub fn create( + path: &Path, + genesis_block: &Block, + genesis_msg_id: MantleMsgId, + genesis_state: &V03State, + ) -> 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 is_start_set = dbio.get_meta_is_first_block_set()?; + if !is_start_set { + let block_id = genesis_block.header.block_id; + // TODO: Shouldn't this be atomic (batched)? + dbio.put_meta_first_block_in_db(genesis_block, genesis_msg_id)?; + dbio.put_meta_is_first_block_set()?; + dbio.put_meta_last_block_in_db(block_id)?; + dbio.put_meta_last_finalized_block_id(None)?; + dbio.put_meta_latest_block_meta(&BlockMeta { + id: genesis_block.header.block_id, + hash: genesis_block.header.hash, + msg_id: genesis_msg_id, + })?; + dbio.put_nssa_state_in_db(genesis_state)?; + } + + 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); + + // ToDo: Add more column families for different data + let cfb = ColumnFamilyDescriptor::new(CF_BLOCK_NAME, cf_opts.clone()); + let cfmeta = ColumnFamilyDescriptor::new(CF_META_NAME, cf_opts.clone()); + let cfstate = ColumnFamilyDescriptor::new(CF_NSSA_STATE_NAME, cf_opts.clone()); + + let db = DBWithThreadMode::::open_cf_descriptors( + db_opts, + path, + vec![cfb, cfmeta, cfstate], + ) + .map_err(|err| DbError::RocksDbError { + error: err, + additional_info: Some("Failed to open or create DB".to_owned()), + })?; + + let dbio = Self { db }; + Ok(dbio) + } + + pub fn destroy(path: &Path) -> DbResult<()> { + let mut cf_opts = Options::default(); + cf_opts.set_max_write_buffer_number(16); + // ToDo: Add more column families for different data + let _cfb = ColumnFamilyDescriptor::new(CF_BLOCK_NAME, cf_opts.clone()); + let _cfmeta = ColumnFamilyDescriptor::new(CF_META_NAME, cf_opts.clone()); + let _cfstate = ColumnFamilyDescriptor::new(CF_NSSA_STATE_NAME, cf_opts.clone()); + + let mut db_opts = Options::default(); + db_opts.create_missing_column_families(true); + db_opts.create_if_missing(true); + DBWithThreadMode::::destroy(&db_opts, path) + .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None)) + } + + // Columns + + pub fn meta_column(&self) -> Arc> { + self.db + .cf_handle(CF_META_NAME) + .expect("Meta column should exist") + } + + pub fn block_column(&self) -> Arc> { + self.db + .cf_handle(CF_BLOCK_NAME) + .expect("Block column should exist") + } + + pub fn nssa_state_column(&self) -> Arc> { + self.db + .cf_handle(CF_NSSA_STATE_NAME) + .expect("State should exist") + } + + // Meta + + pub fn get_meta_first_block_in_db(&self) -> DbResult { + self.get::(()).map(|cell| cell.0) + } + + pub fn get_meta_last_block_in_db(&self) -> DbResult { + self.get::(()).map(|cell| cell.0) + } + + pub fn get_meta_is_first_block_set(&self) -> DbResult { + Ok(self.get_opt::(())?.is_some()) + } + + pub fn put_nssa_state_in_db(&self, state: &V03State) -> DbResult<()> { + self.put(&NSSAStateCellRef(state), ()) + } + + pub fn put_nssa_state_in_db_batch( + &self, + state: &V03State, + batch: &mut WriteBatch, + ) -> DbResult<()> { + self.put_batch(&NSSAStateCellRef(state), (), batch) + } + + pub fn put_meta_first_block_in_db(&self, block: &Block, msg_id: MantleMsgId) -> DbResult<()> { + let cf_meta = self.meta_column(); + self.db + .put_cf( + &cf_meta, + borsh::to_vec(&DB_META_FIRST_BLOCK_IN_DB_KEY).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize DB_META_FIRST_BLOCK_IN_DB_KEY".to_owned()), + ) + })?, + borsh::to_vec(&block.header.block_id).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize first block id".to_owned()), + ) + })?, + ) + .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; + + let mut batch = WriteBatch::default(); + self.put_block(block, msg_id, true, &mut batch)?; + self.db.write(batch).map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to write first block in db".to_owned()), + ) + })?; + + Ok(()) + } + + pub fn put_meta_last_block_in_db(&self, block_id: u64) -> DbResult<()> { + self.put(&LastBlockCell(block_id), ()) + } + + fn put_meta_last_block_in_db_batch( + &self, + block_id: u64, + batch: &mut WriteBatch, + ) -> DbResult<()> { + self.put_batch(&LastBlockCell(block_id), (), batch) + } + + pub fn put_meta_last_finalized_block_id(&self, block_id: Option) -> DbResult<()> { + self.put(&LastFinalizedBlockIdCell(block_id), ()) + } + + pub fn put_meta_is_first_block_set(&self) -> DbResult<()> { + self.put(&FirstBlockSetCell(true), ()) + } + + fn put_meta_latest_block_meta(&self, block_meta: &BlockMeta) -> DbResult<()> { + self.put(&LatestBlockMetaCellRef(block_meta), ()) + } + + fn put_meta_latest_block_meta_batch( + &self, + block_meta: &BlockMeta, + batch: &mut WriteBatch, + ) -> DbResult<()> { + self.put_batch(&LatestBlockMetaCellRef(block_meta), (), batch) + } + + pub fn latest_block_meta(&self) -> DbResult { + self.get::(()).map(|val| val.0) + } + + pub fn get_zone_sdk_checkpoint_bytes(&self) -> DbResult>> { + Ok(self + .get_opt::(())? + .map(|cell| cell.0)) + } + + pub fn put_zone_sdk_checkpoint_bytes(&self, bytes: &[u8]) -> DbResult<()> { + self.put(&ZoneSdkCheckpointCellRef(bytes), ()) + } + + pub fn put_block( + &self, + block: &Block, + msg_id: MantleMsgId, + first: bool, + batch: &mut WriteBatch, + ) -> DbResult<()> { + let cf_block = self.block_column(); + + if !first { + 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, + msg_id, + }, + batch, + )?; + } + } + + batch.put_cf( + &cf_block, + borsh::to_vec(&block.header.block_id).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize block id".to_owned())) + })?, + borsh::to_vec(block).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize block data".to_owned())) + })?, + ); + Ok(()) + } + + pub fn get_block(&self, block_id: u64) -> DbResult> { + self.get_opt::(block_id) + .map(|opt| opt.map(|val| val.0)) + } + + pub fn get_nssa_state(&self) -> DbResult { + self.get::(()).map(|val| val.0) + } + + pub fn delete_block(&self, block_id: u64) -> DbResult<()> { + let cf_block = self.block_column(); + let key = borsh::to_vec(&block_id).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize block id".to_owned())) + })?; + + if self + .db + .get_cf(&cf_block, &key) + .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))? + .is_none() + { + return Err(DbError::db_interaction_error(format!( + "Block with id {block_id} not found" + ))); + } + + self.db + .delete_cf(&cf_block, key) + .map_err(|rerr| DbError::rocksdb_cast_message(rerr, None))?; + + Ok(()) + } + + /// Mark every pending block with `block_id <= last_finalized` as finalized. + /// Idempotent — already-finalized blocks are skipped. + pub fn clean_pending_blocks_up_to(&self, last_finalized: u64) -> DbResult<()> { + let pending_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) + .collect(); + for id in pending_ids { + self.mark_block_as_finalized(id)?; + } + Ok(()) + } + + pub fn mark_block_as_finalized(&self, block_id: u64) -> 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; + + let cf_block = self.block_column(); + self.db + .put_cf( + &cf_block, + borsh::to_vec(&block_id).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize block id".to_owned()), + ) + })?, + borsh::to_vec(&block).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize block data".to_owned()), + ) + })?, + ) + .map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some(format!("Failed to mark block {block_id} as finalized")), + ) + })?; + + Ok(()) + } + + pub fn get_all_blocks(&self) -> impl Iterator> { + let cf_block = self.block_column(); + self.db + .iterator_cf(&cf_block, rocksdb::IteratorMode::Start) + .map(|res| { + let (_key, value) = res.map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to get key value pair".to_owned()), + ) + })?; + + borsh::from_slice::(&value).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to deserialize block data".to_owned()), + ) + }) + }) + } + + pub fn atomic_update( + &self, + block: &Block, + msg_id: MantleMsgId, + state: &V03State, + ) -> DbResult<()> { + let block_id = block.header.block_id; + let mut batch = WriteBatch::default(); + self.put_block(block, msg_id, false, &mut batch)?; + self.put_nssa_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}")), + ) + }) + } +} diff --git a/storage/src/sequencer/sequencer_cells.rs b/storage/src/sequencer/sequencer_cells.rs new file mode 100644 index 00000000..2bf65367 --- /dev/null +++ b/storage/src/sequencer/sequencer_cells.rs @@ -0,0 +1,168 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use common::block::BlockMeta; +use nssa::V03State; + +use crate::{ + CF_META_NAME, DbResult, + cells::{SimpleReadableCell, SimpleStorableCell, SimpleWritableCell}, + error::DbError, + sequencer::{ + CF_NSSA_STATE_NAME, DB_META_LAST_FINALIZED_BLOCK_ID, DB_META_LATEST_BLOCK_META_KEY, + DB_META_ZONE_SDK_CHECKPOINT_KEY, DB_NSSA_STATE_KEY, + }, +}; + +#[derive(BorshDeserialize)] +pub struct NSSAStateCellOwned(pub V03State); + +impl SimpleStorableCell for NSSAStateCellOwned { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_NSSA_STATE_KEY; + const CF_NAME: &'static str = CF_NSSA_STATE_NAME; +} + +impl SimpleReadableCell for NSSAStateCellOwned {} + +#[derive(BorshSerialize)] +pub struct NSSAStateCellRef<'state>(pub &'state V03State); + +impl SimpleStorableCell for NSSAStateCellRef<'_> { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_NSSA_STATE_KEY; + const CF_NAME: &'static str = CF_NSSA_STATE_NAME; +} + +impl SimpleWritableCell for NSSAStateCellRef<'_> { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize last state".to_owned())) + }) + } +} + +#[derive(Debug, BorshSerialize, BorshDeserialize)] +pub struct LastFinalizedBlockIdCell(pub Option); + +impl SimpleStorableCell for LastFinalizedBlockIdCell { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_LAST_FINALIZED_BLOCK_ID; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for LastFinalizedBlockIdCell {} + +impl SimpleWritableCell for LastFinalizedBlockIdCell { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize last finalized block id".to_owned()), + ) + }) + } +} + +#[derive(BorshDeserialize)] +pub struct LatestBlockMetaCellOwned(pub BlockMeta); + +impl SimpleStorableCell for LatestBlockMetaCellOwned { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_LATEST_BLOCK_META_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for LatestBlockMetaCellOwned {} + +#[derive(BorshSerialize)] +pub struct LatestBlockMetaCellRef<'blockmeta>(pub &'blockmeta BlockMeta); + +impl SimpleStorableCell for LatestBlockMetaCellRef<'_> { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_LATEST_BLOCK_META_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleWritableCell for LatestBlockMetaCellRef<'_> { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message(err, Some("Failed to serialize last block meta".to_owned())) + }) + } +} + +/// Opaque bytes for the zone-sdk sequencer checkpoint. The caller is +/// responsible for the actual encoding (we use `serde_json` since +/// `SequencerCheckpoint` only derives serde, not borsh). +#[derive(BorshDeserialize)] +pub struct ZoneSdkCheckpointCellOwned(pub Vec); + +impl SimpleStorableCell for ZoneSdkCheckpointCellOwned { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_ZONE_SDK_CHECKPOINT_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for ZoneSdkCheckpointCellOwned {} + +#[derive(BorshSerialize)] +pub struct ZoneSdkCheckpointCellRef<'bytes>(pub &'bytes [u8]); + +impl SimpleStorableCell for ZoneSdkCheckpointCellRef<'_> { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_ZONE_SDK_CHECKPOINT_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleWritableCell for ZoneSdkCheckpointCellRef<'_> { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize zone-sdk checkpoint cell".to_owned()), + ) + }) + } +} + +#[cfg(test)] +mod uniform_tests { + use crate::{ + cells::SimpleStorableCell as _, + sequencer::sequencer_cells::{ + LatestBlockMetaCellOwned, LatestBlockMetaCellRef, NSSAStateCellOwned, NSSAStateCellRef, + }, + }; + + #[test] + fn state_ref_and_owned_is_aligned() { + assert_eq!(NSSAStateCellRef::CELL_NAME, NSSAStateCellOwned::CELL_NAME); + assert_eq!(NSSAStateCellRef::CF_NAME, NSSAStateCellOwned::CF_NAME); + assert_eq!( + NSSAStateCellRef::key_constructor(()).unwrap(), + NSSAStateCellOwned::key_constructor(()).unwrap() + ); + } + + #[test] + fn block_meta_ref_and_owned_is_aligned() { + assert_eq!( + LatestBlockMetaCellRef::CELL_NAME, + LatestBlockMetaCellOwned::CELL_NAME + ); + assert_eq!( + LatestBlockMetaCellRef::CF_NAME, + LatestBlockMetaCellOwned::CF_NAME + ); + assert_eq!( + LatestBlockMetaCellRef::key_constructor(()).unwrap(), + LatestBlockMetaCellOwned::key_constructor(()).unwrap() + ); + } +} diff --git a/test_fixtures/Cargo.toml b/test_fixtures/Cargo.toml new file mode 100644 index 00000000..1bfd2284 --- /dev/null +++ b/test_fixtures/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "test_fixtures" +version = "0.1.0" +edition = "2024" +license = { workspace = true } +publish = false + +[lints] +workspace = true + +[dependencies] +common.workspace = true +indexer_service.workspace = true +key_protocol.workspace = true +nssa.workspace = true +nssa_core = { workspace = true, features = ["host"] } +sequencer_core = { workspace = true, features = ["default", "testnet"] } +sequencer_service.workspace = true +sequencer_service_rpc = { workspace = true, features = ["client"] } +vault_core.workspace = true +wallet.workspace = true + +anyhow.workspace = true +bytesize.workspace = true +env_logger.workspace = true +futures.workspace = true +jsonrpsee = { workspace = true, features = ["ws-client"] } +log.workspace = true +serde.workspace = true +serde_json.workspace = true +tempfile.workspace = true +testcontainers = { version = "0.27.3", features = ["docker-compose"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +url.workspace = true diff --git a/test_fixtures/src/config.rs b/test_fixtures/src/config.rs new file mode 100644 index 00000000..00bdc74a --- /dev/null +++ b/test_fixtures/src/config.rs @@ -0,0 +1,193 @@ +use std::{net::SocketAddr, path::PathBuf, time::Duration}; + +use anyhow::{Context as _, Result}; +use bytesize::ByteSize; +use indexer_service::{ChannelId, ClientConfig, IndexerConfig}; +use key_protocol::key_management::KeyChain; +use nssa::{AccountId, PrivateKey, PublicKey}; +use nssa_core::Identifier; +use sequencer_core::config::{BedrockConfig, GenesisAction, SequencerConfig}; +use url::Url; +use wallet::config::WalletConfig; + +pub const INITIAL_PUBLIC_BALANCES_FOR_WALLET: [u128; 2] = [10_000, 20_000]; +pub const INITIAL_PRIVATE_BALANCES_FOR_WALLET: [u128; 2] = [10_000, 20_000]; + +#[derive(Clone)] +pub struct InitialPrivateAccountForWallet { + pub key_chain: KeyChain, + pub identifier: Identifier, + pub balance: u128, +} + +impl InitialPrivateAccountForWallet { + #[must_use] + pub fn account_id(&self) -> AccountId { + AccountId::from((&self.key_chain.nullifier_public_key, self.identifier)) + } +} + +/// Sequencer config options available for custom changes in integration tests. +#[derive(Debug, Clone, Copy)] +pub struct SequencerPartialConfig { + pub max_num_tx_in_block: usize, + pub max_block_size: ByteSize, + pub mempool_max_size: usize, + pub block_create_timeout: Duration, +} + +impl Default for SequencerPartialConfig { + fn default() -> Self { + Self { + max_num_tx_in_block: 20, + max_block_size: ByteSize::mib(1), + mempool_max_size: 10_000, + block_create_timeout: Duration::from_secs(10), + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum UrlProtocol { + Http, + Ws, +} + +impl std::fmt::Display for UrlProtocol { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Http => write!(f, "http"), + Self::Ws => write!(f, "ws"), + } + } +} + +pub fn sequencer_config( + partial: SequencerPartialConfig, + home: PathBuf, + bedrock_addr: SocketAddr, + genesis_transactions: Vec, +) -> Result { + let SequencerPartialConfig { + max_num_tx_in_block, + max_block_size, + mempool_max_size, + block_create_timeout, + } = partial; + + Ok(SequencerConfig { + home, + max_num_tx_in_block, + max_block_size, + mempool_max_size, + block_create_timeout, + retry_pending_blocks_timeout: Duration::from_secs(5), + genesis: genesis_transactions, + signing_key: [37; 32], + bedrock_config: BedrockConfig { + channel_id: bedrock_channel_id(), + node_url: addr_to_url(UrlProtocol::Http, bedrock_addr) + .context("Failed to convert bedrock addr to URL")?, + auth: None, + }, + }) +} + +#[must_use] +pub fn default_public_accounts_for_wallet() -> Vec<(PrivateKey, u128)> { + let mut private_keys = vec![PrivateKey::new_os_random(), PrivateKey::new_os_random()]; + private_keys.sort_unstable_by_key(|private_key| { + AccountId::from(&PublicKey::new_from_private_key(private_key)) + }); + + private_keys + .into_iter() + .zip(INITIAL_PUBLIC_BALANCES_FOR_WALLET) + .collect() +} + +#[must_use] +pub fn default_private_accounts_for_wallet() -> Vec { + let mut key_chains = vec![KeyChain::new_os_random(), KeyChain::new_os_random()]; + key_chains.sort_unstable(); + + key_chains + .into_iter() + .zip(INITIAL_PRIVATE_BALANCES_FOR_WALLET) + .map(|(key_chain, balance)| InitialPrivateAccountForWallet { + key_chain, + identifier: 0, + balance, + }) + .collect() +} + +#[must_use] +pub fn genesis_from_accounts( + public_accounts: &[(PrivateKey, u128)], + private_accounts: &[InitialPrivateAccountForWallet], +) -> Vec { + let public_genesis = public_accounts.iter().map(|(private_key, balance)| { + let public_key = PublicKey::new_from_private_key(private_key); + let account_id = AccountId::from(&public_key); + GenesisAction::SupplyAccount { + account_id, + balance: *balance, + } + }); + + let private_genesis = private_accounts + .iter() + .map(|account| GenesisAction::SupplyAccount { + account_id: account.account_id(), + balance: account.balance, + }); + + public_genesis.chain(private_genesis).collect() +} + +pub fn wallet_config(sequencer_addr: SocketAddr) -> Result { + Ok(WalletConfig { + sequencer_addr: addr_to_url(UrlProtocol::Http, sequencer_addr) + .context("Failed to convert sequencer addr to URL")?, + seq_poll_timeout: Duration::from_secs(30), + seq_tx_poll_max_blocks: 15, + seq_poll_max_retries: 10, + seq_block_poll_max_amount: 100, + basic_auth: None, + }) +} + +pub fn indexer_config(bedrock_addr: SocketAddr, home: PathBuf) -> Result { + Ok(IndexerConfig { + home, + consensus_info_polling_interval: Duration::from_secs(1), + bedrock_config: ClientConfig { + addr: addr_to_url(UrlProtocol::Http, bedrock_addr) + .context("Failed to convert bedrock addr to URL")?, + auth: None, + }, + channel_id: bedrock_channel_id(), + }) +} + +pub fn addr_to_url(protocol: UrlProtocol, addr: SocketAddr) -> Result { + // Convert 0.0.0.0 to 127.0.0.1 for client connections + // When binding to port 0, the server binds to 0.0.0.0: + // but clients need to connect to 127.0.0.1: to work reliably + let url_string = if addr.ip().is_unspecified() { + format!("{protocol}://127.0.0.1:{}", addr.port()) + } else { + format!("{protocol}://{addr}") + }; + + url_string.parse().map_err(Into::into) +} + +fn bedrock_channel_id() -> ChannelId { + let channel_id: [u8; 32] = [0_u8, 1] + .repeat(16) + .try_into() + .unwrap_or_else(|_| unreachable!()); + ChannelId::from(channel_id) +} diff --git a/test_fixtures/src/indexer_client.rs b/test_fixtures/src/indexer_client.rs new file mode 100644 index 00000000..5641d824 --- /dev/null +++ b/test_fixtures/src/indexer_client.rs @@ -0,0 +1,34 @@ +//! Thin client wrapper for querying the indexer's JSON-RPC API in tests. +//! +//! The sequencer doesn't depend on the indexer at runtime — finalization comes +//! from zone-sdk events. This wrapper exists purely for test ergonomics so +//! integration tests can construct a single connection and call +//! `indexer_service_rpc::RpcClient` methods directly via `Deref`. + +use std::ops::Deref; + +use anyhow::{Context as _, Result}; +use jsonrpsee::ws_client::{WsClient, WsClientBuilder}; +use log::info; +use url::Url; + +pub struct IndexerClient(WsClient); + +impl IndexerClient { + pub async fn new(indexer_url: &Url) -> Result { + info!("Connecting to Indexer at {indexer_url}"); + let client = WsClientBuilder::default() + .build(indexer_url) + .await + .context("Failed to create websocket client")?; + Ok(Self(client)) + } +} + +impl Deref for IndexerClient { + type Target = WsClient; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} diff --git a/test_fixtures/src/lib.rs b/test_fixtures/src/lib.rs new file mode 100644 index 00000000..b22b6fe7 --- /dev/null +++ b/test_fixtures/src/lib.rs @@ -0,0 +1,506 @@ +//! Shared test/bench fixtures: spins up bedrock + sequencer + indexer + wallet +//! end-to-end against docker-compose, exposes a `TestContext` callers can drive. + +use std::{net::SocketAddr, path::Path, sync::LazyLock}; + +use anyhow::{Context as _, Result}; +use common::{HashType, transaction::NSSATransaction}; +use futures::FutureExt as _; +use indexer_service::IndexerHandle; +use log::{debug, error}; +use nssa::{AccountId, PrivacyPreservingTransaction}; +use nssa_core::Commitment; +use sequencer_core::config::GenesisAction; +use sequencer_service::SequencerHandle; +use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; +use serde::Serialize; +use tempfile::TempDir; +use testcontainers::compose::DockerCompose; +use wallet::{WalletCore, account::AccountIdWithPrivacy, cli::CliAccountMention}; + +use crate::{ + indexer_client::IndexerClient, + setup::{ + setup_bedrock_node, setup_indexer, setup_private_accounts_with_initial_supply, + setup_public_accounts_with_initial_supply, setup_sequencer, setup_wallet, + }, +}; + +pub mod config; +pub mod indexer_client; +pub mod setup; + +// TODO: Remove this and control time from tests +pub const TIME_TO_WAIT_FOR_BLOCK_SECONDS: u64 = 12; +pub const NSSA_PROGRAM_FOR_TEST_DATA_CHANGER: &str = "data_changer.bin"; +pub const NSSA_PROGRAM_FOR_TEST_NOOP: &str = "noop.bin"; +pub const NSSA_PROGRAM_FOR_TEST_PDA_SPEND_PROXY: &str = "pda_spend_proxy.bin"; + +pub(crate) const BEDROCK_SERVICE_WITH_OPEN_PORT: &str = "logos-blockchain-node-0"; +pub(crate) const BEDROCK_SERVICE_PORT: u16 = 18080; + +static LOGGER: LazyLock<()> = LazyLock::new(env_logger::init); + +struct IndexerComponents { + indexer_handle: IndexerHandle, + indexer_client: IndexerClient, + temp_dir: TempDir, +} + +impl Drop for IndexerComponents { + fn drop(&mut self) { + let Self { + indexer_handle, + indexer_client: _, + temp_dir: _, + } = self; + + if !indexer_handle.is_healthy() { + error!("Indexer handle has unexpectedly stopped before IndexerComponents drop"); + } + } +} + +/// Recursively-sized bytes on disk for sequencer / indexer / wallet tempdirs. +#[derive(Debug, Clone, Copy, Default, Serialize)] +pub struct DiskSizes { + pub sequencer_bytes: u64, + pub indexer_bytes: u64, + pub wallet_bytes: u64, +} + +/// Test context which sets up a sequencer and a wallet for integration tests. +/// +/// It's memory and logically safe to create multiple instances of this struct in parallel tests, +/// as each instance uses its own temporary directories for sequencer and wallet data. +// NOTE: Order of fields is important for proper drop order. +pub struct TestContext { + sequencer_client: SequencerClient, + wallet: WalletCore, + wallet_password: String, + /// Optional to move out value in Drop. + sequencer_handle: Option, + indexer_components: Option, + bedrock_compose: DockerCompose, + bedrock_addr: SocketAddr, + temp_sequencer_dir: TempDir, + temp_wallet_dir: TempDir, +} + +impl TestContext { + /// Create new test context. + pub async fn new() -> Result { + Self::builder().build().await + } + + /// Get a builder for the test context to customize its configuration. + #[must_use] + pub const fn builder() -> TestContextBuilder { + TestContextBuilder::new() + } + + /// Get reference to the wallet. + #[must_use] + pub const fn wallet(&self) -> &WalletCore { + &self.wallet + } + + #[must_use] + pub fn wallet_password(&self) -> &str { + &self.wallet_password + } + + /// Get mutable reference to the wallet. + pub const fn wallet_mut(&mut self) -> &mut WalletCore { + &mut self.wallet + } + + /// Get reference to the sequencer client. + #[must_use] + pub const fn sequencer_client(&self) -> &SequencerClient { + &self.sequencer_client + } + + /// Get the Bedrock Node address. + #[must_use] + pub const fn bedrock_addr(&self) -> SocketAddr { + self.bedrock_addr + } + + /// Get reference to the indexer. + /// + /// # Panics + /// + /// Panics if the indexer is not enabled in the test context. See + /// [`TestContextBuilder::disable_indexer()`]. + #[must_use] + pub fn indexer(&self) -> &IndexerHandle { + self.indexer_components + .as_ref() + .map(|components| &components.indexer_handle) + .expect("Called `TestContext::indexer()` on context with disabled indexer") + } + + /// Get the indexer's bound socket address. + /// + /// # Panics + /// + /// Panics if the indexer is not enabled in the test context. + #[must_use] + pub fn indexer_addr(&self) -> SocketAddr { + self.indexer().addr() + } + + /// Get reference to the indexer client. + /// + /// # Panics + /// + /// Panics if the indexer is not enabled in the test context. See + /// [`TestContextBuilder::disable_indexer()`]. + #[must_use] + pub fn indexer_client(&self) -> &IndexerClient { + self.indexer_components + .as_ref() + .map(|components| &components.indexer_client) + .expect("Called `TestContext::indexer_client()` on context with disabled indexer") + } + + /// Recursively-sized bytes on disk for sequencer + indexer + wallet tempdirs. + /// Indexer bytes are zero if the indexer is disabled. + #[must_use] + pub fn disk_sizes(&self) -> DiskSizes { + DiskSizes { + sequencer_bytes: dir_size_bytes(self.temp_sequencer_dir.path()), + indexer_bytes: self + .indexer_components + .as_ref() + .map_or(0, |c| dir_size_bytes(c.temp_dir.path())), + wallet_bytes: dir_size_bytes(self.temp_wallet_dir.path()), + } + } + + /// Get existing public account IDs in the wallet. + #[must_use] + pub fn existing_public_accounts(&self) -> Vec { + self.wallet + .storage() + .key_chain() + .public_account_ids() + .map(|(account_id, _idx)| account_id) + .collect() + } + + /// Get existing private account IDs in the wallet. + #[must_use] + pub fn existing_private_accounts(&self) -> Vec { + self.wallet + .storage() + .key_chain() + .private_account_ids() + .map(|(account_id, _idx)| account_id) + .collect() + } +} + +impl Drop for TestContext { + fn drop(&mut self) { + let Self { + sequencer_handle, + bedrock_compose, + bedrock_addr: _, + indexer_components: _, + sequencer_client: _, + wallet: _, + wallet_password: _, + temp_sequencer_dir: _, + temp_wallet_dir: _, + } = self; + + let sequencer_handle = sequencer_handle + .take() + .expect("Sequencer handle should be present in TestContext drop"); + if !sequencer_handle.is_healthy() { + let Err(err) = sequencer_handle + .failed() + .now_or_never() + .expect("Sequencer handle should not be running"); + error!( + "Sequencer handle has unexpectedly stopped before TestContext drop with error: {err:#}" + ); + } + + let container = bedrock_compose + .service(BEDROCK_SERVICE_WITH_OPEN_PORT) + .unwrap_or_else(|| { + panic!("Failed to get Bedrock service container `{BEDROCK_SERVICE_WITH_OPEN_PORT}`") + }); + let output = std::process::Command::new("docker") + .args(["inspect", "-f", "{{.State.Running}}", container.id()]) + .output() + .expect("Failed to execute docker inspect command to check if Bedrock container is still running"); + let stdout = String::from_utf8(output.stdout) + .expect("Failed to parse docker inspect output as String"); + if stdout.trim() != "true" { + error!( + "Bedrock container `{}` is not running during TestContext drop, docker inspect output: {stdout}", + container.id() + ); + } + } +} + +pub struct TestContextBuilder { + genesis_transactions: Option>, + sequencer_partial_config: Option, + enable_indexer: bool, +} + +impl TestContextBuilder { + const fn new() -> Self { + Self { + genesis_transactions: None, + sequencer_partial_config: None, + enable_indexer: true, + } + } + + #[must_use] + pub fn with_genesis(mut self, genesis_transactions: Vec) -> Self { + self.genesis_transactions = Some(genesis_transactions); + self + } + + #[must_use] + pub const fn with_sequencer_partial_config( + mut self, + sequencer_partial_config: config::SequencerPartialConfig, + ) -> Self { + self.sequencer_partial_config = Some(sequencer_partial_config); + self + } + + /// Exclude Indexer from test context. + /// Indexer is enabled by default. + /// + /// Methods like [`TestContext::indexer()`] and [`TestContext::indexer_client()`] will panic if + /// called when indexer is disabled. + #[must_use] + pub const fn disable_indexer(mut self) -> Self { + self.enable_indexer = false; + self + } + + pub async fn build(self) -> Result { + let Self { + genesis_transactions, + sequencer_partial_config, + enable_indexer, + } = self; + + // Ensure logger is initialized only once + *LOGGER; + + debug!("Test context setup"); + + let (bedrock_compose, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock node")?; + + let indexer_components = if enable_indexer { + let (indexer_handle, temp_indexer_dir) = setup_indexer(bedrock_addr) + .await + .context("Failed to setup Indexer")?; + let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_handle.addr()) + .context("Failed to convert indexer addr to URL")?; + let indexer_client = IndexerClient::new(&indexer_url) + .await + .context("Failed to create indexer client")?; + Some(IndexerComponents { + indexer_handle, + indexer_client, + temp_dir: temp_indexer_dir, + }) + } else { + None + }; + + let initial_public_accounts = config::default_public_accounts_for_wallet(); + let initial_private_accounts = config::default_private_accounts_for_wallet(); + // Wallet genesis must always be present so that + // setup_public/private_accounts_with_initial_supply can claim from the vault PDAs. + // When a test supplies custom genesis, merge rather than replace. + let wallet_genesis = + config::genesis_from_accounts(&initial_public_accounts, &initial_private_accounts); + let genesis = match genesis_transactions { + Some(mut custom) => { + custom.extend(wallet_genesis); + custom + } + None => wallet_genesis, + }; + let (sequencer_handle, temp_sequencer_dir) = setup_sequencer( + sequencer_partial_config.unwrap_or_default(), + bedrock_addr, + genesis, + ) + .await + .context("Failed to setup Sequencer")?; + + let (mut wallet, temp_wallet_dir, wallet_password) = setup_wallet( + sequencer_handle.addr(), + &initial_public_accounts, + &initial_private_accounts, + ) + .context("Failed to setup wallet")?; + + setup_public_accounts_with_initial_supply(&wallet, &initial_public_accounts) + .await + .context("Failed to initialize public accounts in wallet")?; + + setup_private_accounts_with_initial_supply(&mut wallet, &initial_private_accounts) + .await + .context("Failed to initialize private accounts in wallet")?; + + let sequencer_url = config::addr_to_url(config::UrlProtocol::Http, sequencer_handle.addr()) + .context("Failed to convert sequencer addr to URL")?; + let sequencer_client = SequencerClientBuilder::default() + .build(sequencer_url) + .context("Failed to create sequencer client")?; + + Ok(TestContext { + sequencer_client, + wallet, + wallet_password, + bedrock_compose, + bedrock_addr, + sequencer_handle: Some(sequencer_handle), + indexer_components, + temp_sequencer_dir, + temp_wallet_dir, + }) + } + + pub fn build_blocking(self) -> Result { + let runtime = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?; + + let ctx = runtime.block_on(self.build())?; + + Ok(BlockingTestContext { + ctx: Some(ctx), + runtime, + }) + } +} +/// A test context to be used in normal #[test] tests. +pub struct BlockingTestContext { + ctx: Option, + runtime: tokio::runtime::Runtime, +} + +impl BlockingTestContext { + pub fn new() -> Result { + TestContext::builder().build_blocking() + } + + pub const fn ctx(&self) -> &TestContext { + self.ctx.as_ref().expect("TestContext is set") + } + + pub const fn runtime(&self) -> &tokio::runtime::Runtime { + &self.runtime + } + + pub fn block_on<'ctx, F>(&'ctx self, f: impl FnOnce(&'ctx TestContext) -> F) -> F::Output + where + F: std::future::Future + 'ctx, + { + let future = f(self.ctx()); + self.runtime.block_on(future) + } + + pub fn block_on_mut<'ctx, F>( + &'ctx mut self, + f: impl FnOnce(&'ctx mut TestContext) -> F, + ) -> F::Output + where + F: std::future::Future + 'ctx, + { + let ctx_mut = self.ctx.as_mut().expect("TestContext is set"); + let future = f(ctx_mut); + self.runtime.block_on(future) + } +} + +impl Drop for BlockingTestContext { + fn drop(&mut self) { + let Self { ctx, runtime } = self; + + // Ensure async cleanup of TestContext by blocking on its drop in the runtime. + runtime.block_on(async { + if let Some(ctx) = ctx.take() { + drop(ctx); + } + }); + } +} + +#[must_use] +pub const fn public_mention(account_id: AccountId) -> CliAccountMention { + CliAccountMention::Id(AccountIdWithPrivacy::Public(account_id)) +} + +#[must_use] +pub const fn private_mention(account_id: AccountId) -> CliAccountMention { + CliAccountMention::Id(AccountIdWithPrivacy::Private(account_id)) +} + +#[expect( + clippy::wildcard_enum_match_arm, + reason = "We want the code to panic if the transaction type is not PrivacyPreserving" +)] +pub async fn fetch_privacy_preserving_tx( + seq_client: &SequencerClient, + tx_hash: HashType, +) -> PrivacyPreservingTransaction { + let tx = seq_client.get_transaction(tx_hash).await.unwrap().unwrap(); + + match tx { + NSSATransaction::PrivacyPreserving(privacy_preserving_transaction) => { + privacy_preserving_transaction + } + _ => panic!("Invalid tx type"), + } +} + +pub async fn verify_commitment_is_in_state( + commitment: Commitment, + seq_client: &SequencerClient, +) -> bool { + seq_client + .get_proof_for_commitment(commitment) + .await + .ok() + .flatten() + .is_some() +} + +fn dir_size_bytes(path: &Path) -> u64 { + let mut total = 0_u64; + let Ok(entries) = std::fs::read_dir(path) else { + return 0; + }; + for entry in entries.flatten() { + let Ok(metadata) = entry.metadata() else { + continue; + }; + if metadata.is_file() { + total = total.saturating_add(metadata.len()); + } else if metadata.is_dir() { + total = total.saturating_add(dir_size_bytes(&entry.path())); + } else { + // Sockets, FIFOs, block/char devices: ignore. Symlinks are + // already followed by `is_file()` / `is_dir()`. + } + } + total +} diff --git a/test_fixtures/src/setup.rs b/test_fixtures/src/setup.rs new file mode 100644 index 00000000..c43590d0 --- /dev/null +++ b/test_fixtures/src/setup.rs @@ -0,0 +1,327 @@ +use std::{collections::HashMap, net::SocketAddr, path::PathBuf}; + +use anyhow::{Context as _, Result, bail}; +use common::transaction::NSSATransaction; +use indexer_service::IndexerHandle; +use log::{debug, warn}; +use nssa::{AccountId, PrivateKey, PublicKey, PublicTransaction, program::Program}; +use sequencer_service::{GenesisAction, SequencerHandle}; +use sequencer_service_rpc::RpcClient as _; +use tempfile::TempDir; +use testcontainers::compose::DockerCompose; +use wallet::{ + AccDecodeData::Decode, PrivacyPreservingAccount, WalletCore, config::WalletConfigOverrides, +}; + +use crate::{ + BEDROCK_SERVICE_PORT, BEDROCK_SERVICE_WITH_OPEN_PORT, + config::{self, InitialPrivateAccountForWallet}, +}; + +pub async fn setup_bedrock_node() -> Result<(DockerCompose, SocketAddr)> { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let bedrock_compose_path = PathBuf::from(manifest_dir).join("../bedrock/docker-compose.yml"); + + let mut compose = DockerCompose::with_auto_client(&[bedrock_compose_path]) + .await + .context("Failed to setup docker compose for Bedrock")? + // Setting port to 0 to avoid conflicts between parallel tests, actual port will be retrieved after container is up + .with_env("PORT", "0"); + + #[expect( + clippy::items_after_statements, + reason = "This is more readable is this function used just after its definition" + )] + async fn up_and_retrieve_port(compose: &mut DockerCompose) -> Result { + compose + .up() + .await + .context("Failed to bring up Bedrock services")?; + let container = compose + .service(BEDROCK_SERVICE_WITH_OPEN_PORT) + .with_context(|| { + format!( + "Failed to get Bedrock service container `{BEDROCK_SERVICE_WITH_OPEN_PORT}`" + ) + })?; + + let ports = container.ports().await.with_context(|| { + format!( + "Failed to get ports for Bedrock service container `{}`", + container.id() + ) + })?; + ports + .map_to_host_port_ipv4(BEDROCK_SERVICE_PORT) + .with_context(|| { + format!( + "Failed to retrieve host port of {BEDROCK_SERVICE_PORT} container \ + port for container `{}`, existing ports: {ports:?}", + container.id() + ) + }) + } + + let mut port = None; + let mut attempt = 0_u32; + let max_attempts = 5_u32; + while port.is_none() && attempt < max_attempts { + attempt = attempt + .checked_add(1) + .expect("We check that attempt < max_attempts, so this won't overflow"); + match up_and_retrieve_port(&mut compose).await { + Ok(p) => { + port = Some(p); + } + Err(err) => { + warn!( + "Failed to bring up Bedrock services: {err:?}, attempt {attempt}/{max_attempts}" + ); + } + } + } + let Some(port) = port else { + bail!("Failed to bring up Bedrock services after {max_attempts} attempts"); + }; + + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + Ok((compose, addr)) +} + +pub async fn setup_indexer(bedrock_addr: SocketAddr) -> Result<(IndexerHandle, TempDir)> { + let temp_indexer_dir = + tempfile::tempdir().context("Failed to create temp dir for indexer home")?; + + debug!( + "Using temp indexer home at {}", + temp_indexer_dir.path().display() + ); + + let indexer_config = config::indexer_config(bedrock_addr, temp_indexer_dir.path().to_owned()) + .context("Failed to create Indexer config")?; + + indexer_service::run_server(indexer_config, 0) + .await + .context("Failed to run Indexer Service") + .map(|handle| (handle, temp_indexer_dir)) +} + +pub async fn setup_sequencer( + partial: config::SequencerPartialConfig, + bedrock_addr: SocketAddr, + genesis_transactions: Vec, +) -> Result<(SequencerHandle, TempDir)> { + let temp_sequencer_dir = + tempfile::tempdir().context("Failed to create temp dir for sequencer home")?; + + debug!( + "Using temp sequencer home at {}", + temp_sequencer_dir.path().display() + ); + + let config = config::sequencer_config( + partial, + temp_sequencer_dir.path().to_owned(), + bedrock_addr, + genesis_transactions, + ) + .context("Failed to create Sequencer config")?; + + let sequencer_handle = sequencer_service::run(config, 0).await?; + + Ok((sequencer_handle, temp_sequencer_dir)) +} + +pub fn setup_wallet( + sequencer_addr: SocketAddr, + initial_public_accounts: &[(PrivateKey, u128)], + initial_private_accounts: &[InitialPrivateAccountForWallet], +) -> Result<(WalletCore, TempDir, String)> { + let config = config::wallet_config(sequencer_addr).context("Failed to create Wallet config")?; + let config_serialized = + serde_json::to_string_pretty(&config).context("Failed to serialize Wallet config")?; + + let temp_wallet_dir = + tempfile::tempdir().context("Failed to create temp dir for wallet home")?; + + let config_path = temp_wallet_dir.path().join("wallet_config.json"); + std::fs::write(&config_path, config_serialized) + .context("Failed to write wallet config in temp dir")?; + + let storage_path = temp_wallet_dir.path().join("storage.json"); + let config_overrides = WalletConfigOverrides::default(); + + let wallet_password = "test_pass".to_owned(); + let (mut wallet, _mnemonic) = WalletCore::new_init_storage( + config_path, + storage_path, + Some(config_overrides), + &wallet_password, + ) + .context("Failed to init wallet")?; + + for (private_key, _balance) in initial_public_accounts { + wallet + .storage_mut() + .key_chain_mut() + .add_imported_public_account(private_key.clone()); + } + + for private_account in initial_private_accounts { + wallet + .storage_mut() + .key_chain_mut() + .add_imported_private_account( + private_account.key_chain.clone(), + None, + private_account.identifier, + nssa::Account::default(), + ); + } + + wallet + .store_persistent_data() + .context("Failed to store wallet persistent data")?; + + Ok((wallet, temp_wallet_dir, wallet_password)) +} + +pub async fn setup_public_accounts_with_initial_supply( + wallet: &WalletCore, + initial_public_accounts: &[(PrivateKey, u128)], +) -> Result<()> { + for (private_key, amount) in initial_public_accounts { + claim_funds_from_vault( + wallet, + AccountId::from(&PublicKey::new_from_private_key(private_key)), + *amount, + ) + .await + .context("Failed to claim funds from vault into public account")?; + } + + Ok(()) +} + +pub async fn setup_private_accounts_with_initial_supply( + wallet: &mut WalletCore, + initial_private_accounts: &[InitialPrivateAccountForWallet], +) -> Result<()> { + for private_account in initial_private_accounts { + claim_funds_from_vault_to_private( + wallet, + private_account.account_id(), + private_account.balance, + ) + .await + .context("Failed to claim funds from vault into private account")?; + } + + Ok(()) +} + +async fn claim_funds_from_vault( + wallet: &WalletCore, + owner_id: AccountId, + amount: u128, +) -> Result<()> { + let vault_program_id = Program::vault().id(); + let owner_vault_id = vault_core::compute_vault_account_id(vault_program_id, owner_id); + + let nonces = wallet + .get_accounts_nonces(vec![owner_id]) + .await + .context("Failed to fetch owner nonce")?; + + let signing_key = wallet + .storage() + .key_chain() + .pub_account_signing_key(owner_id) + .with_context(|| format!("Missing signing key for public account {owner_id}"))?; + + let message = nssa::public_transaction::Message::try_new( + vault_program_id, + vec![owner_id, owner_vault_id], + nonces, + vault_core::Instruction::Claim { amount }, + ) + .context("Failed to build vault claim message")?; + + let witness_set = nssa::public_transaction::WitnessSet::for_message(&message, &[signing_key]); + let tx = PublicTransaction::new(message, witness_set); + + let tx_hash = wallet + .sequencer_client + .send_transaction(NSSATransaction::Public(tx)) + .await + .context("Failed to submit vault claim transaction")?; + + wallet + .poll_native_token_transfer(tx_hash) + .await + .context("Failed to confirm vault claim transaction")?; + + Ok(()) +} + +async fn claim_funds_from_vault_to_private( + wallet: &mut WalletCore, + owner_id: AccountId, + amount: u128, +) -> Result<()> { + let Some(_) = wallet.storage().key_chain().private_account(owner_id) else { + bail!("Missing private account in wallet key chain for account {owner_id}"); + }; + + let vault_program = Program::vault(); + let vault_program_id = vault_program.id(); + let owner_vault_id = vault_core::compute_vault_account_id(vault_program_id, owner_id); + + let instruction_data = + Program::serialize_instruction(vault_core::Instruction::Claim { amount }) + .context("Failed to serialize vault private claim instruction")?; + + let program_with_dependencies = + nssa::privacy_preserving_transaction::circuit::ProgramWithDependencies::new( + vault_program, + HashMap::from([( + Program::authenticated_transfer_program().id(), + Program::authenticated_transfer_program(), + )]), + ); + + let (tx_hash, mut secrets) = wallet + .send_privacy_preserving_tx( + vec![ + PrivacyPreservingAccount::PrivateOwned(owner_id), + PrivacyPreservingAccount::Public(owner_vault_id), + ], + instruction_data, + &program_with_dependencies, + ) + .await + .context("Failed to submit private vault claim transaction")?; + + let secret = secrets + .pop() + .context("Expected one private output secret for vault claim")?; + + let transfer_tx = wallet + .poll_native_token_transfer(tx_hash) + .await + .context("Failed to confirm private vault claim transaction")?; + + let NSSATransaction::PrivacyPreserving(tx) = transfer_tx else { + bail!("Expected privacy preserving transaction result for private vault claim"); + }; + + wallet + .decode_insert_privacy_preserving_transaction_results(&tx, &[Decode(secret, owner_id)]) + .context("Failed to decode private vault claim transaction")?; + + wallet + .store_persistent_data() + .context("Failed to store wallet data after private vault claim")?; + + Ok(()) +} diff --git a/test_program_methods/guest/Cargo.toml b/test_program_methods/guest/Cargo.toml index 1ca958b3..47ea10e1 100644 --- a/test_program_methods/guest/Cargo.toml +++ b/test_program_methods/guest/Cargo.toml @@ -9,5 +9,9 @@ workspace = true [dependencies] nssa_core.workspace = true +authenticated_transfer_core.workspace = true +clock_core.workspace = true +faucet_core.workspace = true risc0-zkvm.workspace = true +serde = { workspace = true, default-features = false } diff --git a/test_program_methods/guest/src/bin/auth_asserting_noop.rs b/test_program_methods/guest/src/bin/auth_asserting_noop.rs new file mode 100644 index 00000000..0b6d9176 --- /dev/null +++ b/test_program_methods/guest/src/bin/auth_asserting_noop.rs @@ -0,0 +1,40 @@ +use nssa_core::program::{AccountPostState, ProgramInput, ProgramOutput, read_nssa_inputs}; + +/// A variant of `noop` that asserts every `pre_state.is_authorized == true` before echoing +/// the `post_states`. Any unauthorized `pre_state` panics the guest, failing the whole +/// circuit proof. Used as a callee in private-PDA delegation tests to actually exercise the +/// authorization propagated through `ChainedCall.pda_seeds`. +type Instruction = (); + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + .. + }, + instruction_words, + ) = read_nssa_inputs::(); + + for pre in &pre_states { + assert!( + pre.is_authorized, + "auth_asserting_noop: pre_state {} is not authorized", + pre.account_id + ); + } + + let post_states = pre_states + .iter() + .map(|account| AccountPostState::new(account.account.clone())) + .collect(); + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + post_states, + ) + .write(); +} diff --git a/test_program_methods/guest/src/bin/auth_transfer_proxy.rs b/test_program_methods/guest/src/bin/auth_transfer_proxy.rs new file mode 100644 index 00000000..b3590074 --- /dev/null +++ b/test_program_methods/guest/src/bin/auth_transfer_proxy.rs @@ -0,0 +1,104 @@ +use nssa_core::program::{ + AccountPostState, ChainedCall, PdaSeed, ProgramId, ProgramInput, ProgramOutput, + read_nssa_inputs, +}; + +/// PDA authorization program that delegates balance operations to `authenticated_transfer`. +/// +/// The PDA is owned by `authenticated_transfer`, not by this program. This program's role +/// is solely to provide PDA authorization via `pda_seeds` in chained calls. +/// +/// Instruction: `(pda_seed, auth_transfer_id, amount, is_withdraw)`. +/// +/// **Init** (`is_withdraw = false`, 1 pre-state `[pda]`): +/// Chains to `authenticated_transfer` with `instruction=0` (init path) and `pda_seeds=[seed]` +/// to initialize the PDA under `authenticated_transfer`'s ownership. +/// +/// **Withdraw** (`is_withdraw = true`, 2 pre-states `[pda, recipient]`): +/// Chains to `authenticated_transfer` with the amount and `pda_seeds=[seed]` to authorize +/// the PDA for a balance transfer. The actual balance modification happens in +/// `authenticated_transfer`, not here. +/// +/// **Deposit**: done directly via `authenticated_transfer` (no need for this program). +type Instruction = (PdaSeed, ProgramId, u128, bool); + +#[expect( + clippy::allow_attributes, + reason = "allow is needed because the clones are only redundant in test compilation" +)] +#[allow( + clippy::redundant_clone, + reason = "clones needed in non-test compilation" +)] +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: (pda_seed, auth_transfer_id, amount, is_withdraw), + }, + instruction_words, + ) = read_nssa_inputs::(); + + if is_withdraw { + let Ok([pda_pre, recipient_pre]) = <[_; 2]>::try_from(pre_states.clone()) else { + panic!("expected exactly 2 pre_states for withdraw: [pda, recipient]"); + }; + + // Post-states stay unchanged in this program. The actual balance transfer + // happens in the chained call to authenticated_transfer. + let pda_post = AccountPostState::new(pda_pre.account.clone()); + let recipient_post = AccountPostState::new(recipient_pre.account.clone()); + + // Chain to authenticated_transfer with pda_seeds to authorize the PDA. + // The circuit's resolve_authorization_and_record_bindings establishes the + // private PDA (seed, npk) binding when pda_seeds match the private PDA derivation. + let mut auth_pda_pre = pda_pre; + auth_pda_pre.is_authorized = true; + let auth_call = ChainedCall::new( + auth_transfer_id, + vec![auth_pda_pre, recipient_pre], + &authenticated_transfer_core::Instruction::Transfer { amount }, + ) + .with_pda_seeds(vec![pda_seed]); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + vec![pda_post, recipient_post], + ) + .with_chained_calls(vec![auth_call]) + .write(); + } else { + // Init: initialize the PDA under authenticated_transfer's ownership. + let Ok([pda_pre]) = <[_; 1]>::try_from(pre_states.clone()) else { + panic!("expected exactly 1 pre_state for init: [pda]"); + }; + + let pda_post = AccountPostState::new(pda_pre.account.clone()); + + // Chain to authenticated_transfer with instruction=0 (init path) and pda_seeds + // to authorize the PDA. authenticated_transfer will claim it with Claim::Authorized. + let mut auth_pda_pre = pda_pre; + auth_pda_pre.is_authorized = true; + let auth_call = ChainedCall::new( + auth_transfer_id, + vec![auth_pda_pre], + &authenticated_transfer_core::Instruction::Initialize, + ) + .with_pda_seeds(vec![pda_seed]); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + vec![pda_post], + ) + .with_chained_calls(vec![auth_call]) + .write(); + } +} diff --git a/test_program_methods/guest/src/bin/burner.rs b/test_program_methods/guest/src/bin/burner.rs index 991091c0..02be2d38 100644 --- a/test_program_methods/guest/src/bin/burner.rs +++ b/test_program_methods/guest/src/bin/burner.rs @@ -5,6 +5,8 @@ type Instruction = u128; fn main() { let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction: balance_to_burn, }, @@ -20,6 +22,8 @@ fn main() { account_post.balance = account_post.balance.saturating_sub(balance_to_burn); ProgramOutput::new( + self_program_id, + caller_program_id, instruction_words, vec![pre], vec![AccountPostState::new(account_post)], diff --git a/test_program_methods/guest/src/bin/chain_caller.rs b/test_program_methods/guest/src/bin/chain_caller.rs index c5780665..ac25301b 100644 --- a/test_program_methods/guest/src/bin/chain_caller.rs +++ b/test_program_methods/guest/src/bin/chain_caller.rs @@ -1,3 +1,4 @@ +use authenticated_transfer_core::Instruction as AuthTransferInstruction; use nssa_core::program::{ AccountPostState, ChainedCall, PdaSeed, ProgramId, ProgramInput, ProgramOutput, read_nssa_inputs, @@ -13,6 +14,8 @@ type Instruction = (u128, ProgramId, u32, Option); fn main() { let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction: (balance, auth_transfer_id, num_chain_calls, pda_seed), }, @@ -23,7 +26,7 @@ fn main() { return; }; - let instruction_data = to_vec(&balance).unwrap(); + let instruction_data = to_vec(&AuthTransferInstruction::Transfer { amount: balance }).unwrap(); let mut running_recipient_pre = recipient_pre.clone(); let mut running_sender_pre = sender_pre.clone(); @@ -55,6 +58,8 @@ fn main() { } ProgramOutput::new( + self_program_id, + caller_program_id, instruction_words, vec![sender_pre.clone(), recipient_pre.clone()], vec![ diff --git a/test_program_methods/guest/src/bin/changer_claimer.rs b/test_program_methods/guest/src/bin/changer_claimer.rs index ee82ec16..6d2b51b4 100644 --- a/test_program_methods/guest/src/bin/changer_claimer.rs +++ b/test_program_methods/guest/src/bin/changer_claimer.rs @@ -6,6 +6,8 @@ type Instruction = (Option>, bool); fn main() { let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction: (data_opt, should_claim), }, @@ -33,5 +35,12 @@ fn main() { AccountPostState::new(account_post) }; - ProgramOutput::new(instruction_words, vec![pre], vec![post_state]).write(); + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pre], + vec![post_state], + ) + .write(); } diff --git a/test_program_methods/guest/src/bin/claimer.rs b/test_program_methods/guest/src/bin/claimer.rs index e6239381..a3a7fb19 100644 --- a/test_program_methods/guest/src/bin/claimer.rs +++ b/test_program_methods/guest/src/bin/claimer.rs @@ -5,6 +5,8 @@ type Instruction = (); fn main() { let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction: (), }, @@ -17,5 +19,12 @@ fn main() { let account_post = AccountPostState::new_claimed(pre.account.clone(), Claim::Authorized); - ProgramOutput::new(instruction_words, vec![pre], vec![account_post]).write(); + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pre], + vec![account_post], + ) + .write(); } diff --git a/test_program_methods/guest/src/bin/clock_chain_caller.rs b/test_program_methods/guest/src/bin/clock_chain_caller.rs new file mode 100644 index 00000000..cdbe5214 --- /dev/null +++ b/test_program_methods/guest/src/bin/clock_chain_caller.rs @@ -0,0 +1,46 @@ +use nssa_core::{ + Timestamp, + program::{ + AccountPostState, ChainedCall, ProgramId, ProgramInput, ProgramOutput, read_nssa_inputs, + }, +}; +use risc0_zkvm::serde::to_vec; + +type Instruction = (ProgramId, Timestamp); // (clock_program_id, timestamp) + +/// A program that chain-calls the clock program with the clock accounts it received as pre-states. +/// Used in tests to verify that user transactions cannot modify clock accounts, even indirectly +/// via chain calls. +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: (clock_program_id, timestamp), + }, + instruction_words, + ) = read_nssa_inputs::(); + + let post_states: Vec<_> = pre_states + .iter() + .map(|pre| AccountPostState::new(pre.account.clone())) + .collect(); + + let chained_call = ChainedCall { + program_id: clock_program_id, + instruction_data: to_vec(×tamp).unwrap(), + pre_states: pre_states.clone(), + pda_seeds: vec![], + }; + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + post_states, + ) + .with_chained_calls(vec![chained_call]) + .write(); +} diff --git a/test_program_methods/guest/src/bin/data_changer.rs b/test_program_methods/guest/src/bin/data_changer.rs index 730a7180..3969d7f6 100644 --- a/test_program_methods/guest/src/bin/data_changer.rs +++ b/test_program_methods/guest/src/bin/data_changer.rs @@ -6,6 +6,8 @@ type Instruction = Vec; fn main() { let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction: data, }, @@ -23,6 +25,8 @@ fn main() { .expect("provided data should fit into data limit"); ProgramOutput::new( + self_program_id, + caller_program_id, instruction_words, vec![pre], vec![AccountPostState::new_claimed( diff --git a/test_program_methods/guest/src/bin/extra_output.rs b/test_program_methods/guest/src/bin/extra_output.rs index 3adc591c..3a5df556 100644 --- a/test_program_methods/guest/src/bin/extra_output.rs +++ b/test_program_methods/guest/src/bin/extra_output.rs @@ -6,7 +6,15 @@ use nssa_core::{ type Instruction = (); fn main() { - let (ProgramInput { pre_states, .. }, instruction_words) = read_nssa_inputs::(); + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + .. + }, + instruction_words, + ) = read_nssa_inputs::(); let Ok([pre]) = <[_; 1]>::try_from(pre_states) else { return; @@ -15,6 +23,8 @@ fn main() { let account_pre = pre.account.clone(); ProgramOutput::new( + self_program_id, + caller_program_id, instruction_words, vec![pre], vec![ diff --git a/test_program_methods/guest/src/bin/faucet_chain_caller.rs b/test_program_methods/guest/src/bin/faucet_chain_caller.rs new file mode 100644 index 00000000..2e02982d --- /dev/null +++ b/test_program_methods/guest/src/bin/faucet_chain_caller.rs @@ -0,0 +1,52 @@ +use nssa_core::{ + account::AccountId, + program::{ + AccountPostState, ChainedCall, ProgramId, ProgramInput, ProgramOutput, read_nssa_inputs, + }, +}; +use risc0_zkvm::serde::to_vec; + +type Instruction = (ProgramId, ProgramId, AccountId, u128); +// (faucet_program_id, vault_program_id, recipient_id, amount) + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: (faucet_program_id, vault_program_id, recipient_id, amount), + }, + instruction_words, + ) = read_nssa_inputs::(); + + let post_states: Vec<_> = pre_states + .iter() + .map(|pre| AccountPostState::new(pre.account.clone())) + .collect(); + + assert_eq!(pre_states.len(), 2); + let [faucet_pre, vault_pda_pre] = [pre_states[0].clone(), pre_states[1].clone()]; + + let chained_calls = vec![ChainedCall { + program_id: faucet_program_id, + instruction_data: to_vec(&faucet_core::Instruction::Transfer { + vault_program_id, + recipient_id, + amount, + }) + .unwrap(), + pre_states: vec![faucet_pre, vault_pda_pre], + pda_seeds: vec![], + }]; + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + post_states, + ) + .with_chained_calls(chained_calls) + .write(); +} diff --git a/test_program_methods/guest/src/bin/flash_swap_callback.rs b/test_program_methods/guest/src/bin/flash_swap_callback.rs new file mode 100644 index 00000000..ca596163 --- /dev/null +++ b/test_program_methods/guest/src/bin/flash_swap_callback.rs @@ -0,0 +1,97 @@ +//! Flash swap callback, the user logic step in the "prep → callback → assert" pattern. +//! +//! # Role +//! +//! This program is called as chained call 2 in the flash swap sequence: +//! 1. Token transfer out (vault → receiver) +//! 2. **This callback** (user logic) +//! 3. Invariant check (assert vault balance restored) +//! +//! In a real flash swap, this would contain the user's arbitrage or other logic. +//! In this test program, it is controlled by `return_funds`: +//! +//! - `return_funds = true`: emits a token transfer (receiver → vault) to return the funds. The +//! invariant check will pass and the transaction will succeed. +//! +//! - `return_funds = false`: emits no transfers. Funds stay with the receiver. The invariant check +//! will fail (vault balance < initial), causing full atomic rollback. This simulates a malicious +//! or buggy callback that does not repay the flash loan. +//! +//! # Note on `caller_program_id` +//! +//! This program does not enforce any access control on `caller_program_id`. +//! It is designed to be called by the flash swap initiator but could in principle be +//! called by any program. In production, a callback would typically verify the caller +//! if it needs to trust the context it is called from. + +use nssa_core::program::{ + AccountPostState, ChainedCall, PdaSeed, ProgramId, ProgramInput, ProgramOutput, + read_nssa_inputs, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct CallbackInstruction { + /// If true, return the borrowed funds to the vault (happy path). + /// If false, keep the funds (simulates a malicious callback, triggers rollback). + pub return_funds: bool, + pub token_program_id: ProgramId, + pub amount: u128, +} + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, // not enforced in this callback + pre_states, + instruction, + }, + instruction_words, + ) = read_nssa_inputs::(); + + // pre_states[0] = vault (after transfer out), pre_states[1] = receiver (after transfer out) + let Ok([vault_pre, receiver_pre]) = <[_; 2]>::try_from(pre_states) else { + panic!("Callback requires exactly 2 accounts: vault, receiver"); + }; + + let mut chained_calls = Vec::new(); + + if instruction.return_funds { + // Happy path: return the borrowed funds via a token transfer (receiver → vault). + // The receiver is a PDA of this callback program (seed = [1_u8; 32]). + // Mark the receiver as authorized since it will be PDA-authorized in this chained call. + let mut receiver_authorized = receiver_pre.clone(); + receiver_authorized.is_authorized = true; + let transfer_instruction = + risc0_zkvm::serde::to_vec(&authenticated_transfer_core::Instruction::Transfer { + amount: instruction.amount, + }) + .expect("transfer instruction serialization"); + + chained_calls.push(ChainedCall { + program_id: instruction.token_program_id, + pre_states: vec![receiver_authorized, vault_pre.clone()], + instruction_data: transfer_instruction, + pda_seeds: vec![PdaSeed::new([1_u8; 32])], + }); + } + // Malicious path (return_funds = false): emit no chained calls. + // The vault balance will not be restored, so the invariant check in the initiator + // will panic, rolling back the entire transaction including the initial transfer out. + + // The callback itself makes no direct state changes, accounts pass through unchanged. + // All mutations go through the token program via chained calls. + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![vault_pre.clone(), receiver_pre.clone()], + vec![ + AccountPostState::new(vault_pre.account), + AccountPostState::new(receiver_pre.account), + ], + ) + .with_chained_calls(chained_calls) + .write(); +} diff --git a/test_program_methods/guest/src/bin/flash_swap_initiator.rs b/test_program_methods/guest/src/bin/flash_swap_initiator.rs new file mode 100644 index 00000000..c6a76ebd --- /dev/null +++ b/test_program_methods/guest/src/bin/flash_swap_initiator.rs @@ -0,0 +1,219 @@ +//! Flash swap initiator, demonstrates the "prep → callback → assert" pattern using +//! generalized multi tail-calls with `self_program_id` and `caller_program_id`. +//! +//! # Pattern +//! +//! A flash swap lets a program optimistically transfer tokens out, run arbitrary user +//! logic (the callback), then assert that invariants hold after the callback. The entire +//! sequence is a single atomic transaction: if any step fails, all state changes roll back. +//! +//! # How it works +//! +//! This program handles two instruction variants: +//! +//! - `Initiate` (external): the top-level entrypoint. Emits 3 chained calls: +//! 1. Token transfer out (vault → receiver) +//! 2. User callback (arbitrary logic, e.g. arbitrage) +//! 3. Self-call to `InvariantCheck` (using `self_program_id` to reference itself) +//! +//! - `InvariantCheck` (internal): enforces that the vault balance was restored after the callback. +//! Uses `caller_program_id == Some(self_program_id)` to prevent standalone calls (this is the +//! visibility enforcement mechanism). +//! +//! # What this demonstrates +//! +//! - `self_program_id`: enables a program to chain back to itself (step 3 above) +//! - `caller_program_id`: enables a program to restrict which callers can invoke an instruction +//! - Computed intermediate states: the initiator computes expected intermediate account states from +//! the `pre_states` and amount, keeping the instruction minimal. +//! - Atomic rollback: if the callback doesn't return funds, the invariant check fails, and all +//! state changes from steps 1 and 2 are rolled back automatically. +//! +//! # Tests +//! +//! See `nssa/src/state.rs` for integration tests: +//! - `flash_swap_successful`: full round-trip, funds returned, state unchanged +//! - `flash_swap_callback_keeps_funds_rollback`: callback keeps funds, full rollback +//! - `flash_swap_self_call_targets_correct_program`: zero-amount self-call isolation test +//! - `flash_swap_standalone_invariant_check_rejected`: `caller_program_id` access control + +use nssa_core::program::{ + AccountPostState, ChainedCall, PdaSeed, ProgramId, ProgramInput, ProgramOutput, + read_nssa_inputs, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub enum FlashSwapInstruction { + /// External entrypoint: initiate a flash swap. + /// + /// Emits 3 chained calls: + /// 1. Token transfer (vault → receiver, `amount_out`) + /// 2. Callback (user logic, e.g. arbitrage) + /// 3. Self-call `InvariantCheck` (verify vault balance did not decrease) + /// + /// Intermediate account states are computed inside the program from `pre_states` and + /// `amount_out`. + Initiate { + token_program_id: ProgramId, + callback_program_id: ProgramId, + amount_out: u128, + callback_instruction_data: Vec, + }, + /// Internal: verify the vault invariant holds after callback execution. + /// + /// Access control: only callable as a chained call from this program itself. + /// This is enforced by checking `caller_program_id == Some(self_program_id)`. + /// Any attempt to call this instruction as a standalone top-level transaction + /// will be rejected because `caller_program_id` will be `None`. + InvariantCheck { min_vault_balance: u128 }, +} + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = read_nssa_inputs::(); + + match instruction { + FlashSwapInstruction::Initiate { + token_program_id, + callback_program_id, + amount_out, + callback_instruction_data, + } => { + let Ok([vault_pre, receiver_pre]) = <[_; 2]>::try_from(pre_states) else { + panic!("Initiate requires exactly 2 accounts: vault, receiver"); + }; + + // Capture initial vault balance, the invariant check will verify it is restored. + let min_vault_balance = vault_pre.account.balance; + + // Compute intermediate account states from pre_states and amount_out. + let mut vault_after_transfer = vault_pre.clone(); + vault_after_transfer.account.balance = vault_pre + .account + .balance + .checked_sub(amount_out) + .expect("vault has insufficient balance for flash swap"); + + let mut receiver_after_transfer = receiver_pre.clone(); + receiver_after_transfer.account.balance = receiver_pre + .account + .balance + .checked_add(amount_out) + .expect("receiver balance overflow"); + + let mut vault_after_callback = vault_after_transfer.clone(); + vault_after_callback.account.balance = vault_after_transfer + .account + .balance + .checked_add(amount_out) + .expect("vault balance overflow after callback"); + + // Chained call 1: Token transfer (vault → receiver). + // The vault is a PDA of this initiator program (seed = [0_u8; 32]), so we provide + // the PDA seed to authorize the token program to debit the vault on our behalf. + // Mark the vault as authorized since it will be PDA-authorized in this chained call. + let mut vault_authorized = vault_pre.clone(); + vault_authorized.is_authorized = true; + let transfer_instruction = + risc0_zkvm::serde::to_vec(&authenticated_transfer_core::Instruction::Transfer { + amount: amount_out, + }) + .expect("transfer instruction serialization"); + let call_1 = ChainedCall { + program_id: token_program_id, + pre_states: vec![vault_authorized, receiver_pre.clone()], + instruction_data: transfer_instruction, + pda_seeds: vec![PdaSeed::new([0_u8; 32])], + }; + + // Chained call 2: User callback. + // Receives the post-transfer states as its pre_states. The callback may run + // arbitrary logic (arbitrage, etc.) and is expected to return funds to the vault. + let call_2 = ChainedCall { + program_id: callback_program_id, + pre_states: vec![vault_after_transfer, receiver_after_transfer], + instruction_data: callback_instruction_data, + pda_seeds: vec![], + }; + + // Chained call 3: Self-call to enforce the invariant. + // Uses `self_program_id` to reference this program, the key feature that enables + // the "prep → callback → assert" pattern without a separate checker program. + // If the callback did not return funds, vault_after_callback.balance < + // min_vault_balance and this call will panic, rolling back the entire + // transaction. + let invariant_instruction = + risc0_zkvm::serde::to_vec(&FlashSwapInstruction::InvariantCheck { + min_vault_balance, + }) + .expect("invariant instruction serialization"); + let call_3 = ChainedCall { + program_id: self_program_id, // self-referential chained call + pre_states: vec![vault_after_callback], + instruction_data: invariant_instruction, + pda_seeds: vec![], + }; + + // The initiator itself makes no direct state changes. + // All mutations happen inside the chained calls (token transfers). + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![vault_pre.clone(), receiver_pre.clone()], + vec![ + AccountPostState::new(vault_pre.account), + AccountPostState::new(receiver_pre.account), + ], + ) + .with_chained_calls(vec![call_1, call_2, call_3]) + .write(); + } + + FlashSwapInstruction::InvariantCheck { min_vault_balance } => { + // Visibility enforcement: `InvariantCheck` is an internal instruction. + // It must only be called as a chained call from this program itself (via `Initiate`). + // When called as a top-level transaction, `caller_program_id` is `None` → panics. + // When called as a chained call from `Initiate`, `caller_program_id` is + // `Some(self_program_id)` → passes. + assert_eq!( + caller_program_id, + Some(self_program_id), + "InvariantCheck is an internal instruction: must be called by flash_swap_initiator \ + via a chained call", + ); + + let Ok([vault]) = <[_; 1]>::try_from(pre_states) else { + panic!("InvariantCheck requires exactly 1 account: vault"); + }; + + // The core invariant: vault balance must not have decreased. + // If the callback returned funds, this passes. If not, this panics and + // the entire transaction (including the prior token transfer) rolls back. + assert!( + vault.account.balance >= min_vault_balance, + "Flash swap invariant violated: vault balance {} < minimum {}", + vault.account.balance, + min_vault_balance + ); + + // Pass-through: no state changes in the invariant check step. + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![vault.clone()], + vec![AccountPostState::new(vault.account)], + ) + .write(); + } + } +} diff --git a/test_program_methods/guest/src/bin/malicious_authorization_changer.rs b/test_program_methods/guest/src/bin/malicious_authorization_changer.rs index 7452d337..894f22bf 100644 --- a/test_program_methods/guest/src/bin/malicious_authorization_changer.rs +++ b/test_program_methods/guest/src/bin/malicious_authorization_changer.rs @@ -14,6 +14,8 @@ type Instruction = (u128, ProgramId); fn main() { let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction: (balance, transfer_program_id), }, @@ -30,7 +32,8 @@ fn main() { ..sender.clone() }; - let instruction_data = to_vec(&balance).unwrap(); + let instruction_data = + to_vec(&authenticated_transfer_core::Instruction::Transfer { amount: balance }).unwrap(); let chained_call = ChainedCall { program_id: transfer_program_id, @@ -40,6 +43,8 @@ fn main() { }; ProgramOutput::new( + self_program_id, + caller_program_id, instruction_words, vec![sender.clone(), receiver.clone()], vec![ diff --git a/test_program_methods/guest/src/bin/malicious_caller_program_id.rs b/test_program_methods/guest/src/bin/malicious_caller_program_id.rs new file mode 100644 index 00000000..2326190e --- /dev/null +++ b/test_program_methods/guest/src/bin/malicious_caller_program_id.rs @@ -0,0 +1,34 @@ +use nssa_core::program::{ + AccountPostState, DEFAULT_PROGRAM_ID, ProgramInput, ProgramOutput, read_nssa_inputs, +}; + +type Instruction = (); + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id: _, // ignore the actual caller + pre_states, + instruction: (), + }, + instruction_words, + ) = read_nssa_inputs::(); + + let post_states = pre_states + .iter() + .map(|a| AccountPostState::new(a.account.clone())) + .collect(); + + // Deliberately output wrong caller_program_id. + // A real caller_program_id is None for a top-level call, so we spoof Some(DEFAULT_PROGRAM_ID) + // to simulate a program claiming it was invoked by another program when it was not. + ProgramOutput::new( + self_program_id, + Some(DEFAULT_PROGRAM_ID), // WRONG: should be None for a top-level call + instruction_words, + pre_states, + post_states, + ) + .write(); +} diff --git a/test_program_methods/guest/src/bin/malicious_injector.rs b/test_program_methods/guest/src/bin/malicious_injector.rs new file mode 100644 index 00000000..4e7300a2 --- /dev/null +++ b/test_program_methods/guest/src/bin/malicious_injector.rs @@ -0,0 +1,105 @@ +use nssa_core::{ + account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, + program::{ + AccountPostState, ChainedCall, ProgramId, ProgramInput, ProgramOutput, read_nssa_inputs, + }, +}; + +/// Instruction uses only risc0-serde-compatible primitives — no `AccountId`/`Account` structs, +/// which use `SerializeDisplay`/`DeserializeFromStr` and cannot round-trip through +/// `instruction_data`. +/// +/// Fields: +/// `p2_id`: program ID of the launderer (P2) +/// `auth_transfer_id`: program ID of `authenticated_transfer`, forwarded to P2 +/// `victim_id_raw`: raw `[u8; 32]` of the victim `AccountId` +/// `victim_balance`: victim's current balance +/// `victim_nonce`: victim's current nonce (inner `u128`) +/// `victim_program_owner`: victim account's `program_owner` field +/// `recipient_id_raw`: raw `[u8; 32]` of the recipient `AccountId` +/// `amount`: balance to transfer out of the victim. +type Instruction = ( + ProgramId, + ProgramId, + [u8; 32], + u128, + u128, + ProgramId, + [u8; 32], + u128, +); + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: + ( + p2_id, + auth_transfer_id, + victim_id_raw, + victim_balance, + victim_nonce, + victim_program_owner, + recipient_id_raw, + amount, + ), + }, + instruction_words, + ) = read_nssa_inputs::(); + + // Echo own pre_states (attacker's account) unchanged. + let post_states = pre_states + .iter() + .map(|p| AccountPostState::new(p.account.clone())) + .collect(); + + // Construct victim AccountWithMetadata from primitives, stamping is_authorized=true. + // Victim has not signed anything — this flag is forged entirely by P1's logic. + let victim = AccountWithMetadata { + account: Account { + program_owner: victim_program_owner, + balance: victim_balance, + data: Data::default(), + nonce: Nonce(victim_nonce), + }, + is_authorized: true, + account_id: AccountId::new(victim_id_raw), + }; + + // Recipient is already initialized under authenticated_transfer (program_owner = + // auth_transfer_id, balance = 0). Using the default account would trigger + // Claim::Authorized inside authenticated_transfer, which requires is_authorized=true + // on the recipient — a check that would block the transfer. + let recipient = AccountWithMetadata { + account: Account { + program_owner: auth_transfer_id, + balance: 0, + data: Data::default(), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: AccountId::new(recipient_id_raw), + }; + + // Forward auth_transfer_id and amount to P2 so it can call authenticated_transfer. + let p2_instruction = risc0_zkvm::serde::to_vec(&(auth_transfer_id, amount)) + .expect("serialization is infallible"); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + post_states, + ) + .with_chained_calls(vec![ChainedCall { + program_id: p2_id, + pre_states: vec![victim, recipient], + instruction_data: p2_instruction, + pda_seeds: vec![], + }]) + .write(); +} diff --git a/test_program_methods/guest/src/bin/malicious_launderer.rs b/test_program_methods/guest/src/bin/malicious_launderer.rs new file mode 100644 index 00000000..6d0568fd --- /dev/null +++ b/test_program_methods/guest/src/bin/malicious_launderer.rs @@ -0,0 +1,43 @@ +use nssa_core::program::{ChainedCall, ProgramId, ProgramInput, ProgramOutput, read_nssa_inputs}; + +/// Instruction: (`auth_transfer_id`, `amount`) — both primitive, safe for `risc0_zkvm::serde`. +type Instruction = (ProgramId, u128); + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: (auth_transfer_id, amount), + }, + instruction_words, + ) = read_nssa_inputs::(); + + // Output empty pre/post states. P2 processes no accounts itself, so the + // authorization check at validated_state_diff.rs:158-182 runs over nothing. + // Victim is never compared against caller_data.authorized_accounts = {attacker}. + // + // The bug: authorized_accounts for authenticated_transfer is built from + // chained_call.pre_states (this call's inputs, set by P1), which contains + // victim(is_authorized=true). So authorized_accounts = {victim}, and the + // subsequent check passes. + let auth_transfer_instruction = + risc0_zkvm::serde::to_vec(&authenticated_transfer_core::Instruction::Transfer { amount }) + .expect("serialization is infallible"); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![], + vec![], + ) + .with_chained_calls(vec![ChainedCall { + program_id: auth_transfer_id, + pre_states, + instruction_data: auth_transfer_instruction, + pda_seeds: vec![], + }]) + .write(); +} diff --git a/test_program_methods/guest/src/bin/malicious_self_program_id.rs b/test_program_methods/guest/src/bin/malicious_self_program_id.rs new file mode 100644 index 00000000..be447ab9 --- /dev/null +++ b/test_program_methods/guest/src/bin/malicious_self_program_id.rs @@ -0,0 +1,32 @@ +use nssa_core::program::{ + AccountPostState, DEFAULT_PROGRAM_ID, ProgramInput, ProgramOutput, read_nssa_inputs, +}; + +type Instruction = (); + +fn main() { + let ( + ProgramInput { + self_program_id: _, // ignore the correct ID + caller_program_id, + pre_states, + instruction: (), + }, + instruction_words, + ) = read_nssa_inputs::(); + + let post_states = pre_states + .iter() + .map(|a| AccountPostState::new(a.account.clone())) + .collect(); + + // Deliberately output wrong self_program_id + ProgramOutput::new( + DEFAULT_PROGRAM_ID, // WRONG: should be self_program_id + caller_program_id, + instruction_words, + pre_states, + post_states, + ) + .write(); +} diff --git a/test_program_methods/guest/src/bin/minter.rs b/test_program_methods/guest/src/bin/minter.rs index ac29e4d3..1f31ca05 100644 --- a/test_program_methods/guest/src/bin/minter.rs +++ b/test_program_methods/guest/src/bin/minter.rs @@ -3,7 +3,15 @@ use nssa_core::program::{AccountPostState, ProgramInput, ProgramOutput, read_nss type Instruction = (); fn main() { - let (ProgramInput { pre_states, .. }, instruction_words) = read_nssa_inputs::(); + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + .. + }, + instruction_words, + ) = read_nssa_inputs::(); let Ok([pre]) = <[_; 1]>::try_from(pre_states) else { return; @@ -17,6 +25,8 @@ fn main() { .expect("Balance overflow"); ProgramOutput::new( + self_program_id, + caller_program_id, instruction_words, vec![pre], vec![AccountPostState::new(account_post)], diff --git a/test_program_methods/guest/src/bin/missing_output.rs b/test_program_methods/guest/src/bin/missing_output.rs index b485e87a..d7d2778d 100644 --- a/test_program_methods/guest/src/bin/missing_output.rs +++ b/test_program_methods/guest/src/bin/missing_output.rs @@ -3,7 +3,15 @@ use nssa_core::program::{AccountPostState, ProgramInput, ProgramOutput, read_nss type Instruction = (); fn main() { - let (ProgramInput { pre_states, .. }, instruction_words) = read_nssa_inputs::(); + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + .. + }, + instruction_words, + ) = read_nssa_inputs::(); let Ok([pre1, pre2]) = <[_; 2]>::try_from(pre_states) else { return; @@ -12,6 +20,8 @@ fn main() { let account_pre1 = pre1.account.clone(); ProgramOutput::new( + self_program_id, + caller_program_id, instruction_words, vec![pre1, pre2], vec![AccountPostState::new(account_pre1)], diff --git a/test_program_methods/guest/src/bin/modified_transfer.rs b/test_program_methods/guest/src/bin/modified_transfer.rs index a89c72fb..2c05921c 100644 --- a/test_program_methods/guest/src/bin/modified_transfer.rs +++ b/test_program_methods/guest/src/bin/modified_transfer.rs @@ -64,6 +64,8 @@ fn main() { // Read input accounts. let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction: balance_to_move, }, @@ -80,5 +82,12 @@ fn main() { } _ => panic!("invalid params"), }; - ProgramOutput::new(instruction_data, pre_states, post_states).write(); + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_data, + pre_states, + post_states, + ) + .write(); } diff --git a/test_program_methods/guest/src/bin/nonce_changer.rs b/test_program_methods/guest/src/bin/nonce_changer.rs index 0cecdc81..c6e851fe 100644 --- a/test_program_methods/guest/src/bin/nonce_changer.rs +++ b/test_program_methods/guest/src/bin/nonce_changer.rs @@ -3,7 +3,15 @@ use nssa_core::program::{AccountPostState, ProgramInput, ProgramOutput, read_nss type Instruction = (); fn main() { - let (ProgramInput { pre_states, .. }, instruction_words) = read_nssa_inputs::(); + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + .. + }, + instruction_words, + ) = read_nssa_inputs::(); let Ok([pre]) = <[_; 1]>::try_from(pre_states) else { return; @@ -14,6 +22,8 @@ fn main() { account_post.nonce.public_account_nonce_increment(); ProgramOutput::new( + self_program_id, + caller_program_id, instruction_words, vec![pre], vec![AccountPostState::new(account_post)], diff --git a/test_program_methods/guest/src/bin/noop.rs b/test_program_methods/guest/src/bin/noop.rs index 35a07765..fc92aebe 100644 --- a/test_program_methods/guest/src/bin/noop.rs +++ b/test_program_methods/guest/src/bin/noop.rs @@ -3,11 +3,26 @@ use nssa_core::program::{AccountPostState, ProgramInput, ProgramOutput, read_nss type Instruction = (); fn main() { - let (ProgramInput { pre_states, .. }, instruction_words) = read_nssa_inputs::(); + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + .. + }, + instruction_words, + ) = read_nssa_inputs::(); let post_states = pre_states .iter() .map(|account| AccountPostState::new(account.account.clone())) .collect(); - ProgramOutput::new(instruction_words, pre_states, post_states).write(); + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + post_states, + ) + .write(); } diff --git a/test_program_methods/guest/src/bin/pda_claimer.rs b/test_program_methods/guest/src/bin/pda_claimer.rs new file mode 100644 index 00000000..5dec4da4 --- /dev/null +++ b/test_program_methods/guest/src/bin/pda_claimer.rs @@ -0,0 +1,32 @@ +use nssa_core::program::{ + AccountPostState, Claim, PdaSeed, ProgramInput, ProgramOutput, read_nssa_inputs, +}; + +type Instruction = PdaSeed; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: seed, + }, + instruction_words, + ) = read_nssa_inputs::(); + + let Ok([pre]) = <[_; 1]>::try_from(pre_states) else { + return; + }; + + let account_post = AccountPostState::new_claimed(pre.account.clone(), Claim::Pda(seed)); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pre], + vec![account_post], + ) + .write(); +} diff --git a/test_program_methods/guest/src/bin/pda_spend_proxy.rs b/test_program_methods/guest/src/bin/pda_spend_proxy.rs new file mode 100644 index 00000000..4094e101 --- /dev/null +++ b/test_program_methods/guest/src/bin/pda_spend_proxy.rs @@ -0,0 +1,50 @@ +use nssa_core::program::{ + AccountPostState, ChainedCall, PdaSeed, ProgramId, ProgramInput, ProgramOutput, + read_nssa_inputs, +}; +use risc0_zkvm::serde::to_vec; + +/// Proxy for spending from a private PDA via `auth_transfer`. +/// +/// `pre_states = [pda (authorized), recipient]`. Debits the PDA and credits the recipient. +/// The PDA-to-npk binding is established via `pda_seeds` in the chained call to `auth_transfer`. +type Instruction = (PdaSeed, u128, ProgramId); + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: (seed, amount, auth_transfer_id), + }, + instruction_words, + ) = read_nssa_inputs::(); + + let Ok([first, second]) = <[_; 2]>::try_from(pre_states) else { + return; + }; + + assert!(first.is_authorized, "first pre_state must be authorized"); + + let first_post = AccountPostState::new(first.account.clone()); + let second_post = AccountPostState::new(second.account.clone()); + + let chained_call = ChainedCall { + program_id: auth_transfer_id, + instruction_data: to_vec(&authenticated_transfer_core::Instruction::Transfer { amount }) + .unwrap(), + pre_states: vec![first.clone(), second.clone()], + pda_seeds: vec![seed], + }; + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![first, second], + vec![first_post, second_post], + ) + .with_chained_calls(vec![chained_call]) + .write(); +} diff --git a/test_program_methods/guest/src/bin/pinata_cooldown.rs b/test_program_methods/guest/src/bin/pinata_cooldown.rs new file mode 100644 index 00000000..9e8bde3b --- /dev/null +++ b/test_program_methods/guest/src/bin/pinata_cooldown.rs @@ -0,0 +1,116 @@ +//! Cooldown-based pinata program. +//! +//! A Piñata program that uses the on-chain clock to prevent abuse. +//! After each prize claim the program records the current timestamp; the next claim is only +//! allowed once a configurable cooldown period has elapsed. +//! +//! Expected pre-states (in order): +//! 0 - pinata account (authorized, owned by this program) +//! 1 - winner account +//! 2 - clock account `CLOCK_01`. +//! +//! Pinata account data layout (24 bytes): +//! [prize: u64 LE | `cooldown_ms`: u64 LE | `last_claim_timestamp`: u64 LE]. + +use clock_core::{CLOCK_01_PROGRAM_ACCOUNT_ID, ClockAccountData}; +use nssa_core::program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_nssa_inputs}; + +type Instruction = (); + +struct PinataState { + prize: u128, + cooldown_ms: u64, + last_claim_timestamp: u64, +} + +impl PinataState { + fn from_bytes(bytes: &[u8]) -> Self { + assert!(bytes.len() >= 32, "Pinata account data too short"); + let prize = u128::from_le_bytes(bytes[..16].try_into().unwrap()); + let cooldown_ms = u64::from_le_bytes(bytes[16..24].try_into().unwrap()); + let last_claim_timestamp = u64::from_le_bytes(bytes[24..32].try_into().unwrap()); + Self { + prize, + cooldown_ms, + last_claim_timestamp, + } + } + + fn to_bytes(&self) -> Vec { + let mut buf = Vec::with_capacity(32); + buf.extend_from_slice(&self.prize.to_le_bytes()); + buf.extend_from_slice(&self.cooldown_ms.to_le_bytes()); + buf.extend_from_slice(&self.last_claim_timestamp.to_le_bytes()); + buf + } +} + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: (), + }, + instruction_words, + ) = read_nssa_inputs::(); + + let Ok([pinata, winner, clock_pre]) = <[_; 3]>::try_from(pre_states) else { + panic!("Expected exactly 3 input accounts: pinata, winner, clock"); + }; + + // Check the clock account is the system clock account + assert_eq!(clock_pre.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID); + + let clock_data = ClockAccountData::from_bytes(&clock_pre.account.data.clone().into_inner()); + let current_timestamp = clock_data.timestamp; + + let pinata_state = PinataState::from_bytes(&pinata.account.data.clone().into_inner()); + + // Enforce cooldown: the elapsed time since the last claim must exceed the cooldown period. + let elapsed = current_timestamp.saturating_sub(pinata_state.last_claim_timestamp); + assert!( + elapsed >= pinata_state.cooldown_ms, + "Cooldown not elapsed: {elapsed}ms since last claim, need {}ms", + pinata_state.cooldown_ms, + ); + + let mut pinata_post = pinata.account.clone(); + let mut winner_post = winner.account.clone(); + + pinata_post.balance = pinata_post + .balance + .checked_sub(pinata_state.prize) + .expect("Not enough balance in the pinata"); + winner_post.balance = winner_post + .balance + .checked_add(pinata_state.prize) + .expect("Overflow when adding prize to winner"); + + // Update the last claim timestamp. + let updated_state = PinataState { + last_claim_timestamp: current_timestamp, + ..pinata_state + }; + pinata_post.data = updated_state + .to_bytes() + .try_into() + .expect("Pinata state should fit in account data"); + + // Clock account is read-only. + let clock_post = clock_pre.account.clone(); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pinata, winner, clock_pre], + vec![ + AccountPostState::new_claimed_if_default(pinata_post, Claim::Authorized), + AccountPostState::new(winner_post), + AccountPostState::new(clock_post), + ], + ) + .write(); +} diff --git a/test_program_methods/guest/src/bin/private_pda_delegator.rs b/test_program_methods/guest/src/bin/private_pda_delegator.rs new file mode 100644 index 00000000..fe55045e --- /dev/null +++ b/test_program_methods/guest/src/bin/private_pda_delegator.rs @@ -0,0 +1,51 @@ +use nssa_core::program::{ + AccountPostState, ChainedCall, Claim, PdaSeed, ProgramId, ProgramInput, ProgramOutput, + read_nssa_inputs, +}; +use risc0_zkvm::serde::to_vec; + +/// Claims the sole `pre_state` as a PDA with `claim_seed`, then chains to `callee_program_id` +/// delegating authorization with `delegated_seed` in `pda_seeds`. When `claim_seed == +/// delegated_seed` this exercises the happy caller-seeds authorization path for mask-3 private +/// PDAs in `validate_and_sync_states`; when they differ, the callee's mask-3 `pre_state` has +/// no matching authorization source and the circuit must reject. +type Instruction = (PdaSeed, PdaSeed, ProgramId); + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: (claim_seed, delegated_seed, callee_program_id), + }, + instruction_words, + ) = read_nssa_inputs::(); + + let Ok([pre]) = <[_; 1]>::try_from(pre_states) else { + return; + }; + + let claimed = AccountPostState::new_claimed(pre.account.clone(), Claim::Pda(claim_seed)); + + let mut pre_for_callee = pre.clone(); + pre_for_callee.is_authorized = true; + pre_for_callee.account.program_owner = self_program_id; + + let chained_call = ChainedCall { + program_id: callee_program_id, + instruction_data: to_vec(&()).unwrap(), + pre_states: vec![pre_for_callee], + pda_seeds: vec![delegated_seed], + }; + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pre], + vec![claimed], + ) + .with_chained_calls(vec![chained_call]) + .write(); +} diff --git a/test_program_methods/guest/src/bin/program_owner_changer.rs b/test_program_methods/guest/src/bin/program_owner_changer.rs index 7e421351..0282b5cc 100644 --- a/test_program_methods/guest/src/bin/program_owner_changer.rs +++ b/test_program_methods/guest/src/bin/program_owner_changer.rs @@ -3,7 +3,15 @@ use nssa_core::program::{AccountPostState, ProgramInput, ProgramOutput, read_nss type Instruction = (); fn main() { - let (ProgramInput { pre_states, .. }, instruction_words) = read_nssa_inputs::(); + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + .. + }, + instruction_words, + ) = read_nssa_inputs::(); let Ok([pre]) = <[_; 1]>::try_from(pre_states) else { return; @@ -14,6 +22,8 @@ fn main() { account_post.program_owner = [0, 1, 2, 3, 4, 5, 6, 7]; ProgramOutput::new( + self_program_id, + caller_program_id, instruction_words, vec![pre], vec![AccountPostState::new(account_post)], diff --git a/test_program_methods/guest/src/bin/simple_balance_transfer.rs b/test_program_methods/guest/src/bin/simple_balance_transfer.rs index 9ee715e8..f324b371 100644 --- a/test_program_methods/guest/src/bin/simple_balance_transfer.rs +++ b/test_program_methods/guest/src/bin/simple_balance_transfer.rs @@ -5,6 +5,8 @@ type Instruction = u128; fn main() { let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction: balance, }, @@ -27,6 +29,8 @@ fn main() { .expect("Overflow when adding balance"); ProgramOutput::new( + self_program_id, + caller_program_id, instruction_words, vec![sender_pre, receiver_pre], vec![ diff --git a/test_program_methods/guest/src/bin/time_locked_transfer.rs b/test_program_methods/guest/src/bin/time_locked_transfer.rs new file mode 100644 index 00000000..25595661 --- /dev/null +++ b/test_program_methods/guest/src/bin/time_locked_transfer.rs @@ -0,0 +1,72 @@ +//! Time-locked transfer program. +//! +//! Demonstrates how a program can include a clock account among its inputs and use the on-chain +//! timestamp in its logic. The transfer only executes when the clock timestamp is at or past a +//! caller-supplied deadline; otherwise the program panics. +//! +//! Expected pre-states (in order): +//! 0 - sender account (authorized) +//! 1 - receiver account +//! 2 - clock account (read-only, e.g. `CLOCK_01`). + +use clock_core::{CLOCK_01_PROGRAM_ACCOUNT_ID, ClockAccountData}; +use nssa_core::program::{AccountPostState, ProgramInput, ProgramOutput, read_nssa_inputs}; + +/// (`amount`, `deadline_timestamp`). +type Instruction = (u128, u64); + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: (amount, deadline), + }, + instruction_words, + ) = read_nssa_inputs::(); + + let Ok([sender_pre, receiver_pre, clock_pre]) = <[_; 3]>::try_from(pre_states) else { + panic!("Expected exactly 3 input accounts: sender, receiver, clock"); + }; + + // Check the clock account is the system clock account + assert_eq!(clock_pre.account_id, CLOCK_01_PROGRAM_ACCOUNT_ID); + + // Read the current timestamp from the clock account. + let clock_data = ClockAccountData::from_bytes(&clock_pre.account.data.clone().into_inner()); + + assert!( + clock_data.timestamp >= deadline, + "Transfer is time-locked until timestamp {deadline}, current is {}", + clock_data.timestamp, + ); + + let mut sender_post = sender_pre.account.clone(); + let mut receiver_post = receiver_pre.account.clone(); + + sender_post.balance = sender_post + .balance + .checked_sub(amount) + .expect("Insufficient balance"); + receiver_post.balance = receiver_post + .balance + .checked_add(amount) + .expect("Balance overflow"); + + // Clock account is read-only: post state equals pre state. + let clock_post = clock_pre.account.clone(); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![sender_pre, receiver_pre, clock_pre], + vec![ + AccountPostState::new(sender_post), + AccountPostState::new(receiver_post), + AccountPostState::new(clock_post), + ], + ) + .write(); +} diff --git a/test_program_methods/guest/src/bin/two_pda_claimer.rs b/test_program_methods/guest/src/bin/two_pda_claimer.rs new file mode 100644 index 00000000..53aae666 --- /dev/null +++ b/test_program_methods/guest/src/bin/two_pda_claimer.rs @@ -0,0 +1,37 @@ +use nssa_core::program::{ + AccountPostState, Claim, PdaSeed, ProgramInput, ProgramOutput, read_nssa_inputs, +}; + +/// Claims two `pre_states` under the same `seed`. Used to exercise the tx-wide +/// `(program_id, seed) → AccountId` family-binding check: when both `pre_states` are mask-3 +/// with different npks, each `Claim::Pda(seed)` resolves to a different `AccountId` under the +/// same `(program, seed)` key, and the circuit must reject. +type Instruction = PdaSeed; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: seed, + }, + instruction_words, + ) = read_nssa_inputs::(); + + let Ok([pre_a, pre_b]) = <[_; 2]>::try_from(pre_states) else { + return; + }; + + let claim_a = AccountPostState::new_claimed(pre_a.account.clone(), Claim::Pda(seed)); + let claim_b = AccountPostState::new_claimed(pre_b.account.clone(), Claim::Pda(seed)); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pre_a, pre_b], + vec![claim_a, claim_b], + ) + .write(); +} diff --git a/test_program_methods/guest/src/bin/validity_window.rs b/test_program_methods/guest/src/bin/validity_window.rs index a0ff9f36..03100e8e 100644 --- a/test_program_methods/guest/src/bin/validity_window.rs +++ b/test_program_methods/guest/src/bin/validity_window.rs @@ -8,6 +8,8 @@ type Instruction = (BlockValidityWindow, TimestampValidityWindow); fn main() { let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction: (block_validity_window, timestamp_validity_window), }, @@ -21,6 +23,8 @@ fn main() { let post = pre.account.clone(); ProgramOutput::new( + self_program_id, + caller_program_id, instruction_words, vec![pre], vec![AccountPostState::new(post)], diff --git a/test_program_methods/guest/src/bin/validity_window_chain_caller.rs b/test_program_methods/guest/src/bin/validity_window_chain_caller.rs index 39f8ad69..212418a2 100644 --- a/test_program_methods/guest/src/bin/validity_window_chain_caller.rs +++ b/test_program_methods/guest/src/bin/validity_window_chain_caller.rs @@ -16,6 +16,8 @@ type Instruction = (BlockValidityWindow, ProgramId, BlockValidityWindow); fn main() { let ( ProgramInput { + self_program_id, + caller_program_id, pre_states, instruction: (block_validity_window, chained_program_id, chained_block_validity_window), }, @@ -38,6 +40,8 @@ fn main() { }; ProgramOutput::new( + self_program_id, + caller_program_id, instruction_words, vec![pre], vec![AccountPostState::new(post)], diff --git a/testnet_initial_state/src/lib.rs b/testnet_initial_state/src/lib.rs index 6224d71e..668d5f24 100644 --- a/testnet_initial_state/src/lib.rs +++ b/testnet_initial_state/src/lib.rs @@ -1,6 +1,7 @@ use common::PINATA_BASE58; use key_protocol::key_management::{ KeyChain, + key_tree::chain_index::ChainIndex, secret_holders::{PrivateKeyHolder, SecretSpendingKey}, }; use nssa::{Account, AccountId, Data, PrivateKey, PublicKey, V03State}; @@ -95,9 +96,20 @@ pub struct PublicAccountPrivateInitialData { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PrivateAccountPrivateInitialData { - pub account_id: nssa::AccountId, pub account: nssa_core::account::Account, pub key_chain: KeyChain, + pub chain_index: Option, + pub identifier: nssa_core::Identifier, +} + +impl PrivateAccountPrivateInitialData { + #[must_use] + pub fn account_id(&self) -> nssa::AccountId { + nssa::AccountId::for_regular_private_account( + &self.key_chain.nullifier_public_key, + self.identifier, + ) + } } #[must_use] @@ -142,7 +154,6 @@ pub fn initial_priv_accounts_private_keys() -> Vec Vec Vec Vec { initial_priv_accounts_private_keys() .into_iter() .map(|data| PrivateAccountPublicInitialData { - npk: data.key_chain.nullifier_public_key.clone(), + npk: data.key_chain.nullifier_public_key, account: data.account, }) .collect() @@ -196,25 +210,30 @@ pub fn initial_accounts() -> Vec { #[must_use] pub fn initial_state() -> V03State { - let initial_commitments: Vec = initial_commitments() - .iter() - .map(|init_comm_data| { - let npk = &init_comm_data.npk; + let initial_private_accounts: Vec<(nssa_core::Commitment, nssa_core::Nullifier)> = + initial_commitments() + .iter() + .map(|init_comm_data| { + let npk = &init_comm_data.npk; + let account_id = nssa::AccountId::for_regular_private_account(npk, 0); - let mut acc = init_comm_data.account.clone(); + let mut acc = init_comm_data.account.clone(); - acc.program_owner = nssa::program::Program::authenticated_transfer_program().id(); + acc.program_owner = nssa::program::Program::authenticated_transfer_program().id(); - nssa_core::Commitment::new(npk, &acc) - }) - .collect(); + ( + nssa_core::Commitment::new(&account_id, &acc), + nssa_core::Nullifier::for_account_initialization(&account_id), + ) + }) + .collect(); let init_accs: Vec<(nssa::AccountId, u128)> = initial_accounts() .iter() .map(|acc_data| (acc_data.account_id, acc_data.balance)) .collect(); - nssa::V03State::new_with_genesis_accounts(&init_accs, &initial_commitments) + nssa::V03State::new_with_genesis_accounts(&init_accs, initial_private_accounts, 0) } #[must_use] @@ -235,8 +254,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 = "5ya25h4Xc9GAmrGB2WrTEnEWtQKJwRwQx3Xfo2tucNcE"; - const PRIV_ACC_B_TEXT_ADDR: &str = "E8HwiTyQe4H9HK7icTvn95HQMnzx49mP9A2ddtMLpNaN"; + const PRIV_ACC_A_TEXT_ADDR: &str = "4eGX3M3rgjHsme8n3sSp89af8JRZtYVTesbJjLqaX1VQ"; + const PRIV_ACC_B_TEXT_ADDR: &str = "3m6HQmCgmAvsxZtxAHPqqEqoBG4335fCG8TzxigyW7rE"; #[test] fn pub_state_consistency() { @@ -350,11 +369,11 @@ mod tests { ); assert_eq!( - init_private_accs_keys[0].account_id.to_string(), + init_private_accs_keys[0].account_id().to_string(), PRIV_ACC_A_TEXT_ADDR ); assert_eq!( - init_private_accs_keys[1].account_id.to_string(), + init_private_accs_keys[1].account_id().to_string(), PRIV_ACC_B_TEXT_ADDR ); diff --git a/tools/crypto_primitives_bench/Cargo.toml b/tools/crypto_primitives_bench/Cargo.toml new file mode 100644 index 00000000..8e99f79f --- /dev/null +++ b/tools/crypto_primitives_bench/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "crypto_primitives_bench" +version = "0.1.0" +edition = "2024" +license = { workspace = true } +publish = false + +[lints] +workspace = true + +[dev-dependencies] +key_protocol.workspace = true +nssa_core = { workspace = true, features = ["host"] } +rand = { workspace = true } +criterion.workspace = true + +[[bench]] +name = "primitives" +harness = false diff --git a/tools/crypto_primitives_bench/README.md b/tools/crypto_primitives_bench/README.md new file mode 100644 index 00000000..eb2da149 --- /dev/null +++ b/tools/crypto_primitives_bench/README.md @@ -0,0 +1,29 @@ +# crypto_primitives_bench + +Criterion-driven microbenchmarks for the cryptographic primitives client/wallet code uses on every transaction. No live sequencer or Bedrock needed. + +## Run + +```sh +cargo bench -p crypto_primitives_bench --bench primitives +``` + +## What you'll see + +Criterion's per-operation report (point estimate, 95% CI, outlier counts) for: + +- `keychain/new_os_random`: full mnemonic → SSK → NSK/VSK + public-key derivation (HMAC-SHA512 PBKDF dominates). +- `keychain/new_mnemonic`: same pipeline, mnemonic exposed. +- `shared_secret_key/sender_dh`: secp256k1 ECDH per recipient (includes ephemeral key gen). +- `encryption/encrypt` / `decrypt`: ChaCha20 over an Account note. + +Per-bench JSON estimates are written under `target/criterion///`. HTML reports at `target/criterion/report/index.html`. + +## Baseline comparison + +```sh +# On main: +cargo bench -p crypto_primitives_bench --bench primitives -- --save-baseline main +# On your branch: +cargo bench -p crypto_primitives_bench --bench primitives -- --baseline main +``` diff --git a/tools/crypto_primitives_bench/benches/primitives.rs b/tools/crypto_primitives_bench/benches/primitives.rs new file mode 100644 index 00000000..cfa0858f --- /dev/null +++ b/tools/crypto_primitives_bench/benches/primitives.rs @@ -0,0 +1,91 @@ +//! Criterion microbenchmarks for client/wallet cryptographic primitives. +//! +//! Measures: +//! - `KeyChain::new_os_random` (mnemonic → SSK → NSK/VSK + public keys) +//! - `KeyChain::new_mnemonic` (same, but mnemonic exposed) +//! - `SharedSecretKey::new` (Diffie-Hellman shared key derivation, the per-recipient cost) +//! - `EncryptionScheme::encrypt` / `decrypt` (Account note encryption) + +use std::time::Duration; + +use criterion::{Criterion, criterion_group, criterion_main}; +use key_protocol::key_management::KeyChain; +use nssa_core::{ + Commitment, EncryptionScheme, SharedSecretKey, + account::{Account, AccountId}, + encryption::{EphemeralPublicKey, EphemeralSecretKey}, + program::PrivateAccountKind, +}; +use rand::{RngCore as _, rngs::OsRng}; + +fn bench_keychain(c: &mut Criterion) { + let mut g = c.benchmark_group("keychain"); + g.sample_size(50).noise_threshold(0.05); + g.bench_function("new_os_random", |b| b.iter(KeyChain::new_os_random)); + g.bench_function("new_mnemonic", |b| { + b.iter(|| { + let (_kc, _mnemonic) = KeyChain::new_mnemonic(""); + }); + }); + g.finish(); +} + +fn bench_shared_secret_key(c: &mut Criterion) { + // One-time setup: recipient's viewing public key (sender side bench). + let recipient_kc = KeyChain::new_os_random(); + let vpk = recipient_kc.viewing_public_key; + + let mut g = c.benchmark_group("shared_secret_key"); + g.sample_size(50).noise_threshold(0.05); + g.bench_function("sender_dh", |b| { + b.iter(|| { + let mut bytes = [0_u8; 32]; + OsRng.fill_bytes(&mut bytes); + let esk: EphemeralSecretKey = bytes; + let _epk = EphemeralPublicKey::from(&esk); + SharedSecretKey::new(esk, &vpk) + }); + }); + g.finish(); +} + +fn bench_encryption(c: &mut Criterion) { + // One-time setup: a fixed Account/Commitment and a SharedSecretKey to bench + // encrypt/decrypt over a representative note. ESK gen is excluded from the + // measured loop (covered by the SharedSecretKey bench above). + let recipient_kc = KeyChain::new_os_random(); + let vpk = recipient_kc.viewing_public_key; + let npk = recipient_kc.nullifier_public_key; + let account = Account::default(); + let account_id = AccountId::for_regular_private_account(&npk, 0); + let commitment = Commitment::new(&account_id, &account); + let shared = { + let mut bytes = [0_u8; 32]; + OsRng.fill_bytes(&mut bytes); + let esk: EphemeralSecretKey = bytes; + SharedSecretKey::new(esk, &vpk) + }; + let kind = PrivateAccountKind::Regular(0_u128); + let output_index: u32 = 0; + + let mut g = c.benchmark_group("encryption"); + g.sample_size(50).noise_threshold(0.05); + g.bench_function("encrypt", |b| { + b.iter(|| EncryptionScheme::encrypt(&account, &kind, &shared, &commitment, output_index)); + }); + // One ciphertext for the decrypt bench (encrypt is deterministic given inputs). + let ct = EncryptionScheme::encrypt(&account, &kind, &shared, &commitment, output_index); + g.bench_function("decrypt", |b| { + b.iter(|| EncryptionScheme::decrypt(&ct, &shared, &commitment, output_index)); + }); + g.finish(); +} + +criterion_group! { + name = benches; + config = Criterion::default() + .warm_up_time(Duration::from_secs(2)) + .measurement_time(Duration::from_secs(10)); + targets = bench_keychain, bench_shared_secret_key, bench_encryption +} +criterion_main!(benches); diff --git a/tools/cycle_bench/Cargo.toml b/tools/cycle_bench/Cargo.toml new file mode 100644 index 00000000..13ea0023 --- /dev/null +++ b/tools/cycle_bench/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "cycle_bench" +version = "0.1.0" +edition = "2024" +license = { workspace = true } +publish = false + +[lints] +workspace = true + +[features] +default = [] +prove = ["nssa/prove", "risc0-zkvm/prove"] +ppe = ["prove"] + +[dependencies] +nssa = { workspace = true } +nssa_core = { workspace = true, features = ["host"] } +authenticated_transfer_core.workspace = true +clock_core.workspace = true +token_core.workspace = true +amm_core.workspace = true +ata_core.workspace = true + +risc0-zkvm.workspace = true +borsh.workspace = true +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +clap = { workspace = true } + +[dev-dependencies] +criterion.workspace = true + +[[bench]] +name = "verify" +harness = false +required-features = ["ppe"] diff --git a/tools/cycle_bench/README.md b/tools/cycle_bench/README.md new file mode 100644 index 00000000..7f3b3d95 --- /dev/null +++ b/tools/cycle_bench/README.md @@ -0,0 +1,44 @@ +# cycle_bench + +Per-program Risc0 cycle counts, prover wall time, PPE composition cost, and verifier wall time for the built-in LEZ programs. Feeds the fee model (`G_executor`, `G_prove`, `G_verify`, `S_agg`). + +## Run + +The binary handles executor cycles, prover wall time, and PPE composition cost: + +```sh +# Executor cycles only (fast, ~seconds) +cargo run --release -p cycle_bench + +# + real proving per program (slow, ~minutes) +cargo run --release -p cycle_bench --features prove -- --prove + +# + PPE composition cases (very slow, ~hour) +cargo run --release -p cycle_bench --features ppe -- --prove --ppe +``` + +The verifier microbenchmark (`G_verify`) lives in a criterion bench under `benches/verify.rs`: + +```sh +# Generates one PPE receipt for auth_transfer Transfer (~minutes of setup), +# then times Receipt::verify under criterion's statistical sampler. +cargo bench -p cycle_bench --features ppe --bench verify +``` + +`RISC0_DEV_MODE=1` skips proving entirely and is only useful for the executor path. The bin writes to `target/cycle_bench.json`; criterion writes per-bench estimates under `target/criterion/`. + +## What you'll see + +- Per-program executor cycles and segments, plus exec wall time as `best / mean ± stdev (n=N)`. +- With `--prove`: prover total cycles, paging cycles, segments, and wall time. +- With `--ppe`: end-to-end `execute_and_prove` wall time and `S_agg` (the borsh-serialized InnerReceipt length) for one auth-transfer-in-PPE case and a chain-caller depth sweep. +- From the `verify` criterion bench: `ppe/verify_auth_transfer` slope-regression point estimate with 95% CI bounds. + +## Baseline comparison (verify bench) + +```sh +# On main: +cargo bench -p cycle_bench --features ppe --bench verify -- --save-baseline main +# On your branch: +cargo bench -p cycle_bench --features ppe --bench verify -- --baseline main +``` diff --git a/tools/cycle_bench/benches/verify.rs b/tools/cycle_bench/benches/verify.rs new file mode 100644 index 00000000..d7bdfbe3 --- /dev/null +++ b/tools/cycle_bench/benches/verify.rs @@ -0,0 +1,47 @@ +//! Criterion bench for `Receipt::verify(PRIVACY_PRESERVING_CIRCUIT_ID)`. +//! +//! Produces the `G_verify` fee-model parameter. Setup: one full PPE prove of an +//! `auth_transfer` Transfer (minutes, runs once outside the timed loop). Measured +//! op: `Receipt::verify` over a real PPE receipt. +//! +//! Run with: `cargo bench -p cycle_bench --features ppe --bench verify`. + +use std::{hint::black_box, time::Duration}; + +use anyhow::Context as _; +use criterion::{Criterion, criterion_group, criterion_main}; +use cycle_bench::ppe::prove_auth_transfer_in_ppe; +use nssa::program_methods::PRIVACY_PRESERVING_CIRCUIT_ID; +use risc0_zkvm::{InnerReceipt, Receipt}; + +fn bench_verify(c: &mut Criterion) { + let (output, proof) = prove_auth_transfer_in_ppe().expect("prove auth_transfer in PPE"); + let journal = output.to_bytes(); + let proof_bytes = proof.into_inner(); + let inner: InnerReceipt = borsh::from_slice(&proof_bytes) + .context("decode InnerReceipt") + .expect("InnerReceipt deserialize"); + let receipt = Receipt::new(inner, journal); + + // Sanity check before the timed loop. + receipt + .verify(PRIVACY_PRESERVING_CIRCUIT_ID) + .expect("verify sanity check"); + + let mut g = c.benchmark_group("ppe"); + g.sample_size(100) + .warm_up_time(Duration::from_secs(2)) + .measurement_time(Duration::from_secs(15)) + .noise_threshold(0.05); + g.bench_function("verify_auth_transfer", |b| { + b.iter(|| { + receipt + .verify(black_box(PRIVACY_PRESERVING_CIRCUIT_ID)) + .expect("verify failed mid-loop"); + }); + }); + g.finish(); +} + +criterion_group!(benches, bench_verify); +criterion_main!(benches); diff --git a/tools/cycle_bench/src/lib.rs b/tools/cycle_bench/src/lib.rs new file mode 100644 index 00000000..7091cf84 --- /dev/null +++ b/tools/cycle_bench/src/lib.rs @@ -0,0 +1,23 @@ +//! `cycle_bench` library: per-program executor/prover cycle measurement helpers +//! shared between the `cycle_bench` binary and the `verify` criterion bench. + +#![expect( + clippy::arithmetic_side_effects, + clippy::as_conversions, + clippy::cast_precision_loss, + clippy::float_arithmetic, + clippy::print_literal, + clippy::print_stdout, + reason = "Bench library: stats arithmetic and table printing are bench-style" +)] +#![cfg_attr( + feature = "ppe", + expect( + clippy::arbitrary_source_item_ordering, + clippy::print_stderr, + reason = "PPE module: re-export ordering and eprintln progress trip strict lints" + ) +)] + +pub mod ppe; +pub mod stats; diff --git a/tools/cycle_bench/src/main.rs b/tools/cycle_bench/src/main.rs new file mode 100644 index 00000000..bb44fb4f --- /dev/null +++ b/tools/cycle_bench/src/main.rs @@ -0,0 +1,597 @@ +//! Measures Risc0 user cycles per built-in program instruction. +//! +//! Runs each guest ELF through the Risc0 executor (no proving) with realistic inputs +//! drawn from the existing per-program unit tests, then prints a table and writes a +//! JSON dump for regression comparison. +//! +//! Run with `cargo run --release -p cycle_bench`. `RISC0_DEV_MODE` has no effect on +//! executor cycle counts. + +#![expect( + clippy::arithmetic_side_effects, + clippy::float_arithmetic, + clippy::missing_const_for_fn, + clippy::non_ascii_literal, + clippy::print_stderr, + clippy::print_stdout, + reason = "Bench tool: matches test-style fixture code" +)] + +use std::{path::PathBuf, time::Instant}; + +use amm_core::{PoolDefinition, compute_liquidity_token_pda, compute_pool_pda, compute_vault_pda}; +use anyhow::Result; +use ata_core::{compute_ata_seed, get_associated_token_account_id}; +use clap::Parser; +use clock_core::{ + CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID, + ClockAccountData, +}; +use cycle_bench::{ppe, stats::Stats}; +use nssa::program_methods::{ + AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID, + AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, CLOCK_ELF, CLOCK_ID, TOKEN_ELF, + TOKEN_ID, +}; +use nssa_core::{ + Timestamp, + account::{Account, AccountId, AccountWithMetadata, Data}, + program::{InstructionData, ProgramId}, +}; +use risc0_zkvm::{ExecutorEnv, default_executor, default_prover}; +use serde::Serialize; +use token_core::{TokenDefinition, TokenHolding}; + +#[derive(Parser, Debug)] +#[command(about = "Per-program executor and (optionally) prover cycle measurements")] +struct Cli { + /// Also run prover.prove for each case and report wall time + cycles. Slow. + #[arg(long)] + prove: bool, + + /// Also run privacy-preserving execution circuit (PPE) composition cases: + /// (a) single `auth_transfer` Transfer through `execute_and_prove`, (b) `chain_caller` + /// with depth N=1,3,5,9. Requires --features ppe at build time. Very slow. + #[arg(long)] + ppe: bool, + + /// Iterations for executor wall-time sampling per case. First iter is + /// discarded as warmup, remaining N feed the stats. + #[arg(long, default_value_t = 5)] + exec_iters: usize, +} + +#[derive(Debug, Serialize)] +struct BenchResult { + program: &'static str, + instruction: &'static str, + user_cycles: u64, + segments: usize, + exec_stats: Stats, + /// Stats over prover.prove(env, elf) wall-clock samples. Only populated when --prove is set. + /// Single-sample (n=1) when --prove is on without explicit repetition, since proving is slow. + prove_stats: Option, + /// Total cycles (with continuation overhead, paging, po2 padding) from ProveInfo.stats. + prove_total_cycles: Option, + /// User cycles from ProveInfo.stats (should match executor cycles). + prove_user_cycles: Option, + /// Paging cycles from ProveInfo.stats. + prove_paging_cycles: Option, + /// Segments from ProveInfo.stats. + prove_segments: Option, +} + +struct Case { + program: &'static str, + instruction_label: &'static str, + elf: &'static [u8], + self_program_id: ProgramId, + pre_states: Vec, + instruction_words: InstructionData, +} + +impl Case { + fn new( + program: &'static str, + instruction_label: &'static str, + elf: &'static [u8], + self_program_id: ProgramId, + pre_states: Vec, + instruction: &I, + ) -> Result { + Ok(Self { + program, + instruction_label, + elf, + self_program_id, + pre_states, + instruction_words: risc0_zkvm::serde::to_vec(instruction)?, + }) + } + + fn run(self, prove: bool, exec_iters: usize) -> Result { + let Self { + program, + instruction_label, + elf, + self_program_id, + pre_states, + instruction_words, + } = self; + let caller_program_id: Option = None; + + // One warmup pass discarded, then `exec_iters` samples. The executor has + // large per-call setup overhead (ELF parsing, env init); reporting both + // best-of-N and mean ± stdev shows whether jitter is significant. + let mut samples: Vec = Vec::with_capacity(exec_iters); + let mut last_info = None; + let total = exec_iters.saturating_add(1).max(2); + for iter in 0..total { + let mut env_builder = ExecutorEnv::builder(); + env_builder + .write(&self_program_id)? + .write(&caller_program_id)? + .write(&pre_states)? + .write(&instruction_words)?; + let env = env_builder.build()?; + + let started = Instant::now(); + let info = default_executor().execute(env, elf)?; + let elapsed_ms = started.elapsed().as_secs_f64() * 1_000.0; + + if iter > 0 { + samples.push(elapsed_ms); + } + last_info = Some(info); + } + let info = last_info.expect("at least one iteration"); + let exec_stats = Stats::from_samples(&samples); + + let mut prove_stats = None; + let mut prove_total_cycles = None; + let mut prove_user_cycles = None; + let mut prove_paging_cycles = None; + let mut prove_segments = None; + if prove { + let mut env_builder = ExecutorEnv::builder(); + env_builder + .write(&self_program_id)? + .write(&caller_program_id)? + .write(&pre_states)? + .write(&instruction_words)?; + let env = env_builder.build()?; + + let started = Instant::now(); + let prove_info = default_prover() + .prove(env, elf) + .map_err(|e| anyhow::anyhow!("prove failed: {e}"))?; + let prove_ms = started.elapsed().as_secs_f64() * 1_000.0; + prove_stats = Some(Stats::from_samples(&[prove_ms])); + prove_total_cycles = Some(prove_info.stats.total_cycles); + prove_user_cycles = Some(prove_info.stats.user_cycles); + prove_paging_cycles = Some(prove_info.stats.paging_cycles); + prove_segments = Some(prove_info.stats.segments); + eprintln!( + " prove({program}/{instruction_label}): {prove_ms:.1} ms ({:.1}s), total_cycles={}, segments={}", + prove_ms / 1_000.0, + prove_info.stats.total_cycles, + prove_info.stats.segments, + ); + } + + Ok(BenchResult { + program, + instruction: instruction_label, + user_cycles: info.cycles(), + segments: info.segments.len(), + exec_stats, + prove_stats, + prove_total_cycles, + prove_user_cycles, + prove_paging_cycles, + prove_segments, + }) + } +} + +fn authenticated_transfer_init() -> Vec { + vec![AccountWithMetadata { + account: Account::default(), + is_authorized: true, + account_id: AccountId::new([1; 32]), + }] +} + +fn authenticated_transfer_transfer() -> Vec { + let sender = AccountWithMetadata { + account: Account { + balance: 1_000_000, + ..Account::default() + }, + is_authorized: true, + account_id: AccountId::new([1; 32]), + }; + let recipient = AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: AccountId::new([2; 32]), + }; + vec![sender, recipient] +} + +fn token_holding( + definition_id: AccountId, + account_id: AccountId, + balance: u128, + is_authorized: bool, +) -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: TOKEN_ID, + balance: 0, + data: Data::from(&TokenHolding::Fungible { + definition_id, + balance, + }), + nonce: 0_u128.into(), + }, + is_authorized, + account_id, + } +} + +fn token_definition( + account_id: AccountId, + total_supply: u128, + is_authorized: bool, +) -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: TOKEN_ID, + balance: 0, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("test"), + total_supply, + metadata_id: None, + }), + nonce: 0_u128.into(), + }, + is_authorized, + account_id, + } +} + +fn token_transfer_pre_states() -> Vec { + let def = AccountId::new([15; 32]); + let sender = token_holding(def, AccountId::new([17; 32]), 100_000, true); + let recipient = token_holding(def, AccountId::new([42; 32]), 50_000, true); + vec![sender, recipient] +} + +fn token_mint_pre_states() -> Vec { + let def_id = AccountId::new([15; 32]); + let def = token_definition(def_id, 100_000, true); + let holding = token_holding(def_id, AccountId::new([17; 32]), 1_000, true); + vec![def, holding] +} + +fn token_burn_pre_states() -> Vec { + let def_id = AccountId::new([15; 32]); + let def = token_definition(def_id, 100_000, true); + let holding = token_holding(def_id, AccountId::new([17; 32]), 1_000, true); + vec![def, holding] +} + +fn clock_account(account_id: AccountId, block_id: u64) -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: CLOCK_ID, + balance: 0, + data: ClockAccountData { + block_id, + timestamp: Timestamp::from(0_u64), + } + .to_bytes() + .try_into() + .expect("ClockAccountData should fit in account data"), + nonce: 0_u128.into(), + }, + is_authorized: false, + account_id, + } +} + +fn clock_pre_states_tick_at(block_id: u64) -> Vec { + vec![ + clock_account(CLOCK_01_PROGRAM_ACCOUNT_ID, block_id), + clock_account(CLOCK_10_PROGRAM_ACCOUNT_ID, block_id), + clock_account(CLOCK_50_PROGRAM_ACCOUNT_ID, block_id), + ] +} + +fn amm_token_a_def_id() -> AccountId { + AccountId::new([42; 32]) +} +fn amm_token_b_def_id() -> AccountId { + AccountId::new([43; 32]) +} +fn amm_pool_id() -> AccountId { + compute_pool_pda(AMM_ID, amm_token_a_def_id(), amm_token_b_def_id()) +} +fn amm_vault_a_id() -> AccountId { + compute_vault_pda(AMM_ID, amm_pool_id(), amm_token_a_def_id()) +} +fn amm_vault_b_id() -> AccountId { + compute_vault_pda(AMM_ID, amm_pool_id(), amm_token_b_def_id()) +} +fn amm_lp_def_id() -> AccountId { + compute_liquidity_token_pda(AMM_ID, amm_pool_id()) +} + +/// Pool seeded with reserves `1_000` / `500`, lp supply `sqrt(1000*500) = 707`. +fn amm_pool_account() -> AccountWithMetadata { + let reserve_a: u128 = 1_000; + let reserve_b: u128 = 500; + let lp_supply = (reserve_a * reserve_b).isqrt(); + AccountWithMetadata { + account: Account { + program_owner: AMM_ID, + balance: 0, + data: Data::from(&PoolDefinition { + definition_token_a_id: amm_token_a_def_id(), + definition_token_b_id: amm_token_b_def_id(), + vault_a_id: amm_vault_a_id(), + vault_b_id: amm_vault_b_id(), + liquidity_pool_id: amm_lp_def_id(), + liquidity_pool_supply: lp_supply, + reserve_a, + reserve_b, + fees: 0, + active: true, + }), + nonce: 0_u128.into(), + }, + is_authorized: true, + account_id: amm_pool_id(), + } +} + +fn amm_swap_pre_states() -> Vec { + let pool = amm_pool_account(); + let vault_a = token_holding(amm_token_a_def_id(), amm_vault_a_id(), 1_000, true); + let vault_b = token_holding(amm_token_b_def_id(), amm_vault_b_id(), 500, true); + let user_a = token_holding(amm_token_a_def_id(), AccountId::new([45; 32]), 1_000, true); + let user_b = token_holding(amm_token_b_def_id(), AccountId::new([46; 32]), 500, false); + vec![pool, vault_a, vault_b, user_a, user_b] +} + +fn amm_add_liquidity_pre_states() -> Vec { + let pool = amm_pool_account(); + let vault_a = token_holding(amm_token_a_def_id(), amm_vault_a_id(), 1_000, true); + let vault_b = token_holding(amm_token_b_def_id(), amm_vault_b_id(), 500, true); + let lp_supply = (1_000_u128 * 500_u128).isqrt(); + let lp_def = token_definition(amm_lp_def_id(), lp_supply, true); + let user_a = token_holding(amm_token_a_def_id(), AccountId::new([45; 32]), 1_000, true); + let user_b = token_holding(amm_token_b_def_id(), AccountId::new([46; 32]), 500, true); + let user_lp = token_holding(amm_lp_def_id(), AccountId::new([47; 32]), 0, true); + vec![pool, vault_a, vault_b, lp_def, user_a, user_b, user_lp] +} + +fn ata_create_pre_states() -> Vec { + let owner_id = AccountId::new([91; 32]); + let definition_id = AccountId::new([15; 32]); + let owner = AccountWithMetadata { + account: Account::default(), + is_authorized: true, + account_id: owner_id, + }; + let token_def = token_definition(definition_id, 100_000, false); + let seed = compute_ata_seed(owner_id, definition_id); + let ata_id = get_associated_token_account_id(&ASSOCIATED_TOKEN_ACCOUNT_ID, &seed); + let ata_account = AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: ata_id, + }; + vec![owner, token_def, ata_account] +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let prove = cli.prove; + let exec_iters = cli.exec_iters.max(1); + if prove { + eprintln!("cycle_bench: prove mode ON, this will be slow (~minutes per program)"); + } + + let cases = [ + Case::new( + "authenticated_transfer", + "Transfer", + AUTHENTICATED_TRANSFER_ELF, + AUTHENTICATED_TRANSFER_ID, + authenticated_transfer_transfer(), + &authenticated_transfer_core::Instruction::Transfer { amount: 5_000 }, + )?, + Case::new( + "authenticated_transfer", + "Initialize", + AUTHENTICATED_TRANSFER_ELF, + AUTHENTICATED_TRANSFER_ID, + authenticated_transfer_init(), + &authenticated_transfer_core::Instruction::Initialize, + )?, + Case::new( + "token", + "Transfer", + TOKEN_ELF, + TOKEN_ID, + token_transfer_pre_states(), + &token_core::Instruction::Transfer { + amount_to_transfer: 5_000, + }, + )?, + Case::new( + "token", + "Mint", + TOKEN_ELF, + TOKEN_ID, + token_mint_pre_states(), + &token_core::Instruction::Mint { + amount_to_mint: 5_000, + }, + )?, + Case::new( + "token", + "Burn", + TOKEN_ELF, + TOKEN_ID, + token_burn_pre_states(), + &token_core::Instruction::Burn { + amount_to_burn: 500, + }, + )?, + Case::new( + "clock", + "Tick (block_id+1, no multiples)", + CLOCK_ELF, + CLOCK_ID, + clock_pre_states_tick_at(0), + &Timestamp::from(1_700_000_000_u64), + )?, + Case::new( + "amm", + "SwapExactInput", + AMM_ELF, + AMM_ID, + amm_swap_pre_states(), + &amm_core::Instruction::SwapExactInput { + swap_amount_in: 200, + min_amount_out: 1, + token_definition_id_in: amm_token_a_def_id(), + }, + )?, + Case::new( + "amm", + "AddLiquidity", + AMM_ELF, + AMM_ID, + amm_add_liquidity_pre_states(), + &amm_core::Instruction::AddLiquidity { + min_amount_liquidity: 1, + max_amount_to_add_token_a: 400, + max_amount_to_add_token_b: 200, + }, + )?, + Case::new( + "ata", + "Create", + ASSOCIATED_TOKEN_ACCOUNT_ELF, + ASSOCIATED_TOKEN_ACCOUNT_ID, + ata_create_pre_states(), + &ata_core::Instruction::Create { + ata_program_id: ASSOCIATED_TOKEN_ACCOUNT_ID, + }, + )?, + ]; + + let results: Vec = cases + .into_iter() + .map(|c| c.run(prove, exec_iters)) + .collect::>>()?; + + print_table(&results, prove); + + #[cfg(feature = "ppe")] + let ppe_results = if cli.ppe { ppe::run_all() } else { Vec::new() }; + #[cfg(not(feature = "ppe"))] + let ppe_results: Vec = { + if cli.ppe { + eprintln!("cycle_bench: --ppe requires --features ppe at build time. Ignoring."); + } + Vec::new() + }; + if !ppe_results.is_empty() { + ppe::print_table(&ppe_results); + } + + let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize()?; + let out_path = workspace_root.join("target").join("cycle_bench.json"); + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent)?; + } + let combined = serde_json::json!({ + "standalone": results, + "ppe": ppe_results, + }); + std::fs::write(&out_path, serde_json::to_string_pretty(&combined)?)?; + println!("\nJSON written to {}", out_path.display()); + + Ok(()) +} + +fn print_table(results: &[BenchResult], prove: bool) { + let pw = results + .iter() + .map(|r| r.program.len()) + .max() + .unwrap_or(0) + .max("program".len()); + let iw = results + .iter() + .map(|r| r.instruction.len()) + .max() + .unwrap_or(0) + .max("instruction".len()); + let cw = 12_usize; + let sw = 8_usize; + let exec_w = results + .iter() + .map(|r| r.exec_stats.to_string().len()) + .max() + .unwrap_or(0) + .max("exec_ms (best / mean ± stdev)".len()); + + println!( + "{:cw$} {:>sw$} {:cw$} {:>sw$} {:pcw$} {:>pwallw$} {:>psw$}", + "program", "instruction", "prove_total_c", "prove_ms (s)", "prove_segs", + ); + println!("{}", "-".repeat(pw + iw + pcw + pwallw + psw + 8)); + for r in results { + let total = r + .prove_total_cycles + .map_or_else(|| "-".to_owned(), |c| c.to_string()); + let pms = r.prove_stats.map_or_else( + || "-".to_owned(), + |s| format!("{:.1} ({:.1}s)", s.best_ms, s.best_ms / 1_000.0), + ); + let psegs = r + .prove_segments + .map_or_else(|| "-".to_owned(), |s| s.to_string()); + println!( + "{:pcw$} {:>pwallw$} {:>psw$}", + r.program, r.instruction, total, pms, psegs, + ); + } + } +} diff --git a/tools/cycle_bench/src/ppe.rs b/tools/cycle_bench/src/ppe.rs new file mode 100644 index 00000000..77ce52dc --- /dev/null +++ b/tools/cycle_bench/src/ppe.rs @@ -0,0 +1,94 @@ +//! Privacy-preserving execution (PPE) cases for `cycle_bench`. +//! +//! Composition cost is the delta between standalone `prover.prove(env, elf)` for +//! a single program (measured in the main bench) and a full `execute_and_prove` +//! that wraps the same program in the privacy circuit. Chained-call depth sweep +//! uses the `chain_caller` test program (loaded from artifacts/) with N=1, 3, 5, 9. +//! +//! `Receipt::verify(PRIVACY_PRESERVING_CIRCUIT_ID)` timings (the `G_verify` fee-model +//! parameter) are measured by the `verify` criterion bench under `benches/verify.rs`, +//! which reuses the `prove_auth_transfer_in_ppe` setup helper re-exported below. + +#![allow( + dead_code, + reason = "Stubs are used when the `ppe` feature is disabled." +)] + +use serde::Serialize; + +#[cfg(feature = "ppe")] +mod ppe_impl; + +#[cfg(feature = "ppe")] +pub use ppe_impl::prove_auth_transfer_in_ppe; + +#[derive(Debug, Serialize, Clone)] +pub struct PpeBenchResult { + pub label: String, + pub chain_depth: usize, + pub prove_wall_ms: Option, + /// borsh-serialized `InnerReceipt` length (`S_agg` in the fee model). + pub proof_bytes: Option, + pub error: Option, +} + +#[cfg(not(feature = "ppe"))] +#[must_use] +pub const fn run_all() -> Vec { + Vec::new() +} + +#[cfg(feature = "ppe")] +#[must_use] +pub fn run_all() -> Vec { + let mut results = Vec::new(); + + eprintln!("PPE: running composition cost (auth_transfer Transfer in PPE)"); + results.push(ppe_impl::run_auth_transfer_in_ppe()); + + for depth in [1_u32, 3, 5, 9] { + eprintln!("PPE: running chain_caller depth={depth}"); + results.push(ppe_impl::run_chain_caller(depth)); + } + + results +} + +pub fn print_table(results: &[PpeBenchResult]) { + let lw = results + .iter() + .map(|r| r.label.len()) + .max() + .unwrap_or(0) + .max("label".len()); + + println!( + "\n{:5} {:>20} {:>12} {}", + "label", + "depth", + "prove_ms (s)", + "proof_bytes", + "error", + lw = lw, + ); + println!("{}", "-".repeat(lw + 60)); + for r in results { + let p = r.prove_wall_ms.map_or_else( + || "-".to_owned(), + |v| format!("{v:.1} ({:.1}s)", v / 1_000.0), + ); + let b = r + .proof_bytes + .map_or_else(|| "-".to_owned(), |n| n.to_string()); + let e = r.error.as_deref().unwrap_or(""); + println!( + "{:5} {:>20} {:>12} {}", + r.label, + r.chain_depth, + p, + b, + e, + lw = lw, + ); + } +} diff --git a/tools/cycle_bench/src/ppe/ppe_impl.rs b/tools/cycle_bench/src/ppe/ppe_impl.rs new file mode 100644 index 00000000..433c4aa4 --- /dev/null +++ b/tools/cycle_bench/src/ppe/ppe_impl.rs @@ -0,0 +1,159 @@ +//! Feature-gated implementation of PPE composition benches. +//! +//! `prove_auth_transfer_in_ppe` is reused by the `verify` criterion bench under +//! `benches/verify.rs` (re-exported via `super::prove_auth_transfer_in_ppe`). + +use std::{collections::HashMap, time::Instant}; + +use nssa::{ + execute_and_prove, + privacy_preserving_transaction::circuit::{ProgramWithDependencies, Proof}, + program::Program, +}; +use nssa_core::{ + InputAccountIdentity, PrivacyPreservingCircuitOutput, + account::{Account, AccountId, AccountWithMetadata}, + program::ProgramId, +}; +use risc0_zkvm::serde::to_vec; + +use super::PpeBenchResult; + +const AUTH_TRANSFER_ID: ProgramId = nssa::program_methods::AUTHENTICATED_TRANSFER_ID; +const AUTH_TRANSFER_ELF: &[u8] = nssa::program_methods::AUTHENTICATED_TRANSFER_ELF; + +/// `chain_caller` bytecode shipped at `artifacts/test_program_methods/chain_caller.bin`. +/// Loaded at compile time so we don't need a dev-dependency on `test_program_methods`. +const CHAIN_CALLER_ELF: &[u8] = + include_bytes!("../../../../artifacts/test_program_methods/chain_caller.bin"); + +pub fn run_auth_transfer_in_ppe() -> PpeBenchResult { + let label = "auth_transfer Transfer in PPE".to_owned(); + let started = Instant::now(); + match prove_auth_transfer_in_ppe() { + Ok((_out, proof)) => { + let prove_ms = started.elapsed().as_secs_f64() * 1_000.0; + PpeBenchResult { + label, + chain_depth: 0, + prove_wall_ms: Some(prove_ms), + proof_bytes: Some(proof.into_inner().len()), + error: None, + } + } + Err(err) => PpeBenchResult { + label, + chain_depth: 0, + prove_wall_ms: None, + proof_bytes: None, + error: Some(err.to_string()), + }, + } +} + +pub fn prove_auth_transfer_in_ppe() -> anyhow::Result<(PrivacyPreservingCircuitOutput, Proof)> { + let program = Program::new(AUTH_TRANSFER_ELF.to_vec())?; + let pwd = ProgramWithDependencies::from(program); + + // For PPE to allow the sender's balance to be decremented by this + // program, the sender must already be claimed by auth_transfer. + // Recipient stays default-owned so the first call can claim it. + let sender = AccountWithMetadata { + account: Account { + program_owner: AUTH_TRANSFER_ID, + balance: 1_000_000, + ..Account::default() + }, + is_authorized: true, + account_id: AccountId::new([1; 32]), + }; + let recipient = AccountWithMetadata { + account: Account::default(), + is_authorized: true, + account_id: AccountId::new([2; 32]), + }; + let pre_states = vec![sender, recipient]; + + let instruction = authenticated_transfer_core::Instruction::Transfer { amount: 5_000 }; + let instruction_data = to_vec(&instruction)?; + + let account_identities = vec![InputAccountIdentity::Public; pre_states.len()]; + + Ok(execute_and_prove( + pre_states, + instruction_data, + account_identities, + &pwd, + )?) +} + +pub fn run_chain_caller(depth: u32) -> PpeBenchResult { + let label = format!("chain_caller depth={depth}"); + let started = Instant::now(); + match prove_chain_caller(depth) { + Ok((_out, proof)) => { + let prove_ms = started.elapsed().as_secs_f64() * 1_000.0; + PpeBenchResult { + label, + chain_depth: depth as usize, + prove_wall_ms: Some(prove_ms), + proof_bytes: Some(proof.into_inner().len()), + error: None, + } + } + Err(err) => PpeBenchResult { + label, + chain_depth: depth as usize, + prove_wall_ms: None, + proof_bytes: None, + error: Some(err.to_string()), + }, + } +} + +fn prove_chain_caller( + num_chain_calls: u32, +) -> anyhow::Result<(PrivacyPreservingCircuitOutput, Proof)> { + let chain_caller = Program::new(CHAIN_CALLER_ELF.to_vec())?; + let auth_transfer = Program::new(AUTH_TRANSFER_ELF.to_vec())?; + let mut deps = HashMap::new(); + deps.insert(AUTH_TRANSFER_ID, auth_transfer); + let pwd = ProgramWithDependencies::new(chain_caller, deps); + + // Both accounts pre-claimed by auth_transfer. chain_caller doesn't + // track recipient's post-claim program_owner, so a default recipient + // would cause a state mismatch on subsequent chained calls. + let recipient_pre = AccountWithMetadata { + account: Account { + program_owner: AUTH_TRANSFER_ID, + ..Account::default() + }, + is_authorized: true, + account_id: AccountId::new([2; 32]), + }; + let sender_pre = AccountWithMetadata { + account: Account { + program_owner: AUTH_TRANSFER_ID, + balance: 1_000_000, + ..Account::default() + }, + is_authorized: true, + account_id: AccountId::new([1; 32]), + }; + // chain_caller expects pre_states = [recipient, sender]. + let pre_states = vec![recipient_pre, sender_pre]; + + let balance: u128 = 1; + let pda_seed: Option = None; + let instruction = (balance, AUTH_TRANSFER_ID, num_chain_calls, pda_seed); + let instruction_data = to_vec(&instruction)?; + + let account_identities = vec![InputAccountIdentity::Public; pre_states.len()]; + + Ok(execute_and_prove( + pre_states, + instruction_data, + account_identities, + &pwd, + )?) +} diff --git a/tools/cycle_bench/src/stats.rs b/tools/cycle_bench/src/stats.rs new file mode 100644 index 00000000..7f75fd6f --- /dev/null +++ b/tools/cycle_bench/src/stats.rs @@ -0,0 +1,64 @@ +//! Small helper for best / mean / stdev over wall-time samples. +//! +//! We report both best-of-N (the figure that strips OS noise and matches what most +//! bench READMEs print) and mean +/- stdev (the figure the fee model wants, since +//! it cares about the steady-state cost not a single fastest sample). + +use std::fmt; + +use serde::Serialize; + +#[derive(Debug, Serialize, Clone, Copy, Default)] +pub struct Stats { + /// Number of samples in the aggregate (excluding warmup). + pub n: usize, + /// Lowest sample (ms). Strips OS jitter; matches the bench README "best of N" figure. + pub best_ms: f64, + /// Arithmetic mean of samples (ms). + pub mean_ms: f64, + /// Sample standard deviation of samples (ms), computed with Bessel's correction (n-1). + /// 0.0 when n < 2. + pub stdev_ms: f64, +} + +impl Stats { + pub fn from_samples(samples: &[f64]) -> Self { + let n = samples.len(); + if n == 0 { + return Self::default(); + } + let best_ms = samples.iter().copied().fold(f64::INFINITY, f64::min); + let sum: f64 = samples.iter().sum(); + let mean_ms = sum / n as f64; + let stdev_ms = if n > 1 { + let var: f64 = samples + .iter() + .map(|s| { + let d = s - mean_ms; + d * d + }) + .sum::() + / (n - 1) as f64; + var.sqrt() + } else { + 0.0 + }; + Self { + n, + best_ms, + mean_ms, + stdev_ms, + } + } +} + +/// `best / mean ± stdev (n=N)` for table display. +impl fmt::Display for Stats { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{:.2} / {:.2} ± {:.2} (n={})", + self.best_ms, self.mean_ms, self.stdev_ms, self.n, + ) + } +} diff --git a/tools/integration_bench/Cargo.toml b/tools/integration_bench/Cargo.toml new file mode 100644 index 00000000..0829a7f0 --- /dev/null +++ b/tools/integration_bench/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "integration_bench" +version = "0.1.0" +edition = "2024" +license = { workspace = true } +publish = false + +[lints] +workspace = true + +[dependencies] +common.workspace = true +indexer_service_rpc = { workspace = true, features = ["client"] } +nssa.workspace = true +sequencer_service_rpc = { workspace = true, features = ["client"] } +test_fixtures.workspace = true +wallet.workspace = true + +anyhow.workspace = true +borsh.workspace = true +clap.workspace = true +jsonrpsee = { workspace = true, features = ["ws-client"] } +serde.workspace = true +serde_json.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } diff --git a/tools/integration_bench/README.md b/tools/integration_bench/README.md new file mode 100644 index 00000000..f6e2ee04 --- /dev/null +++ b/tools/integration_bench/README.md @@ -0,0 +1,27 @@ +# integration_bench + +End-to-end LEZ scenarios driven through the wallet against a docker-compose Bedrock node + in-process sequencer + indexer (via `test_fixtures::TestContext`). Times each step (submit, inclusion, wallet sync) and records borsh sizes for every block produced, split into per-tx-variant counts. + +## Run + +Prerequisite: a running local Docker daemon. The Bedrock service comes up via the same `bedrock/docker-compose.yml` that integration tests use, so no host-side binary or env vars are required. + +```sh +# All scenarios, dev-mode proving (fast) +RISC0_DEV_MODE=1 cargo run --release -p integration_bench -- --scenario all + +# One scenario, real proving (slow) +cargo run --release -p integration_bench -- --scenario amm +``` + +Scenarios: `token`, `amm`, `fanout`, `private`, `parallel`, `all`. + +All scenarios share a single TestContext for the run (one Bedrock + sequencer + indexer + wallet across the whole run, chain state accumulating), which matches how the node runs in production. + +## What you'll see + +Per scenario: a step table (`submit_s`, `inclusion_s`, `sync_s`, `total_s`) and a size summary covering every block captured during the scenario (block_bytes total/mean/min/max; per-tx-variant sizes for public, PPE, and program-deployment transactions). + +The fanout, parallel, and private scenarios are the most representative for L1-payload-size measurements since they put multiple txs per block. + +JSON output is written to `target/integration_bench_dev.json` (dev mode) or `target/integration_bench_prove.json` (real proving). diff --git a/tools/integration_bench/src/harness.rs b/tools/integration_bench/src/harness.rs new file mode 100644 index 00000000..813bbbab --- /dev/null +++ b/tools/integration_bench/src/harness.rs @@ -0,0 +1,331 @@ +//! Step / scenario timing primitives shared across scenarios. + +#![allow( + clippy::ref_option, + reason = "serde::serialize_with requires fn(&Option, S) -> Result<...>" +)] + +use std::time::{Duration, Instant}; + +use anyhow::{Result, bail}; +use common::transaction::NSSATransaction; +use sequencer_service_rpc::RpcClient as _; +use serde::{Serialize, Serializer}; +use test_fixtures::{DiskSizes, TestContext}; +use wallet::cli::SubcommandReturnValue; + +const TX_INCLUSION_POLL_INTERVAL: Duration = Duration::from_millis(250); +const TX_INCLUSION_TIMEOUT: Duration = Duration::from_mins(2); + +/// Borsh-serialized sizes for one zone block fetched after a step. `block_bytes` +/// is the full Block (header + body + bedrock metadata) and is the closest +/// proxy we have to the L1 payload posted per block. `tx_bytes` is each contained +/// transaction split by variant, which is what the fee model's `S_tx` slot covers. +#[derive(Debug, Serialize, Clone, Default)] +pub struct BlockSize { + pub block_id: u64, + pub block_bytes: usize, + pub public_tx_bytes: Vec, + pub ppe_tx_bytes: Vec, + pub deploy_tx_bytes: Vec, +} + +#[derive(Debug, Serialize, Clone)] +pub struct StepResult { + pub label: String, + #[serde(serialize_with = "ser_duration_secs", rename = "submit_s")] + pub submit: Duration, + #[serde(serialize_with = "ser_opt_duration_secs", rename = "inclusion_s")] + pub inclusion: Option, + #[serde(serialize_with = "ser_opt_duration_secs", rename = "wallet_sync_s")] + pub wallet_sync: Option, + #[serde(serialize_with = "ser_duration_secs", rename = "total_s")] + pub total: Duration, + pub tx_hash: Option, + /// Borsh sizes for every zone block produced during this step. + /// Empty for steps that don't advance the chain (e.g. `RegisterAccount`). + pub blocks: Vec, +} + +#[derive(Debug, Serialize, Default)] +pub struct ScenarioOutput { + pub name: String, + pub steps: Vec, + #[serde(serialize_with = "ser_duration_secs", rename = "total_s")] + pub total: Duration, + /// Disk sizes (sequencer / indexer / wallet tempdirs) sampled at scenario start. + pub disk_before: Option, + /// Disk sizes sampled at scenario end. + pub disk_after: Option, + /// Bedrock-finality latency: time from final-step inclusion to the indexer + /// reporting the sequencer tip as L1-finalised. Effectively measures the + /// sequencer→Bedrock posting + Bedrock finalisation + indexer L1 ingest path. + /// A value at the timeout (60s) means finalisation did not happen within the bench window. + #[serde( + serialize_with = "ser_opt_duration_secs", + rename = "bedrock_finality_s" + )] + pub bedrock_finality: Option, +} + +impl ScenarioOutput { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + ..Default::default() + } + } + + pub fn push(&mut self, step: StepResult) { + self.total = self.total.saturating_add(step.total); + self.steps.push(step); + } + + /// Run a single timed step against `ctx`: capture pre-block, run `submit`, + /// finalize timings, push a `StepResult` onto `self.steps`. Returns the + /// `SubcommandReturnValue` from `submit` so the caller can match on it. + pub async fn step( + &mut self, + ctx: &mut TestContext, + label: impl Into, + submit: impl AsyncFnOnce(&mut TestContext) -> Result, + ) -> Result { + let pre_block = begin_step(ctx).await?; + let started = Instant::now(); + let ret = submit(ctx).await?; + let step = finalize_step(label, started, pre_block, &ret, ctx).await?; + self.push(step); + Ok(ret) + } +} + +/// Begin a timed step. Capture this *before* submitting the wallet operation +/// so we can later subtract it from the post-submit block height to detect +/// when the chain has advanced past the tx's block. +async fn begin_step(ctx: &TestContext) -> Result { + Ok(ctx.sequencer_client().get_last_block_id().await?) +} + +/// Finish a timed wallet step. Records submit (the time between `started` +/// being captured and `ret` being received) and, if `ret` is a +/// [`SubcommandReturnValue::PrivacyPreservingTransfer`], polls the sequencer +/// for inclusion and records the inclusion latency. Returns a [`StepResult`]. +async fn finalize_step( + label: impl Into, + started: Instant, + pre_block_id: u64, + ret: &SubcommandReturnValue, + ctx: &mut TestContext, +) -> Result { + let label = label.into(); + let submit = started.elapsed(); + + let mut tx_hash_str = None; + let mut inclusion = None; + let mut wallet_sync = None; + let mut blocks: Vec = Vec::new(); + + // For non-account-create steps (anything that produces a tx_hash, or even + // `Empty` for public Token Send), wait for the chain to advance past the + // submission block so state is applied before the next step. We use + // get_last_block_id as the canonical "block has been produced and + // recorded" signal. + let should_wait_for_chain = !matches!(ret, SubcommandReturnValue::RegisterAccount { .. }); + if should_wait_for_chain { + if let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = ret { + tx_hash_str = Some(format!("{tx_hash}")); + } + let started_inclusion = Instant::now(); + wait_for_chain_advance(ctx, pre_block_id, 2).await?; + inclusion = Some(started_inclusion.elapsed()); + + let started_sync = Instant::now(); + sync_wallet_to_tip(ctx).await?; + wallet_sync = Some(started_sync.elapsed()); + + // Capture block-byte and per-tx-byte sizes for every block produced + // during this step. We intentionally capture all blocks, including + // empty clock-only ticks: the empty-block baseline lets the fee model + // back out the per-tx contribution. + let tip = ctx.sequencer_client().get_last_block_id().await?; + for block_id in (pre_block_id.saturating_add(1))..=tip { + if let Some(block) = ctx.sequencer_client().get_block(block_id).await? { + let block_bytes = borsh::to_vec(&block).map_or(0, |v| v.len()); + let mut sz = BlockSize { + block_id, + block_bytes, + public_tx_bytes: Vec::new(), + ppe_tx_bytes: Vec::new(), + deploy_tx_bytes: Vec::new(), + }; + for tx in &block.body.transactions { + let n = borsh::to_vec(tx).map_or(0, |v| v.len()); + match tx { + NSSATransaction::Public(_) => sz.public_tx_bytes.push(n), + NSSATransaction::PrivacyPreserving(_) => sz.ppe_tx_bytes.push(n), + NSSATransaction::ProgramDeployment(_) => sz.deploy_tx_bytes.push(n), + } + } + blocks.push(sz); + } + } + } + + Ok(StepResult { + label, + submit, + inclusion, + wallet_sync, + total: started.elapsed(), + tx_hash: tx_hash_str, + blocks, + }) +} + +/// Wait for `get_last_block_id` to advance by at least `min_blocks` from `from_block_id`. +pub async fn wait_for_chain_advance( + ctx: &TestContext, + from_block_id: u64, + min_blocks: u64, +) -> Result<()> { + let target = from_block_id.saturating_add(min_blocks); + let poll = async { + loop { + match ctx.sequencer_client().get_last_block_id().await { + Ok(current) if current >= target => return, + Ok(_) => {} + Err(err) => eprintln!("get_last_block_id error (continuing poll): {err:#}"), + } + tokio::time::sleep(TX_INCLUSION_POLL_INTERVAL).await; + } + }; + match tokio::time::timeout(TX_INCLUSION_TIMEOUT, poll).await { + Ok(()) => Ok(()), + Err(_) => bail!( + "chain did not advance from {from_block_id} to at least {target} within {TX_INCLUSION_TIMEOUT:?}" + ), + } +} + +async fn sync_wallet_to_tip(ctx: &mut TestContext) -> Result<()> { + let last_block = ctx.sequencer_client().get_last_block_id().await?; + ctx.wallet_mut().sync_to_block(last_block).await?; + Ok(()) +} + +pub fn print_table(output: &ScenarioOutput) { + let label_width = output + .steps + .iter() + .map(|s| s.label.len()) + .max() + .unwrap_or(0) + .max("step".len()); + + println!( + "\nScenario: {} (total {:.2}s)", + output.name, + output.total.as_secs_f64(), + ); + println!( + "{:10} {:>12} {:>10} {:>10}", + "step", + "submit_s", + "inclusion_s", + "sync_s", + "total_s", + lw = label_width, + ); + println!("{}", "-".repeat(label_width.saturating_add(50))); + for s in &output.steps { + let inclusion = s + .inclusion + .map_or_else(|| "-".to_owned(), |v| format!("{:.3}", v.as_secs_f64())); + let sync = s + .wallet_sync + .map_or_else(|| "-".to_owned(), |v| format!("{:.3}", v.as_secs_f64())); + println!( + "{:10.3} {:>12} {:>10} {:>10.3}", + s.label, + s.submit.as_secs_f64(), + inclusion, + sync, + s.total.as_secs_f64(), + lw = label_width, + ); + } + + print_size_summary(output); +} + +/// Aggregate borsh sizes per scenario: total/mean/min/max block bytes, and +/// per-tx bytes split by variant. Empty if no blocks were captured. +fn print_size_summary(output: &ScenarioOutput) { + let blocks: Vec<&BlockSize> = output.steps.iter().flat_map(|s| s.blocks.iter()).collect(); + if blocks.is_empty() { + return; + } + + let block_bytes: Vec = blocks.iter().map(|b| b.block_bytes).collect(); + let total_block_bytes: usize = block_bytes.iter().sum(); + let mean_block = mean_usize(&block_bytes); + let min_block = block_bytes.iter().copied().min().unwrap_or(0); + let max_block = block_bytes.iter().copied().max().unwrap_or(0); + + let public: Vec = blocks + .iter() + .flat_map(|b| b.public_tx_bytes.iter().copied()) + .collect(); + let ppe: Vec = blocks + .iter() + .flat_map(|b| b.ppe_tx_bytes.iter().copied()) + .collect(); + let deploy: Vec = blocks + .iter() + .flat_map(|b| b.deploy_tx_bytes.iter().copied()) + .collect(); + + println!( + "\nBlock + tx size summary ({} blocks captured):", + blocks.len() + ); + println!( + " block_bytes: total={total_block_bytes}, mean={mean_block}, min={min_block}, max={max_block}", + ); + print_tx_line("public_tx_bytes ", &public); + print_tx_line("ppe_tx_bytes ", &ppe); + print_tx_line("deploy_tx_bytes ", &deploy); +} + +fn print_tx_line(label: &str, samples: &[usize]) { + if samples.is_empty() { + println!(" {label}: (none)"); + return; + } + let total: usize = samples.iter().sum(); + let mean = mean_usize(samples); + let min = samples.iter().copied().min().unwrap_or(0); + let max = samples.iter().copied().max().unwrap_or(0); + println!( + " {label}: n={}, total={total}, mean={mean}, min={min}, max={max}", + samples.len() + ); +} + +fn mean_usize(xs: &[usize]) -> usize { + xs.iter().sum::().checked_div(xs.len()).unwrap_or(0) +} + +fn ser_duration_secs(d: &Duration, s: S) -> std::result::Result { + s.serialize_f64(d.as_secs_f64()) +} + +fn ser_opt_duration_secs( + d: &Option, + s: S, +) -> std::result::Result { + match d { + Some(d) => s.serialize_f64(d.as_secs_f64()), + None => s.serialize_none(), + } +} diff --git a/tools/integration_bench/src/main.rs b/tools/integration_bench/src/main.rs new file mode 100644 index 00000000..4d14f6d1 --- /dev/null +++ b/tools/integration_bench/src/main.rs @@ -0,0 +1,200 @@ +//! End-to-end LEZ scenario bench. +//! +//! Spins up the full stack via `test_fixtures::TestContext` (docker-compose +//! Bedrock + in-process sequencer + indexer + wallet) once for the whole run, +//! then drives the wallet through each requested scenario against that single +//! shared stack. Times each step and records borsh-serialized block + tx sizes +//! per scenario. +//! +//! Prerequisite: a working local Docker daemon. The Bedrock service is brought +//! up via the same `bedrock/docker-compose.yml` the integration tests use, so +//! no host-side binary or env vars are required. +//! +//! Run examples: +//! `RISC0_DEV_MODE=1 cargo run --release -p integration_bench -- --scenario all`. +//! `cargo run --release -p integration_bench -- --scenario amm`. +//! +//! `RISC0_DEV_MODE=1` skips proving and produces latency-only numbers in +//! ~minutes; omitting it produces realistic proving-inclusive numbers but +//! the run takes much longer. + +#![allow( + clippy::arithmetic_side_effects, + clippy::print_stderr, + clippy::print_stdout, + clippy::shadow_unrelated, + clippy::wildcard_enum_match_arm, + reason = "Bench tool: stderr/stdout output is the deliverable; small Duration / iterator-sum \ + arithmetic is safe at bench scale; bench scenarios bail loudly on any unexpected \ + return variant, which is preferable to maintaining an exhaustive list in five files; \ + the step() closure helper canonically rebinds `ctx` inside the closure body." +)] + +use std::{path::PathBuf, time::Duration}; + +use anyhow::{Context as _, Result}; +use clap::{Parser, ValueEnum}; +use harness::ScenarioOutput; +use serde::Serialize; +use test_fixtures::TestContext; + +mod harness; +mod scenarios; + +#[derive(Copy, Clone, Debug, ValueEnum)] +enum ScenarioName { + Token, + Amm, + Fanout, + Private, + Parallel, + All, +} + +#[derive(Parser, Debug)] +#[command(about = "End-to-end LEZ scenario bench")] +struct Cli { + /// Which scenario(s) to run. + #[arg(long, value_enum, default_value_t = ScenarioName::All)] + scenario: ScenarioName, + + /// Optional JSON output path. Defaults to `/target/integration_bench.json`. + #[arg(long)] + json_out: Option, +} + +#[derive(Debug, Serialize)] +struct BenchRunReport { + risc0_dev_mode: bool, + /// Time to bring up the shared `TestContext` (docker-compose Bedrock + + /// sequencer + indexer + wallet). Paid once per run regardless of how many + /// scenarios are exercised. + shared_setup_s: f64, + scenarios: Vec, + total_wall_s: f64, +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<()> { + // test_fixtures initializes env_logger via a LazyLock, so we leave logger + // setup to it. Set RUST_LOG=info before running to see logs. + + let cli = Cli::parse(); + let risc0_dev_mode = std::env::var("RISC0_DEV_MODE").is_ok_and(|v| !v.is_empty() && v != "0"); + + eprintln!( + "integration_bench: scenario={:?}, RISC0_DEV_MODE={}", + cli.scenario, + if risc0_dev_mode { "1" } else { "unset/0" } + ); + + let to_run: Vec = match cli.scenario { + ScenarioName::All => vec![ + ScenarioName::Token, + ScenarioName::Amm, + ScenarioName::Fanout, + ScenarioName::Private, + ScenarioName::Parallel, + ], + other => vec![other], + }; + + let overall_started = std::time::Instant::now(); + + // One shared stack for the entire run: docker-compose Bedrock + sequencer + + // indexer + wallet. Scenarios share chain state, which matches how the node + // runs in production (long-lived, accumulating). + let setup_started = std::time::Instant::now(); + let mut ctx = TestContext::new() + .await + .context("failed to setup TestContext")?; + let shared_setup = setup_started.elapsed(); + eprintln!("setup: {:.2}s", shared_setup.as_secs_f64()); + + let mut all_outputs = Vec::with_capacity(to_run.len()); + + for name in to_run { + eprintln!("\n=== running scenario: {name:?} ==="); + let disk_before = ctx.disk_sizes(); + let mut output = run_scenario(name, &mut ctx).await?; + output.disk_before = Some(disk_before); + output.disk_after = Some(ctx.disk_sizes()); + output.bedrock_finality = Some(measure_bedrock_finality(&ctx).await?); + harness::print_table(&output); + all_outputs.push(output); + } + + let total_wall_s = overall_started.elapsed().as_secs_f64(); + eprintln!("\nTotal wall time: {total_wall_s:.1}s"); + + let report = BenchRunReport { + risc0_dev_mode, + shared_setup_s: shared_setup.as_secs_f64(), + scenarios: all_outputs, + total_wall_s, + }; + + let out_path = if let Some(p) = cli.json_out { + p + } else { + let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .canonicalize()?; + let suffix = if risc0_dev_mode { "dev" } else { "prove" }; + workspace_root + .join("target") + .join(format!("integration_bench_{suffix}.json")) + }; + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&out_path, serde_json::to_string_pretty(&report)?)?; + eprintln!("\nJSON written to {}", out_path.display()); + + Ok(()) +} + +async fn run_scenario(name: ScenarioName, ctx: &mut TestContext) -> Result { + match name { + ScenarioName::Token => scenarios::token::run(ctx).await, + ScenarioName::Amm => scenarios::amm::run(ctx).await, + ScenarioName::Fanout => scenarios::fanout::run(ctx).await, + ScenarioName::Private => scenarios::private::run(ctx).await, + ScenarioName::Parallel => scenarios::parallel::run(ctx).await, + ScenarioName::All => unreachable!("dispatched above"), + } +} + +/// Poll the indexer's L1-finalised block id until it catches up with the +/// sequencer's last block id. This is effectively the sequencer→Bedrock posting +/// plus Bedrock finalisation plus indexer ingest latency. +async fn measure_bedrock_finality(ctx: &TestContext) -> Result { + use indexer_service_rpc::RpcClient as _; + use jsonrpsee::ws_client::WsClientBuilder; + use sequencer_service_rpc::RpcClient as _; + + let indexer_url = format!("ws://{}", ctx.indexer_addr()); + let indexer_ws = WsClientBuilder::default() + .build(&indexer_url) + .await + .context("connect indexer WS")?; + let sequencer_tip = ctx.sequencer_client().get_last_block_id().await?; + + let timeout = Duration::from_mins(1); + let started = std::time::Instant::now(); + let poll = async { + loop { + match indexer_ws.get_last_finalized_block_id().await { + Ok(Some(b)) if b >= sequencer_tip => return, + Ok(_) => {} + Err(err) => eprintln!("indexer last_synced poll error: {err:#}"), + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + }; + if tokio::time::timeout(timeout, poll).await.is_err() { + eprintln!("indexer did not catch up to {sequencer_tip} within {timeout:?}"); + } + Ok(started.elapsed()) +} diff --git a/tools/integration_bench/src/scenarios/amm.rs b/tools/integration_bench/src/scenarios/amm.rs new file mode 100644 index 00000000..79100c42 --- /dev/null +++ b/tools/integration_bench/src/scenarios/amm.rs @@ -0,0 +1,191 @@ +//! AMM swap flow: setup two tokens, create pool, swap, add liquidity, remove liquidity. + +use anyhow::{Result, bail}; +use test_fixtures::{TestContext, public_mention}; +use wallet::cli::{ + Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + programs::{amm::AmmProgramAgnosticSubcommand, token::TokenProgramAgnosticSubcommand}, +}; + +use crate::harness::ScenarioOutput; + +pub async fn run(ctx: &mut TestContext) -> Result { + let mut output = ScenarioOutput::new("amm_swap_flow"); + + let def_a = new_public_account(ctx, &mut output, "create_acc_def_a").await?; + let supply_a = new_public_account(ctx, &mut output, "create_acc_supply_a").await?; + let user_a = new_public_account(ctx, &mut output, "create_acc_user_a").await?; + + let def_b = new_public_account(ctx, &mut output, "create_acc_def_b").await?; + let supply_b = new_public_account(ctx, &mut output, "create_acc_supply_b").await?; + let user_b = new_public_account(ctx, &mut output, "create_acc_user_b").await?; + + let user_lp = new_public_account(ctx, &mut output, "create_acc_user_lp").await?; + + timed_token_new(ctx, &mut output, "token_a_new", def_a, supply_a, "TokA").await?; + timed_token_send( + ctx, + &mut output, + "token_a_fund_user", + supply_a, + user_a, + 1_000, + ) + .await?; + + timed_token_new(ctx, &mut output, "token_b_new", def_b, supply_b, "TokB").await?; + timed_token_send( + ctx, + &mut output, + "token_b_fund_user", + supply_b, + user_b, + 1_000, + ) + .await?; + + output + .step(ctx, "amm_new_pool", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::AMM(AmmProgramAgnosticSubcommand::New { + user_holding_a: public_mention(user_a), + user_holding_b: public_mention(user_b), + user_holding_lp: public_mention(user_lp), + balance_a: 300, + balance_b: 300, + }), + ) + .await + }) + .await?; + + output + .step(ctx, "amm_swap_exact_input", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::AMM(AmmProgramAgnosticSubcommand::SwapExactInput { + user_holding_a: public_mention(user_a), + user_holding_b: public_mention(user_b), + amount_in: 50, + min_amount_out: 1, + token_definition: def_a, + }), + ) + .await + }) + .await?; + + output + .step(ctx, "amm_add_liquidity", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::AMM(AmmProgramAgnosticSubcommand::AddLiquidity { + user_holding_a: public_mention(user_a), + user_holding_b: public_mention(user_b), + user_holding_lp: public_mention(user_lp), + min_amount_lp: 1, + max_amount_a: 100, + max_amount_b: 100, + }), + ) + .await + }) + .await?; + + output + .step(ctx, "amm_remove_liquidity", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::AMM(AmmProgramAgnosticSubcommand::RemoveLiquidity { + user_holding_a: public_mention(user_a), + user_holding_b: public_mention(user_b), + user_holding_lp: public_mention(user_lp), + balance_lp: 50, + min_amount_a: 1, + min_amount_b: 1, + }), + ) + .await + }) + .await?; + + Ok(output) +} + +async fn new_public_account( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, +) -> Result { + let ret = output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Public { + cci: None, + label: None, + })), + ) + .await + }) + .await?; + match ret { + SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id), + other => bail!("expected RegisterAccount, got {other:?}"), + } +} + +async fn timed_token_new( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, + def_id: nssa::AccountId, + supply_id: nssa::AccountId, + name: &str, +) -> Result<()> { + let name = name.to_owned(); + output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::New { + definition_account_id: public_mention(def_id), + supply_account_id: public_mention(supply_id), + name, + total_supply: 10_000, + }), + ) + .await + }) + .await?; + Ok(()) +} + +async fn timed_token_send( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, + from_id: nssa::AccountId, + to_id: nssa::AccountId, + amount: u128, +) -> Result<()> { + output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: public_mention(from_id), + to: Some(public_mention(to_id)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount, + }), + ) + .await + }) + .await?; + Ok(()) +} diff --git a/tools/integration_bench/src/scenarios/fanout.rs b/tools/integration_bench/src/scenarios/fanout.rs new file mode 100644 index 00000000..d03adf83 --- /dev/null +++ b/tools/integration_bench/src/scenarios/fanout.rs @@ -0,0 +1,86 @@ +//! Multi-recipient fanout: one funded supply pays 10 distinct recipients. + +use anyhow::{Result, bail}; +use test_fixtures::{TestContext, public_mention}; +use wallet::cli::{ + Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + programs::token::TokenProgramAgnosticSubcommand, +}; + +use crate::harness::ScenarioOutput; + +const FANOUT_COUNT: usize = 10; +const AMOUNT_PER_TRANSFER: u128 = 100; + +pub async fn run(ctx: &mut TestContext) -> Result { + let mut output = ScenarioOutput::new("multi_recipient_fanout"); + + let def_id = new_public_account(ctx, &mut output, "create_acc_def").await?; + let supply_id = new_public_account(ctx, &mut output, "create_acc_supply").await?; + + output + .step(ctx, "token_new_fungible", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::New { + definition_account_id: public_mention(def_id), + supply_account_id: public_mention(supply_id), + name: "FanoutToken".to_owned(), + total_supply: 10_000_000, + }), + ) + .await + }) + .await?; + + let mut recipients = Vec::with_capacity(FANOUT_COUNT); + for i in 0..FANOUT_COUNT { + let id = new_public_account(ctx, &mut output, &format!("create_recipient_{i:02}")).await?; + recipients.push(id); + } + + for (i, recipient_id) in recipients.iter().copied().enumerate() { + output + .step(ctx, format!("transfer_{i:02}"), async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: public_mention(supply_id), + to: Some(public_mention(recipient_id)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount: AMOUNT_PER_TRANSFER, + }), + ) + .await + }) + .await?; + } + + Ok(output) +} + +async fn new_public_account( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, +) -> Result { + let ret = output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Public { + cci: None, + label: None, + })), + ) + .await + }) + .await?; + match ret { + SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id), + other => bail!("expected RegisterAccount, got {other:?}"), + } +} diff --git a/tools/integration_bench/src/scenarios/mod.rs b/tools/integration_bench/src/scenarios/mod.rs new file mode 100644 index 00000000..0536a250 --- /dev/null +++ b/tools/integration_bench/src/scenarios/mod.rs @@ -0,0 +1,7 @@ +//! Scenarios driven by the e2e bench. + +pub mod amm; +pub mod fanout; +pub mod parallel; +pub mod private; +pub mod token; diff --git a/tools/integration_bench/src/scenarios/parallel.rs b/tools/integration_bench/src/scenarios/parallel.rs new file mode 100644 index 00000000..c6a265b9 --- /dev/null +++ b/tools/integration_bench/src/scenarios/parallel.rs @@ -0,0 +1,188 @@ +//! Parallel-fanout throughput scenario. N distinct senders each transfer one token +//! to one recipient. Submission is serialised through the single wallet but does +//! not wait for chain advance between submits, so all N txs land in the same +//! block (up to `max_num_tx_in_block`). Measures observed throughput. + +use std::time::Instant; + +use anyhow::{Result, bail}; +use common::transaction::NSSATransaction; +use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{TestContext, public_mention}; +use wallet::cli::{ + Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + programs::token::TokenProgramAgnosticSubcommand, +}; + +use crate::harness::{BlockSize, ScenarioOutput, StepResult}; + +const PARALLEL_FANOUT_N: usize = 10; +const AMOUNT_PER_TRANSFER: u128 = 100; + +pub async fn run(ctx: &mut TestContext) -> Result { + let mut output = ScenarioOutput::new("parallel_fanout"); + + // Setup: definition, master supply, N parallel supplies, N recipients. + let def_id = new_public_account(ctx, &mut output, "create_acc_def").await?; + let master_id = new_public_account(ctx, &mut output, "create_acc_master").await?; + + let mut senders = Vec::with_capacity(PARALLEL_FANOUT_N); + for i in 0..PARALLEL_FANOUT_N { + let id = new_public_account(ctx, &mut output, &format!("create_sender_{i:02}")).await?; + senders.push(id); + } + let mut recipients = Vec::with_capacity(PARALLEL_FANOUT_N); + for i in 0..PARALLEL_FANOUT_N { + let id = new_public_account(ctx, &mut output, &format!("create_recipient_{i:02}")).await?; + recipients.push(id); + } + + // Mint full supply into master. + let total_mint = u128::try_from(PARALLEL_FANOUT_N) + .expect("usize fits u128") + .saturating_mul(AMOUNT_PER_TRANSFER) + .saturating_mul(10); + output + .step(ctx, "token_new_fungible", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::New { + definition_account_id: public_mention(def_id), + supply_account_id: public_mention(master_id), + name: "ParToken".to_owned(), + total_supply: total_mint, + }), + ) + .await + }) + .await?; + + // Fund each sender from master. Serial; this is setup, not measured throughput. + for (i, sender_id) in senders.iter().copied().enumerate() { + output + .step(ctx, format!("fund_sender_{i:02}"), async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: public_mention(master_id), + to: Some(public_mention(sender_id)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount: AMOUNT_PER_TRANSFER * 5, + }), + ) + .await + }) + .await?; + } + + // The measured phase: submit N transfers as fast as possible, do not wait + // for chain advance between submits. The sequencer batches whatever lands in + // its mempool before block_create_timeout. The burst step is captured + // manually rather than via the `step()` helper because we need to time + // submit-and-inclusion as two separate intervals over a synthesised batch + // rather than per-tx. + let pre_block_burst = ctx.sequencer_client().get_last_block_id().await?; + let burst_started = Instant::now(); + + // Submit all N back-to-back. Wallet serialises through `wallet_mut()`, but + // each sender has its own nonce so there are no collisions. + for (sender_id, recipient_id) in senders.iter().zip(recipients.iter()) { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: public_mention(*sender_id), + to: Some(public_mention(*recipient_id)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount: AMOUNT_PER_TRANSFER, + }), + ) + .await?; + } + let all_submitted_at = Instant::now(); + let submit_duration = all_submitted_at.saturating_duration_since(burst_started); + + // Wait for the chain to advance by at least 2 blocks past pre_block_burst. + // That guarantees the block holding our burst is sealed and applied. + crate::harness::wait_for_chain_advance(ctx, pre_block_burst, 2).await?; + let inclusion_done_at = Instant::now(); + let inclusion_after_submit = inclusion_done_at.saturating_duration_since(all_submitted_at); + let burst_total = inclusion_done_at.saturating_duration_since(burst_started); + + eprintln!( + "parallel_fanout: submitted {} txs in {:.3}s, inclusion in {:.3}s, total {:.3}s", + senders.len(), + submit_duration.as_secs_f64(), + inclusion_after_submit.as_secs_f64(), + burst_total.as_secs_f64(), + ); + + // Capture every block produced during the burst window. This is the + // scenario where one block holds many txs, so block_bytes here is the + // most representative L1-payload-equivalent measurement we have. + let tip = ctx.sequencer_client().get_last_block_id().await?; + let mut blocks: Vec = Vec::new(); + for block_id in (pre_block_burst.saturating_add(1))..=tip { + if let Some(block) = ctx.sequencer_client().get_block(block_id).await? { + let block_bytes = borsh::to_vec(&block).map_or(0, |v| v.len()); + let mut sz = BlockSize { + block_id, + block_bytes, + public_tx_bytes: Vec::new(), + ppe_tx_bytes: Vec::new(), + deploy_tx_bytes: Vec::new(), + }; + for tx in &block.body.transactions { + let n = borsh::to_vec(tx).map_or(0, |v| v.len()); + match tx { + NSSATransaction::Public(_) => sz.public_tx_bytes.push(n), + NSSATransaction::PrivacyPreserving(_) => sz.ppe_tx_bytes.push(n), + NSSATransaction::ProgramDeployment(_) => sz.deploy_tx_bytes.push(n), + } + } + blocks.push(sz); + } + } + + // Synthesise a single summary "step" for the burst. Use the submit time + // for `submit` and the inclusion-wait time for `inclusion`. + let burst_step = StepResult { + label: format!("burst_{}_transfers", senders.len()), + submit: submit_duration, + inclusion: Some(inclusion_after_submit), + wallet_sync: None, + total: burst_total, + tx_hash: None, + blocks, + }; + output.push(burst_step); + + Ok(output) +} + +async fn new_public_account( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, +) -> Result { + let ret = output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Public { + cci: None, + label: None, + })), + ) + .await + }) + .await?; + match ret { + SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id), + other => bail!("expected RegisterAccount, got {other:?}"), + } +} diff --git a/tools/integration_bench/src/scenarios/private.rs b/tools/integration_bench/src/scenarios/private.rs new file mode 100644 index 00000000..2be8c43c --- /dev/null +++ b/tools/integration_bench/src/scenarios/private.rs @@ -0,0 +1,140 @@ +//! Private chained flow: shielded, deshielded, and private-to-private transfers. + +use anyhow::{Result, bail}; +use test_fixtures::{TestContext, private_mention, public_mention}; +use wallet::cli::{ + Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + programs::token::TokenProgramAgnosticSubcommand, +}; + +use crate::harness::ScenarioOutput; + +pub async fn run(ctx: &mut TestContext) -> Result { + let mut output = ScenarioOutput::new("private_chained_flow"); + + let def_id = new_public_account(ctx, &mut output, "create_acc_def").await?; + let supply_id = new_public_account(ctx, &mut output, "create_acc_supply").await?; + let public_recipient_id = + new_public_account(ctx, &mut output, "create_acc_pub_recipient").await?; + let private_a = new_private_account(ctx, &mut output, "create_acc_priv_a").await?; + let private_b = new_private_account(ctx, &mut output, "create_acc_priv_b").await?; + + // Mint into public supply. + output + .step(ctx, "token_new_fungible", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::New { + definition_account_id: public_mention(def_id), + supply_account_id: public_mention(supply_id), + name: "PrivToken".to_owned(), + total_supply: 1_000_000, + }), + ) + .await + }) + .await?; + + // Shielded transfer: public supply -> private_a. + output + .step(ctx, "shielded_transfer", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: public_mention(supply_id), + to: Some(private_mention(private_a)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount: 1_000, + }), + ) + .await + }) + .await?; + + // Deshielded transfer: private_a -> public_recipient. + output + .step(ctx, "deshielded_transfer", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: private_mention(private_a), + to: Some(public_mention(public_recipient_id)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount: 100, + }), + ) + .await + }) + .await?; + + // Private-to-private transfer: private_a -> private_b. + output + .step(ctx, "private_to_private", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: private_mention(private_a), + to: Some(private_mention(private_b)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount: 200, + }), + ) + .await + }) + .await?; + + Ok(output) +} + +async fn new_public_account( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, +) -> Result { + let ret = output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Public { + cci: None, + label: None, + })), + ) + .await + }) + .await?; + match ret { + SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id), + other => bail!("expected RegisterAccount, got {other:?}"), + } +} + +async fn new_private_account( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, +) -> Result { + let ret = output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Private { + cci: None, + label: None, + })), + ) + .await + }) + .await?; + match ret { + SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id), + other => bail!("expected RegisterAccount, got {other:?}"), + } +} diff --git a/tools/integration_bench/src/scenarios/token.rs b/tools/integration_bench/src/scenarios/token.rs new file mode 100644 index 00000000..d1dfdef3 --- /dev/null +++ b/tools/integration_bench/src/scenarios/token.rs @@ -0,0 +1,119 @@ +//! Token onboarding scenario: create accounts, mint, public transfer, private transfer. + +use anyhow::{Result, bail}; +use test_fixtures::{TestContext, private_mention, public_mention}; +use wallet::cli::{ + Command, SubcommandReturnValue, + account::{AccountSubcommand, NewSubcommand}, + programs::token::TokenProgramAgnosticSubcommand, +}; + +use crate::harness::ScenarioOutput; + +pub async fn run(ctx: &mut TestContext) -> Result { + let mut output = ScenarioOutput::new("token_onboarding"); + + let definition_id = new_public_account(ctx, &mut output, "create_pub_definition").await?; + let supply_id = new_public_account(ctx, &mut output, "create_pub_supply").await?; + let recipient_id = new_public_account(ctx, &mut output, "create_pub_recipient").await?; + + output + .step(ctx, "token_new_fungible", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::New { + definition_account_id: public_mention(definition_id), + supply_account_id: public_mention(supply_id), + name: "BenchToken".to_owned(), + total_supply: 1_000_000, + }), + ) + .await + }) + .await?; + + output + .step(ctx, "token_public_transfer", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: public_mention(supply_id), + to: Some(public_mention(recipient_id)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount: 1_000, + }), + ) + .await + }) + .await?; + + let private_recipient_id = + new_private_account(ctx, &mut output, "create_priv_recipient").await?; + + output + .step(ctx, "token_shielded_transfer", async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Token(TokenProgramAgnosticSubcommand::Send { + from: public_mention(supply_id), + to: Some(private_mention(private_recipient_id)), + to_npk: None, + to_vpk: None, + to_identifier: Some(0), + amount: 500, + }), + ) + .await + }) + .await?; + + Ok(output) +} + +async fn new_public_account( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, +) -> Result { + let ret = output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Public { + cci: None, + label: None, + })), + ) + .await + }) + .await?; + match ret { + SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id), + other => bail!("expected RegisterAccount, got {other:?}"), + } +} + +async fn new_private_account( + ctx: &mut TestContext, + output: &mut ScenarioOutput, + label: &str, +) -> Result { + let ret = output + .step(ctx, label, async |ctx| { + wallet::cli::execute_subcommand( + ctx.wallet_mut(), + Command::Account(AccountSubcommand::New(NewSubcommand::Private { + cci: None, + label: None, + })), + ) + .await + }) + .await?; + match ret { + SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id), + other => bail!("expected RegisterAccount, got {other:?}"), + } +} diff --git a/wallet-ffi/Cargo.toml b/wallet-ffi/Cargo.toml index 0af20a54..869845c8 100644 --- a/wallet-ffi/Cargo.toml +++ b/wallet-ffi/Cargo.toml @@ -15,7 +15,10 @@ wallet.workspace = true nssa.workspace = true nssa_core.workspace = true sequencer_service_rpc = { workspace = true, features = ["client"] } + tokio.workspace = true +key_protocol.workspace = true +serde_json.workspace = true [build-dependencies] cbindgen = "0.29" diff --git a/wallet-ffi/src/account.rs b/wallet-ffi/src/account.rs index 49f6a8de..ed27abe6 100644 --- a/wallet-ffi/src/account.rs +++ b/wallet-ffi/src/account.rs @@ -1,14 +1,20 @@ //! Account management functions. -use std::ptr; +use std::{ffi::c_char, ptr, str::FromStr as _}; +use key_protocol::key_management::{key_tree::chain_index::ChainIndex, KeyChain}; use nssa::AccountId; +use wallet::account::{AccountIdWithPrivacy, HumanReadableAccount}; use crate::{ - block_on, + block_on, c_str_to_string, error::{print_error, WalletFfiError}, - types::{FfiAccount, FfiAccountList, FfiAccountListEntry, FfiBytes32, WalletHandle}, + types::{ + FfiAccount, FfiAccountList, FfiAccountListEntry, FfiBytes32, FfiPrivateAccountKeys, + WalletHandle, + }, wallet::get_wallet, + FfiU128, }; /// Create a new public account. @@ -59,10 +65,18 @@ pub unsafe extern "C" fn wallet_ffi_create_account_public( WalletFfiError::Success } -/// Create a new private account. +/// Create a new private account, storing a default account entry in local storage. /// -/// Private accounts use privacy-preserving transactions with nullifiers -/// and commitments. +/// This is the private-account equivalent of `wallet_ffi_create_account_public`. +/// It generates a key node, assigns a random identifier, and inserts a default +/// account record so the account can immediately be used with +/// `wallet_ffi_register_private_account`. +/// +/// The identifier is chosen at random and is not encoded in the mnemonic seed. +/// Once the account is initialized, the identifier is embedded in the encrypted +/// transaction payload and can be recovered by running `sync-private` from the +/// same mnemonic. An account that was created locally but has never been initialized +/// cannot be recovered from the seed alone. /// /// # Parameters /// - `handle`: Valid wallet handle @@ -107,6 +121,74 @@ pub unsafe extern "C" fn wallet_ffi_create_account_private( WalletFfiError::Success } +/// Create a new private key node. +/// +/// Returns the nullifier public key (npk) and viewing public key (vpk) to share with +/// senders. Account IDs are discovered later via sync when senders initialize accounts +/// under this key. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `out_keys`: Output pointer for the key data (npk + vpk) +/// +/// # Returns +/// - `Success` on successful creation +/// - Error code on failure +/// +/// # Memory +/// The keys structure must be freed with `wallet_ffi_free_private_account_keys()`. +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +/// - `out_keys` must be a valid pointer to a `FfiPrivateAccountKeys` struct +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_create_private_accounts_key( + handle: *mut WalletHandle, + out_keys: *mut FfiPrivateAccountKeys, +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + if out_keys.is_null() { + print_error("Null output pointer for keys"); + return WalletFfiError::NullPointer; + } + + let mut wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {e}")); + return WalletFfiError::InternalError; + } + }; + + let chain_index = wallet.create_private_accounts_key(None); + let key_chain = wallet + .storage() + .key_chain() + .private_account_key_chain_by_index(&chain_index) + .expect("Node was just inserted"); + + let npk_bytes = key_chain.nullifier_public_key.0; + let vpk_bytes = key_chain.viewing_public_key.to_bytes(); + let vpk_len = vpk_bytes.len(); + #[expect( + clippy::as_conversions, + reason = "We need to convert the boxed slice into a raw pointer for FFI" + )] + let vpk_ptr = Box::into_raw(vpk_bytes.to_vec().into_boxed_slice()) as *const u8; + + unsafe { + (*out_keys).nullifier_public_key.data = npk_bytes; + (*out_keys).viewing_public_key = vpk_ptr; + (*out_keys).viewing_public_key_len = vpk_len; + } + + WalletFfiError::Success +} + /// List all accounts in the wallet. /// /// Returns both public and private accounts managed by this wallet. @@ -148,40 +230,21 @@ pub unsafe extern "C" fn wallet_ffi_list_accounts( } }; - let user_data = &wallet.storage().user_data; - let mut entries = Vec::new(); - - // Public accounts from default signing keys (preconfigured) - for account_id in user_data.default_pub_account_signing_keys.keys() { - entries.push(FfiAccountListEntry { - account_id: FfiBytes32::from_account_id(account_id), - is_public: true, - }); - } - - // Public accounts from key tree (generated) - for account_id in user_data.public_key_tree.account_id_map.keys() { - entries.push(FfiAccountListEntry { - account_id: FfiBytes32::from_account_id(account_id), - is_public: true, - }); - } - - // Private accounts from default accounts (preconfigured) - for account_id in user_data.default_user_private_accounts.keys() { - entries.push(FfiAccountListEntry { - account_id: FfiBytes32::from_account_id(account_id), - is_public: false, - }); - } - - // Private accounts from key tree (generated) - for account_id in user_data.private_key_tree.account_id_map.keys() { - entries.push(FfiAccountListEntry { - account_id: FfiBytes32::from_account_id(account_id), - is_public: false, - }); - } + let entries = wallet + .storage() + .key_chain() + .account_ids() + .map(|(account_id, _idx)| match account_id { + AccountIdWithPrivacy::Public(account_id) => FfiAccountListEntry { + account_id: FfiBytes32::from_account_id(account_id), + is_public: true, + }, + AccountIdWithPrivacy::Private(account_id) => FfiAccountListEntry { + account_id: FfiBytes32::from_account_id(account_id), + is_public: false, + }, + }) + .collect::>(); let count = entries.len(); @@ -425,3 +488,168 @@ pub unsafe extern "C" fn wallet_ffi_free_account_data(account: *mut FfiAccount) } } } + +/// Import a public account private key into wallet storage. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `private_key_hex`: Hex-encoded private key string +/// +/// # Returns +/// - `Success` on successful import +/// - Error code on failure +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +/// - `private_key_hex` must be a valid pointer to a null-terminated C string +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_import_public_account( + handle: *mut WalletHandle, + private_key_hex: *const c_char, +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + let private_key_hex = match c_str_to_string(private_key_hex, "private_key_hex") { + Ok(value) => value, + Err(e) => return e, + }; + + let private_key = match nssa::PrivateKey::from_str(&private_key_hex) { + Ok(value) => value, + Err(e) => { + print_error(format!("Invalid public account private key: {e}")); + return WalletFfiError::InvalidKeyValue; + } + }; + + let mut wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {e}")); + return WalletFfiError::InternalError; + } + }; + + wallet + .storage_mut() + .key_chain_mut() + .add_imported_public_account(private_key); + + match wallet.store_persistent_data() { + Ok(()) => WalletFfiError::Success, + Err(e) => { + print_error(format!("Failed to save wallet after public import: {e}")); + WalletFfiError::StorageError + } + } +} + +/// Import a private account keychain and account state into wallet storage. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `key_chain_json`: JSON-encoded `key_protocol::key_management::KeyChain` +/// - `chain_index`: Optional chain index string (for example `/0/1`, `NULL` if unknown) +/// - `identifier`: Identifier for this private account as little-endian u128 bytes +/// - `account_state_json`: JSON-encoded `wallet::account::HumanReadableAccount` +/// +/// # Returns +/// - `Success` on successful import +/// - Error code on failure +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +/// - `key_chain_json` must be a valid pointer to a null-terminated C string +/// - `identifier` must be a valid pointer to a `FfiU128` struct +/// - `account_state_json` must be a valid pointer to a null-terminated C string +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_import_private_account( + handle: *mut WalletHandle, + key_chain_json: *const c_char, + chain_index: *const c_char, + identifier: *const FfiU128, + account_state_json: *const c_char, +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + if identifier.is_null() { + print_error("Null pointer for identifier"); + return WalletFfiError::NullPointer; + } + + let key_chain_json = match c_str_to_string(key_chain_json, "key_chain_json") { + Ok(value) => value, + Err(e) => return e, + }; + + let account_state_json = match c_str_to_string(account_state_json, "account_state_json") { + Ok(value) => value, + Err(e) => return e, + }; + + let key_chain: KeyChain = match serde_json::from_str(&key_chain_json) { + Ok(value) => value, + Err(e) => { + print_error(format!("Invalid key chain JSON: {e}")); + return WalletFfiError::SerializationError; + } + }; + + let account_state: HumanReadableAccount = match serde_json::from_str(&account_state_json) { + Ok(value) => value, + Err(e) => { + print_error(format!("Invalid account state JSON: {e}")); + return WalletFfiError::SerializationError; + } + }; + + let account = nssa::Account::from(account_state); + + let mut wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {e}")); + return WalletFfiError::InternalError; + } + }; + + let chain_index = if chain_index.is_null() { + None + } else { + let chain_index_path = match c_str_to_string(chain_index, "chain_index") { + Ok(value) => value, + Err(e) => return e, + }; + + let parsed_chain_index = match ChainIndex::from_str(&chain_index_path) { + Ok(value) => value, + Err(e) => { + print_error(format!("Invalid chain index string: {e}")); + return WalletFfiError::InvalidTypeConversion; + } + }; + + Some(parsed_chain_index) + }; + + let identifier = u128::from_le_bytes(unsafe { (*identifier).data }); + + wallet + .storage_mut() + .key_chain_mut() + .add_imported_private_account(key_chain, chain_index, identifier, account); + + match wallet.store_persistent_data() { + Ok(()) => WalletFfiError::Success, + Err(e) => { + print_error(format!("Failed to save wallet after private import: {e}")); + WalletFfiError::StorageError + } + } +} diff --git a/wallet-ffi/src/error.rs b/wallet-ffi/src/error.rs index a8c345b5..17b73075 100644 --- a/wallet-ffi/src/error.rs +++ b/wallet-ffi/src/error.rs @@ -4,6 +4,7 @@ /// Error codes returned by FFI functions. #[repr(C)] +#[must_use] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum WalletFfiError { /// Operation completed successfully. diff --git a/wallet-ffi/src/keys.rs b/wallet-ffi/src/keys.rs index 4eeadd8f..b676ffab 100644 --- a/wallet-ffi/src/keys.rs +++ b/wallet-ffi/src/keys.rs @@ -116,11 +116,11 @@ pub unsafe extern "C" fn wallet_ffi_get_private_account_keys( let account_id = AccountId::new(unsafe { (*account_id).data }); - let Some((key_chain, _account)) = wallet.storage().user_data.get_private_account(account_id) - else { + let Some(acc) = wallet.storage().key_chain().private_account(account_id) else { print_error("Private account not found in wallet"); return WalletFfiError::AccountNotFound; }; + let key_chain = acc.key_chain; // NPK is a 32-byte array let npk_bytes = key_chain.nullifier_public_key.0; diff --git a/wallet-ffi/src/lib.rs b/wallet-ffi/src/lib.rs index d84bf5a3..16943d3e 100644 --- a/wallet-ffi/src/lib.rs +++ b/wallet-ffi/src/lib.rs @@ -26,9 +26,13 @@ reason = "TODO: fix later" )] -use std::sync::OnceLock; +use std::{ + ffi::{c_char, CStr}, + sync::OnceLock, +}; use ::wallet::ExecutionFailureKind; +use error::WalletFfiError; // Re-export public types for cbindgen pub use error::WalletFfiError as FfiError; use tokio::runtime::Handle; @@ -88,3 +92,20 @@ pub(crate) fn map_execution_error(e: ExecutionFailureKind) -> FfiError { _ => FfiError::InternalError, } } + +/// Helper to convert a C string to a Rust String. +fn c_str_to_string(ptr: *const c_char, name: &str) -> Result { + if ptr.is_null() { + print_error(format!("Null pointer for {name}")); + return Err(WalletFfiError::NullPointer); + } + + let c_str = unsafe { CStr::from_ptr(ptr) }; + match c_str.to_str() { + Ok(s) => Ok(s.to_owned()), + Err(e) => { + print_error(format!("Invalid UTF-8 in {name}: {e}")); + Err(WalletFfiError::InvalidUtf8) + } + } +} diff --git a/wallet-ffi/src/sync.rs b/wallet-ffi/src/sync.rs index 41031d06..5f7a4413 100644 --- a/wallet-ffi/src/sync.rs +++ b/wallet-ffi/src/sync.rs @@ -93,7 +93,7 @@ pub unsafe extern "C" fn wallet_ffi_get_last_synced_block( }; unsafe { - *out_block_id = wallet.last_synced_block; + *out_block_id = wallet.storage().last_synced_block(); } WalletFfiError::Success diff --git a/wallet-ffi/src/transfer.rs b/wallet-ffi/src/transfer.rs index 739832ae..8f3c47d7 100644 --- a/wallet-ffi/src/transfer.rs +++ b/wallet-ffi/src/transfer.rs @@ -3,13 +3,16 @@ use std::{ffi::CString, ptr}; use nssa::AccountId; -use wallet::program_facades::native_token_transfer::NativeTokenTransfer; +use wallet::{ + account::AccountIdWithPrivacy, cli::CliAccountMention, + program_facades::native_token_transfer::NativeTokenTransfer, +}; use crate::{ block_on, error::{print_error, WalletFfiError}, map_execution_error, - types::{FfiBytes32, FfiTransferResult, WalletHandle}, + types::{FfiBytes32, FfiTransferResult, FfiU128, WalletHandle}, wallet::get_wallet, FfiPrivateAccountKeys, }; @@ -72,7 +75,16 @@ pub unsafe extern "C" fn wallet_ffi_transfer_public( let transfer = NativeTokenTransfer(&wallet); - match block_on(transfer.send_public_transfer(from_id, to_id, amount)) { + let from_mention = CliAccountMention::Id(AccountIdWithPrivacy::Public(from_id)); + let to_mention = CliAccountMention::Id(AccountIdWithPrivacy::Public(to_id)); + + match block_on(transfer.send_public_transfer( + from_id, + to_id, + amount, + &from_mention, + &to_mention, + )) { Ok(tx_hash) => { let tx_hash = CString::new(tx_hash.to_string()) .map_or(ptr::null_mut(), std::ffi::CString::into_raw); @@ -102,6 +114,7 @@ pub unsafe extern "C" fn wallet_ffi_transfer_public( /// - `handle`: Valid wallet handle /// - `from`: Source account ID (must be owned by this wallet) /// - `to_keys`: Destination account keys +/// - `to_identifier`: Identifier for the recipient's private account /// - `amount`: Amount to transfer as little-endian [u8; 16] /// - `out_result`: Output pointer for transfer result /// @@ -125,6 +138,7 @@ pub unsafe extern "C" fn wallet_ffi_transfer_shielded( handle: *mut WalletHandle, from: *const FfiBytes32, to_keys: *const FfiPrivateAccountKeys, + to_identifier: *const FfiU128, amount: *const [u8; 16], out_result: *mut FfiTransferResult, ) -> WalletFfiError { @@ -133,7 +147,12 @@ pub unsafe extern "C" fn wallet_ffi_transfer_shielded( Err(e) => return e, }; - if from.is_null() || to_keys.is_null() || amount.is_null() || out_result.is_null() { + if from.is_null() + || to_keys.is_null() + || to_identifier.is_null() + || amount.is_null() + || out_result.is_null() + { print_error("Null pointer argument"); return WalletFfiError::NullPointer; } @@ -155,13 +174,18 @@ pub unsafe extern "C" fn wallet_ffi_transfer_shielded( return e; } }; + let to_identifier = u128::from_le_bytes(unsafe { (*to_identifier).data }); let amount = u128::from_le_bytes(unsafe { *amount }); let transfer = NativeTokenTransfer(&wallet); - match block_on( - transfer.send_shielded_transfer_to_outer_account(from_id, to_npk, to_vpk, amount), - ) { + match block_on(transfer.send_shielded_transfer_to_outer_account( + from_id, + to_npk, + to_vpk, + to_identifier, + amount, + )) { Ok((tx_hash, _shared_key)) => { let tx_hash = CString::new(tx_hash.to_string()) .map_or(ptr::null_mut(), std::ffi::CString::into_raw); @@ -271,6 +295,7 @@ pub unsafe extern "C" fn wallet_ffi_transfer_deshielded( /// - `handle`: Valid wallet handle /// - `from`: Source account ID (must be owned by this wallet) /// - `to_keys`: Destination account keys +/// - `to_identifier`: Identifier for the recipient's private account /// - `amount`: Amount to transfer as little-endian [u8; 16] /// - `out_result`: Output pointer for transfer result /// @@ -294,6 +319,7 @@ pub unsafe extern "C" fn wallet_ffi_transfer_private( handle: *mut WalletHandle, from: *const FfiBytes32, to_keys: *const FfiPrivateAccountKeys, + to_identifier: *const FfiU128, amount: *const [u8; 16], out_result: *mut FfiTransferResult, ) -> WalletFfiError { @@ -302,7 +328,12 @@ pub unsafe extern "C" fn wallet_ffi_transfer_private( Err(e) => return e, }; - if from.is_null() || to_keys.is_null() || amount.is_null() || out_result.is_null() { + if from.is_null() + || to_keys.is_null() + || to_identifier.is_null() + || amount.is_null() + || out_result.is_null() + { print_error("Null pointer argument"); return WalletFfiError::NullPointer; } @@ -324,12 +355,18 @@ pub unsafe extern "C" fn wallet_ffi_transfer_private( return e; } }; + let to_identifier = u128::from_le_bytes(unsafe { (*to_identifier).data }); let amount = u128::from_le_bytes(unsafe { *amount }); let transfer = NativeTokenTransfer(&wallet); - match block_on(transfer.send_private_transfer_to_outer_account(from_id, to_npk, to_vpk, amount)) - { + match block_on(transfer.send_private_transfer_to_outer_account( + from_id, + to_npk, + to_vpk, + to_identifier, + amount, + )) { Ok((tx_hash, _shared_key)) => { let tx_hash = CString::new(tx_hash.to_string()) .map_or(ptr::null_mut(), std::ffi::CString::into_raw); @@ -566,7 +603,9 @@ pub unsafe extern "C" fn wallet_ffi_register_public_account( let transfer = NativeTokenTransfer(&wallet); - match block_on(transfer.register_account(account_id)) { + let mention = CliAccountMention::Id(AccountIdWithPrivacy::Public(account_id)); + + match block_on(transfer.register_account(account_id, &mention)) { 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/wallet-ffi/src/types.rs b/wallet-ffi/src/types.rs index 87c30315..b970a8d3 100644 --- a/wallet-ffi/src/types.rs +++ b/wallet-ffi/src/types.rs @@ -149,7 +149,7 @@ impl FfiBytes32 { /// Create from an `AccountId`. #[must_use] - pub const fn from_account_id(id: &nssa::AccountId) -> Self { + pub const fn from_account_id(id: nssa::AccountId) -> Self { Self { data: *id.value() } } } @@ -186,8 +186,8 @@ impl From for u128 { } } -impl From<&nssa::AccountId> for FfiBytes32 { - fn from(id: &nssa::AccountId) -> Self { +impl From for FfiBytes32 { + fn from(id: nssa::AccountId) -> Self { Self::from_account_id(id) } } diff --git a/wallet-ffi/src/wallet.rs b/wallet-ffi/src/wallet.rs index 9117d0ee..7aabaa2d 100644 --- a/wallet-ffi/src/wallet.rs +++ b/wallet-ffi/src/wallet.rs @@ -10,7 +10,7 @@ use std::{ use wallet::WalletCore; use crate::{ - block_on, + c_str_to_string, error::{print_error, WalletFfiError}, types::WalletHandle, }; @@ -60,23 +60,6 @@ fn c_str_to_path(ptr: *const c_char, name: &str) -> Result Result { - if ptr.is_null() { - print_error(format!("Null pointer for {name}")); - return Err(WalletFfiError::NullPointer); - } - - let c_str = unsafe { CStr::from_ptr(ptr) }; - match c_str.to_str() { - Ok(s) => Ok(s.to_owned()), - Err(e) => { - print_error(format!("Invalid UTF-8 in {name}: {e}")); - Err(WalletFfiError::InvalidUtf8) - } - } -} - /// Create a new wallet with fresh storage. /// /// This initializes a new wallet with a new seed derived from the password. @@ -111,8 +94,8 @@ pub unsafe extern "C" fn wallet_ffi_create_new( return ptr::null_mut(); }; - match WalletCore::new_init_storage(config_path, storage_path, None, password) { - Ok(core) => { + match WalletCore::new_init_storage(config_path, storage_path, None, &password) { + Ok((core, _mnemonic)) => { let wrapper = Box::new(WalletWrapper { core: Mutex::new(core), }); @@ -212,7 +195,7 @@ pub unsafe extern "C" fn wallet_ffi_save(handle: *mut WalletHandle) -> WalletFfi } }; - match block_on(wallet.store_persistent_data()) { + match wallet.store_persistent_data() { Ok(()) => WalletFfiError::Success, Err(e) => { print_error(format!("Failed to save wallet: {e}")); diff --git a/wallet-ffi/wallet_ffi.h b/wallet-ffi/wallet_ffi.h index 2665cd40..adbb7b50 100644 --- a/wallet-ffi/wallet_ffi.h +++ b/wallet-ffi/wallet_ffi.h @@ -126,6 +126,24 @@ typedef struct FfiBytes32 { uint8_t data[32]; } FfiBytes32; +/** + * Public keys for a private account (safe to expose). + */ +typedef struct FfiPrivateAccountKeys { + /** + * Nullifier public key (32 bytes). + */ + struct FfiBytes32 nullifier_public_key; + /** + * viewing public key (compressed secp256k1 point). + */ + const uint8_t *viewing_public_key; + /** + * Length of viewing public key (typically 33 bytes). + */ + uintptr_t viewing_public_key_len; +} FfiPrivateAccountKeys; + /** * Single entry in the account list. */ @@ -189,24 +207,6 @@ typedef struct FfiPublicAccountKey { struct FfiBytes32 public_key; } FfiPublicAccountKey; -/** - * Public keys for a private account (safe to expose). - */ -typedef struct FfiPrivateAccountKeys { - /** - * Nullifier public key (32 bytes). - */ - struct FfiBytes32 nullifier_public_key; - /** - * viewing public key (compressed secp256k1 point). - */ - const uint8_t *viewing_public_key; - /** - * Length of viewing public key (typically 33 bytes). - */ - uintptr_t viewing_public_key_len; -} FfiPrivateAccountKeys; - /** * Result of a transfer operation. */ @@ -243,10 +243,18 @@ enum WalletFfiError wallet_ffi_create_account_public(struct WalletHandle *handle struct FfiBytes32 *out_account_id); /** - * Create a new private account. + * Create a new private account, storing a default account entry in local storage. * - * Private accounts use privacy-preserving transactions with nullifiers - * and commitments. + * This is the private-account equivalent of `wallet_ffi_create_account_public`. + * It generates a key node, assigns a random identifier, and inserts a default + * account record so the account can immediately be used with + * `wallet_ffi_register_private_account`. + * + * The identifier is chosen at random and is not encoded in the mnemonic seed. + * Once the account is initialized, the identifier is embedded in the encrypted + * transaction payload and can be recovered by running `sync-private` from the + * same mnemonic. An account that was created locally but has never been initialized + * cannot be recovered from the seed alone. * * # Parameters * - `handle`: Valid wallet handle @@ -263,6 +271,31 @@ enum WalletFfiError wallet_ffi_create_account_public(struct WalletHandle *handle enum WalletFfiError wallet_ffi_create_account_private(struct WalletHandle *handle, struct FfiBytes32 *out_account_id); +/** + * Create a new private key node. + * + * Returns the nullifier public key (npk) and viewing public key (vpk) to share with + * senders. Account IDs are discovered later via sync when senders initialize accounts + * under this key. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `out_keys`: Output pointer for the key data (npk + vpk) + * + * # Returns + * - `Success` on successful creation + * - Error code on failure + * + * # Memory + * The keys structure must be freed with `wallet_ffi_free_private_account_keys()`. + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + * - `out_keys` must be a valid pointer to a `FfiPrivateAccountKeys` struct + */ +enum WalletFfiError wallet_ffi_create_private_accounts_key(struct WalletHandle *handle, + struct FfiPrivateAccountKeys *out_keys); + /** * List all accounts in the wallet. * @@ -377,6 +410,50 @@ enum WalletFfiError wallet_ffi_get_account_private(struct WalletHandle *handle, */ void wallet_ffi_free_account_data(struct FfiAccount *account); +/** + * Import a public account private key into wallet storage. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `private_key_hex`: Hex-encoded private key string + * + * # Returns + * - `Success` on successful import + * - Error code on failure + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + * - `private_key_hex` must be a valid pointer to a null-terminated C string + */ +enum WalletFfiError wallet_ffi_import_public_account(struct WalletHandle *handle, + const char *private_key_hex); + +/** + * Import a private account keychain and account state into wallet storage. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `key_chain_json`: JSON-encoded `key_protocol::key_management::KeyChain` + * - `chain_index`: Optional chain index string (for example `/0/1`, `NULL` if unknown) + * - `identifier`: Identifier for this private account as little-endian u128 bytes + * - `account_state_json`: JSON-encoded `wallet::account::HumanReadableAccount` + * + * # Returns + * - `Success` on successful import + * - Error code on failure + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + * - `key_chain_json` must be a valid pointer to a null-terminated C string + * - `identifier` must be a valid pointer to a `FfiU128` struct + * - `account_state_json` must be a valid pointer to a null-terminated C string + */ +enum WalletFfiError wallet_ffi_import_private_account(struct WalletHandle *handle, + const char *key_chain_json, + const char *chain_index, + const struct FfiU128 *identifier, + const char *account_state_json); + /** * Get the public key for a public account. * @@ -685,6 +762,7 @@ enum WalletFfiError wallet_ffi_transfer_public(struct WalletHandle *handle, * - `handle`: Valid wallet handle * - `from`: Source account ID (must be owned by this wallet) * - `to_keys`: Destination account keys + * - `to_identifier`: Identifier for the recipient's private account * - `amount`: Amount to transfer as little-endian [u8; 16] * - `out_result`: Output pointer for transfer result * @@ -707,6 +785,7 @@ enum WalletFfiError wallet_ffi_transfer_public(struct WalletHandle *handle, enum WalletFfiError wallet_ffi_transfer_shielded(struct WalletHandle *handle, const struct FfiBytes32 *from, const struct FfiPrivateAccountKeys *to_keys, + const struct FfiU128 *to_identifier, const uint8_t (*amount)[16], struct FfiTransferResult *out_result); @@ -753,6 +832,7 @@ enum WalletFfiError wallet_ffi_transfer_deshielded(struct WalletHandle *handle, * - `handle`: Valid wallet handle * - `from`: Source account ID (must be owned by this wallet) * - `to_keys`: Destination account keys + * - `to_identifier`: Identifier for the recipient's private account * - `amount`: Amount to transfer as little-endian [u8; 16] * - `out_result`: Output pointer for transfer result * @@ -775,6 +855,7 @@ enum WalletFfiError wallet_ffi_transfer_deshielded(struct WalletHandle *handle, enum WalletFfiError wallet_ffi_transfer_private(struct WalletHandle *handle, const struct FfiBytes32 *from, const struct FfiPrivateAccountKeys *to_keys, + const struct FfiU128 *to_identifier, const uint8_t (*amount)[16], struct FfiTransferResult *out_result); diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index f77988a0..3aaa1753 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -11,12 +11,18 @@ workspace = true nssa_core.workspace = true nssa.workspace = true common.workspace = true +authenticated_transfer_core.workspace = true key_protocol.workspace = true sequencer_service_rpc = { workspace = true, features = ["client"] } token_core.workspace = true amm_core.workspace = true testnet_initial_state.workspace = true ata_core.workspace = true +bip39.workspace = true +pyo3.workspace = true +rpassword = "7" +zeroize = "1" +keycard_wallet.workspace = true anyhow.workspace = true thiserror.workspace = true @@ -38,3 +44,9 @@ async-stream.workspace = true indicatif = { version = "0.18.3", features = ["improved_unicode"] } optfield = "0.4.0" url.workspace = true +derive_more = { workspace = true, features = ["display"] } + +[dev-dependencies] +tempfile.workspace = true +key_protocol = { workspace = true, features = ["test_utils"] } +bincode.workspace = true diff --git a/wallet/src/account.rs b/wallet/src/account.rs new file mode 100644 index 00000000..dca0a051 --- /dev/null +++ b/wallet/src/account.rs @@ -0,0 +1,149 @@ +use std::str::FromStr; + +use base58::{FromBase58 as _, ToBase58 as _}; +use derive_more::Display; +use nssa::AccountId; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Display, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[display("{_0}")] +pub struct Label(String); + +impl Label { + #[expect( + clippy::needless_pass_by_value, + reason = "Convenience for caller and negligible cost" + )] + #[must_use] + pub fn new(label: impl ToString) -> Self { + Self(label.to_string()) + } +} + +impl FromStr for Label { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> std::result::Result { + Ok(Self(s.to_owned())) + } +} + +impl From<&str> for Label { + fn from(value: &str) -> Self { + Self(value.to_owned()) + } +} + +impl From for Label { + fn from(value: String) -> Self { + Self(value) + } +} + +#[derive(Debug, Display, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AccountIdWithPrivacy { + #[display("Public/{_0}")] + Public(AccountId), + #[display("Private/{_0}")] + Private(AccountId), +} + +#[derive(Debug, Error)] +pub enum AccountIdWithPrivacyParseError { + #[error("Invalid format, expected 'Public/{{account_id}}' or 'Private/{{account_id}}'")] + InvalidFormat, + #[error("Invalid account id")] + InvalidAccountId(#[from] nssa_core::account::AccountIdError), +} + +impl FromStr for AccountIdWithPrivacy { + type Err = AccountIdWithPrivacyParseError; + + fn from_str(s: &str) -> Result { + if let Some(stripped) = s.strip_prefix("Public/") { + Ok(Self::Public(AccountId::from_str(stripped)?)) + } else if let Some(stripped) = s.strip_prefix("Private/") { + Ok(Self::Private(AccountId::from_str(stripped)?)) + } else { + Err(AccountIdWithPrivacyParseError::InvalidFormat) + } + } +} + +/// Human-readable representation of an account. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HumanReadableAccount { + balance: u128, + program_owner: String, + data: String, + nonce: u128, +} + +impl FromStr for HumanReadableAccount { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + serde_json::from_str(s).map_err(Into::into) + } +} + +impl std::fmt::Display for HumanReadableAccount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let json = serde_json::to_string_pretty(self).map_err(|_err| std::fmt::Error)?; + write!(f, "{json}") + } +} + +impl From for HumanReadableAccount { + fn from(account: nssa::Account) -> Self { + let program_owner = account + .program_owner + .iter() + .flat_map(|n| n.to_le_bytes()) + .collect::>() + .to_base58(); + let data = hex::encode(account.data); + Self { + balance: account.balance, + program_owner, + data, + nonce: account.nonce.0, + } + } +} + +impl From for nssa::Account { + fn from(account: HumanReadableAccount) -> Self { + let mut program_owner_bytes = [0_u8; 32]; + let decoded_program_owner = account + .program_owner + .from_base58() + .expect("Invalid base58 in HumanReadableAccount.program_owner"); + assert!( + decoded_program_owner.len() == 32, + "HumanReadableAccount.program_owner must decode to exactly 32 bytes" + ); + program_owner_bytes.copy_from_slice(&decoded_program_owner); + + let mut program_owner = [0_u32; 8]; + for (index, chunk) in program_owner_bytes.chunks_exact(4).enumerate() { + let chunk: [u8; 4] = chunk + .try_into() + .expect("chunk length is guaranteed to be 4"); + program_owner[index] = u32::from_le_bytes(chunk); + } + + let data = hex::decode(&account.data).expect("Invalid hex in HumanReadableAccount.data"); + let data = data + .try_into() + .expect("Invalid account data: exceeds maximum allowed size"); + + Self { + balance: account.balance, + program_owner, + data, + nonce: nssa_core::account::Nonce(account.nonce), + } + } +} diff --git a/wallet/src/chain_storage.rs b/wallet/src/chain_storage.rs deleted file mode 100644 index ebfe9896..00000000 --- a/wallet/src/chain_storage.rs +++ /dev/null @@ -1,214 +0,0 @@ -use std::collections::{BTreeMap, HashMap, btree_map::Entry}; - -use anyhow::Result; -use key_protocol::{ - key_management::{ - key_tree::{KeyTreePrivate, KeyTreePublic, chain_index::ChainIndex}, - secret_holders::SeedHolder, - }, - key_protocol_core::NSSAUserData, -}; -use log::debug; -use nssa::program::Program; - -use crate::config::{InitialAccountData, Label, PersistentAccountData, WalletConfig}; - -pub struct WalletChainStore { - pub user_data: NSSAUserData, - pub wallet_config: WalletConfig, - pub labels: HashMap, -} - -impl WalletChainStore { - #[expect( - clippy::wildcard_enum_match_arm, - reason = "We perform search for specific variants only" - )] - pub fn new( - config: WalletConfig, - persistent_accounts: Vec, - labels: HashMap, - ) -> Result { - if persistent_accounts.is_empty() { - anyhow::bail!("Roots not found; please run setup beforehand"); - } - - let mut public_init_acc_map = BTreeMap::new(); - let mut private_init_acc_map = BTreeMap::new(); - - let public_root = persistent_accounts - .iter() - .find(|data| match data { - &PersistentAccountData::Public(data) => data.chain_index == ChainIndex::root(), - _ => false, - }) - .cloned() - .expect("Malformed persistent account data, must have public root"); - - let private_root = persistent_accounts - .iter() - .find(|data| match data { - &PersistentAccountData::Private(data) => data.chain_index == ChainIndex::root(), - _ => false, - }) - .cloned() - .expect("Malformed persistent account data, must have private root"); - - let mut public_tree = KeyTreePublic::new_from_root(match public_root { - PersistentAccountData::Public(data) => data.data, - _ => unreachable!(), - }); - let mut private_tree = KeyTreePrivate::new_from_root(match private_root { - PersistentAccountData::Private(data) => data.data, - _ => unreachable!(), - }); - - for pers_acc_data in persistent_accounts { - match pers_acc_data { - PersistentAccountData::Public(data) => { - public_tree.insert(data.account_id, data.chain_index, data.data); - } - PersistentAccountData::Private(data) => { - private_tree.insert(data.account_id, data.chain_index, data.data); - } - PersistentAccountData::Preconfigured(acc_data) => match acc_data { - InitialAccountData::Public(data) => { - public_init_acc_map.insert(data.account_id, data.pub_sign_key); - } - InitialAccountData::Private(data) => { - private_init_acc_map - .insert(data.account_id, (data.key_chain, data.account)); - } - }, - } - } - - Ok(Self { - user_data: NSSAUserData::new_with_accounts( - public_init_acc_map, - private_init_acc_map, - public_tree, - private_tree, - )?, - wallet_config: config, - labels, - }) - } - - pub fn new_storage(config: WalletConfig, password: String) -> Result { - let mut public_init_acc_map = BTreeMap::new(); - let mut private_init_acc_map = BTreeMap::new(); - - let initial_accounts = config - .initial_accounts - .clone() - .unwrap_or_else(InitialAccountData::create_initial_accounts_data); - - for init_acc_data in initial_accounts { - match init_acc_data { - InitialAccountData::Public(data) => { - public_init_acc_map.insert(data.account_id, data.pub_sign_key); - } - InitialAccountData::Private(data) => { - let mut account = data.account; - // TODO: Program owner is only known after code is compiled and can't be set - // in the config. Therefore we overwrite it here on - // startup. Fix this when program id can be fetched - // from the node and queried from the wallet. - account.program_owner = Program::authenticated_transfer_program().id(); - private_init_acc_map.insert(data.account_id, (data.key_chain, account)); - } - } - } - - let public_tree = KeyTreePublic::new(&SeedHolder::new_mnemonic(password.clone())); - let private_tree = KeyTreePrivate::new(&SeedHolder::new_mnemonic(password)); - - Ok(Self { - user_data: NSSAUserData::new_with_accounts( - public_init_acc_map, - private_init_acc_map, - public_tree, - private_tree, - )?, - wallet_config: config, - labels: HashMap::new(), - }) - } - - pub fn insert_private_account_data( - &mut self, - account_id: nssa::AccountId, - account: nssa_core::account::Account, - ) { - debug!("inserting at address {account_id}, this account {account:?}"); - - let entry = self - .user_data - .default_user_private_accounts - .entry(account_id) - .and_modify(|data| data.1 = account.clone()); - - if matches!(entry, Entry::Vacant(_)) { - self.user_data - .private_key_tree - .account_id_map - .get(&account_id) - .map(|chain_index| { - self.user_data - .private_key_tree - .key_map - .entry(chain_index.clone()) - .and_modify(|data| data.value.1 = account) - }); - } - } -} - -#[cfg(test)] -mod tests { - use key_protocol::key_management::key_tree::{ - keys_private::ChildKeysPrivate, keys_public::ChildKeysPublic, traits::KeyNode as _, - }; - - use super::*; - use crate::config::{PersistentAccountDataPrivate, PersistentAccountDataPublic}; - - fn create_sample_wallet_config() -> WalletConfig { - WalletConfig { - sequencer_addr: "http://127.0.0.1".parse().unwrap(), - seq_poll_timeout: std::time::Duration::from_secs(12), - seq_tx_poll_max_blocks: 5, - seq_poll_max_retries: 10, - seq_block_poll_max_amount: 100, - basic_auth: None, - initial_accounts: None, - } - } - - fn create_sample_persistent_accounts() -> Vec { - let public_data = ChildKeysPublic::root([42; 64]); - let private_data = ChildKeysPrivate::root([47; 64]); - - vec![ - PersistentAccountData::Public(PersistentAccountDataPublic { - account_id: public_data.account_id(), - chain_index: ChainIndex::root(), - data: public_data, - }), - PersistentAccountData::Private(Box::new(PersistentAccountDataPrivate { - account_id: private_data.account_id(), - chain_index: ChainIndex::root(), - data: private_data, - })), - ] - } - - #[test] - fn new_initializes_correctly() { - let config = create_sample_wallet_config(); - let accs = create_sample_persistent_accounts(); - - let _ = WalletChainStore::new(config, accs, HashMap::new()).unwrap(); - } -} diff --git a/wallet/src/cli/account.rs b/wallet/src/cli/account.rs index 2a8ed2c7..1dcea1d5 100644 --- a/wallet/src/cli/account.rs +++ b/wallet/src/cli/account.rs @@ -1,16 +1,15 @@ use anyhow::{Context as _, Result}; use clap::Subcommand; use itertools::Itertools as _; -use key_protocol::key_management::key_tree::chain_index::ChainIndex; +use key_protocol::key_management::{KeyChain, key_tree::chain_index::ChainIndex}; use nssa::{Account, PublicKey, program::Program}; -use sequencer_service_rpc::RpcClient as _; +use nssa_core::Identifier; use token_core::{TokenDefinition, TokenHolding}; use crate::{ WalletCore, - cli::{SubcommandReturnValue, WalletSubcommand}, - config::Label, - helperfunctions::{AccountPrivacyKind, HumanReadableAccount, parse_addr_with_privacy_prefix}, + account::{AccountIdWithPrivacy, HumanReadableAccount, Label}, + cli::{CliAccountMention, SubcommandReturnValue, WalletSubcommand}, }; /// Represents generic chain CLI subcommand. @@ -24,9 +23,9 @@ pub enum AccountSubcommand { /// Display keys (pk for public accounts, npk/vpk for private accounts). #[arg(short, long)] keys: bool, - /// Valid 32 byte base58 string with privacy prefix. + /// Either 32 byte base58 account id string with privacy prefix or a label. #[arg(short, long)] - account_id: String, + account_id: CliAccountMention, }, /// Produce new public or private account. #[command(subcommand)] @@ -42,13 +41,16 @@ pub enum AccountSubcommand { }, /// Set a label for an account. Label { - /// Valid 32 byte base58 string with privacy prefix. + /// Either 32 byte base58 account id string with privacy prefix or a label. #[arg(short, long)] - account_id: String, + account_id: CliAccountMention, /// The label to assign to the account. #[arg(short, long)] - label: String, + label: Label, }, + /// Import external account. + #[command(subcommand)] + Import(ImportSubcommand), } /// Represents generic register CLI subcommand. @@ -61,16 +63,45 @@ pub enum NewSubcommand { cci: Option, #[arg(short, long)] /// Label to assign to the new account. - label: Option, + label: Option