diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88be5dab..1a075d77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -353,3 +353,34 @@ jobs: exit 1 fi echo "βœ… Artifacts are up to date" + + # The dashboard generator is plain Rust (no Docker), so this runs directly in + # the CI image container rather than through `run-in-ci-image`. + dashboards: + needs: ci-image + runs-on: ubuntu-latest + container: + image: ${{ needs.ci-image.outputs.image }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + timeout-minutes: 30 + + name: dashboards + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha || github.head_ref }} + + - name: Regenerate dashboards + run: just regenerate-dashboards + + - name: Check if dashboards match repository + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + if ! git diff --exit-code monitoring/grafana/dashboards/; then + echo "❌ Dashboards in the repository are out of date!" + echo "Please run 'just regenerate-dashboards' and commit the changes." + exit 1 + fi + echo "βœ… Dashboards are up to date" diff --git a/.gitignore b/.gitignore index f32b258c..3befdb9c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ data/ rocksdb* sequencer/service/data/ storage.json +statistics.json result diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b15693b8..68843859 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,6 +6,10 @@ This document describes the guidelines for contributing to the project. We will If you have any questions, come say hi to our [Discord](https://discord.gg/tGJwgGrSPN)! +## Metrics + +We have guidelines about metrics, for more information refer to [metrics](docs/metrics/metrics.md). + ## Commit title format We use [Conventional Commits](https://www.conventionalcommits.org/). @@ -59,7 +63,7 @@ Could be squashed to an empty commit if they belong to the same PR. ## Default branch -By default all PRs must be directed into the `dev` branch. This helps us to keep releases stable. +By default all PRs must be directed into the `dev` branch. This helps us to keep releases stable. ## Branch workflow diff --git a/Cargo.lock b/Cargo.lock index e4eab65f..634cd99e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -794,6 +794,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.7.9" @@ -1589,6 +1612,15 @@ dependencies = [ "lee_core", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cmov" version = "0.5.4" @@ -2259,6 +2291,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dashboard_gen" +version = "0.1.0" +dependencies = [ + "clap", + "json-pretty-compact", + "sequencer_core_metrics", + "sequencer_service_metrics", + "serde", + "serde_json", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -2554,6 +2598,12 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -2858,6 +2908,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "evmap" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8" +dependencies = [ + "hashbag", + "left-right", + "smallvec", +] + [[package]] name = "example_program_deployment_methods" version = "0.1.0" @@ -3036,6 +3097,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.5.0" @@ -3072,6 +3139,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "funty" version = "2.0.0" @@ -3231,6 +3304,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -3436,6 +3524,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbag" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064" + [[package]] name = "hashbrown" version = "0.12.3" @@ -3459,7 +3553,16 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", ] [[package]] @@ -3817,6 +3920,7 @@ dependencies = [ "hyper-util", "log", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -4503,6 +4607,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-pretty-compact" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62a9c1f06b173b0da0ccc8cae00599d7b3ceda6e76d68be9b6bc2c941adafe0e" +dependencies = [ + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "jsonrpsee" version = "0.26.0" @@ -4890,6 +5004,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "left-right" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bc015ded5d9b3054dbbdb63332cdd6ee42352ccef19e911e25117490e2f48ee" +dependencies = [ + "crossbeam-utils", + "loom", + "slab", +] + [[package]] name = "leptos" version = "0.8.19" @@ -5896,7 +6021,7 @@ dependencies = [ "multiaddr", "num-bigint 0.4.6", "serde", - "strum", + "strum 0.27.2", "thiserror 2.0.18", "time", "tracing", @@ -6435,6 +6560,19 @@ dependencies = [ "prost-types 0.13.5", ] +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber 0.3.23", +] + [[package]] name = "lru" version = "0.12.5" @@ -6613,6 +6751,7 @@ dependencies = [ name = "mempool" version = "0.1.0" dependencies = [ + "futures", "tokio", ] @@ -6643,6 +6782,56 @@ dependencies = [ "paste", ] +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" +dependencies = [ + "base64 0.22.1", + "evmap", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "indexmap 2.14.0", + "ipnet", + "metrics", + "metrics-util", + "quanta", + "rustls", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "metrics-util" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96f8722f8562635f92f8ed992f26df0532266eb03d5202607c20c0d7e9745e13" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.16.1", + "metrics", + "quanta", + "rand 0.9.4", + "rand_xoshiro", + "rapidhash", + "sketches-ddsketch", +] + [[package]] name = "mime" version = "0.3.17" @@ -7965,6 +8154,21 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quick-protobuf" version = "0.8.1" @@ -8177,6 +8381,33 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.12.1", +] + [[package]] name = "rawpointer" version = "0.2.1" @@ -8978,6 +9209,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -9042,6 +9274,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -9091,7 +9324,7 @@ dependencies = [ "serde", "serde_with", "sha2 0.10.9", - "strum", + "strum 0.27.2", "tempfile", "thiserror 2.0.18", "toml 0.8.23", @@ -9162,6 +9395,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -9271,6 +9510,7 @@ dependencies = [ "programs", "rand 0.8.6", "risc0-zkvm", + "sequencer_core_metrics", "serde", "serde_json", "storage", @@ -9285,6 +9525,15 @@ dependencies = [ "vault_core", ] +[[package]] +name = "sequencer_core_metrics" +version = "0.1.0" +dependencies = [ + "common", + "metrics", + "strum 0.28.0", +] + [[package]] name = "sequencer_service" version = "0.1.0" @@ -9301,14 +9550,23 @@ dependencies = [ "lee", "log", "mempool", + "metrics-exporter-prometheus", "programs", "sequencer_core", + "sequencer_service_metrics", "sequencer_service_protocol", "sequencer_service_rpc", "tokio", "tokio-util", ] +[[package]] +name = "sequencer_service_metrics" +version = "0.1.0" +dependencies = [ + "metrics", +] + [[package]] name = "sequencer_service_protocol" version = "0.1.0" @@ -9724,6 +9982,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "sketches-ddsketch" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" + [[package]] name = "slab" version = "0.4.12" @@ -9923,7 +10187,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -9938,6 +10211,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index 397eb965..d5ebbe48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,9 +18,11 @@ members = [ "lez/system_accounts", "lez/chain_state", "lez/sequencer/core", + "lez/sequencer/core/metrics", "lez/sequencer/service", "lez/sequencer/service/protocol", "lez/sequencer/service/rpc", + "lez/sequencer/service/metrics", "lez/indexer/core", "lez/indexer/service", "lez/indexer/service/protocol", @@ -67,6 +69,7 @@ members = [ "tools/crypto_primitives_bench", "tools/integration_bench", "tools/cross_zone_chat", + "tools/dashboard_gen", ] [workspace.dependencies] @@ -78,8 +81,10 @@ mempool = { path = "lez/mempool" } storage = { path = "lez/storage" } key_protocol = { path = "lee/key_protocol" } sequencer_core = { path = "lez/sequencer/core" } +sequencer_core_metrics = { path = "lez/sequencer/core/metrics" } sequencer_service_protocol = { path = "lez/sequencer/service/protocol" } sequencer_service_rpc = { path = "lez/sequencer/service/rpc" } +sequencer_service_metrics = { path = "lez/sequencer/service/metrics" } sequencer_service = { path = "lez/sequencer/service" } indexer_core = { path = "lez/indexer/core" } indexer_service = { path = "lez/indexer/service" } @@ -142,6 +147,8 @@ actix-rt = "*" lazy_static = "1.5.0" env_logger = "0.11" log = "0.4.28" +metrics = "0.24.6" +metrics-exporter-prometheus = "0.18.3" lru = "0.16.3" thiserror = "2.0" sha2 = "0.10.8" @@ -173,6 +180,7 @@ url = { version = "2.5.4", features = ["serde"] } tokio-retry = "0.3.0" schemars = "1.2" async-stream = "0.3.6" +strum = { version = "0.28.0", features = ["derive"] } logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "0dc34e2c5a6e2ff772140378659489a602ee06bc" } logos-blockchain-key-management-system-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git", rev = "0dc34e2c5a6e2ff772140378659489a602ee06bc" } diff --git a/Justfile b/Justfile index d436689d..4741f6c8 100644 --- a/Justfile +++ b/Justfile @@ -53,6 +53,13 @@ regenerate-test-fixture: @echo "πŸ§ͺ Regenerating test fixture" RISC0_DEV_MODE=1 cargo run -p test_fixtures --bin regenerate_test_fixture +# Regenerate the committed Grafana dashboards from the Rust generator +# (tools/dashboard_gen) and commit the result. CI checks these are up to date. +regenerate-dashboards: + @echo "πŸ“Š Regenerating Grafana dashboards" + @cargo build -q -p dashboard_gen + @cargo run -q -p dashboard_gen -- sequencer > monitoring/grafana/dashboards/sequencer.json + # Run criterion benches: fast crypto primitives, then the slow PPE verify (real proving setup). bench: @echo "πŸ“Š Running criterion benches" @@ -65,19 +72,24 @@ run-bedrock: @echo "⛓️ Running bedrock" docker compose up -# Run Sequencer. Run with RISC0_DEV_MODE=1 to disable proof verification for faster iteration. -# Optional home/port let a second instance run off the same config, e.g. -# `just run-sequencer "" "$TMPDIR/lez-sequencer2" 3041` for the multi-sequencer demo. +# Run Prometheus + Grafana in docker. Grafana: http://localhost:3000 (anonymous +# admin), Prometheus: http://localhost:9090. Scrapes the sequencer's /metrics. +[working-directory: 'monitoring'] +run-monitoring: + @echo "πŸ“Š Running Prometheus (http://localhost:9090) + Grafana (http://localhost:3000)" + docker compose up + +# Run Sequencer. Extra args are forwarded to the binary. Run with RISC0_DEV_MODE=1 to disable proof verification for faster iteration. [working-directory: 'lez/sequencer/service'] -run-sequencer standalone="" home="" port="3040": +run-sequencer *args: @echo "🧠 Running sequencer" - @if [ "{{standalone}}" = "standalone" ]; then \ - echo "πŸ§ͺ Running in standalone mode"; \ - RUST_LOG=info cargo run --features standalone --release -p sequencer_service -- configs/debug/sequencer_config.json --port {{port}} {{ if home != "" { "--home " + quote(home) } else { "" } }}; \ - else \ - echo "πŸš€ Running in normal mode"; \ - RUST_LOG=info cargo run --release -p sequencer_service -- configs/debug/sequencer_config.json --port {{port}} {{ if home != "" { "--home " + quote(home) } else { "" } }}; \ - fi + RUST_LOG=info cargo run --release -p sequencer_service -- configs/debug/sequencer_config.json {{args}} + +# Run Sequencer with mocked Bedrock clients. Takes the same args as `run-sequencer`. +[working-directory: 'lez/sequencer/service'] +run-sequencer-standalone *args: + @echo "πŸ§ͺ Running sequencer in standalone mode" + RUST_LOG=info cargo run --features standalone --release -p sequencer_service -- configs/debug/sequencer_config.json {{args}} # Run Indexer. Run with RISC0_DEV_MODE=1 to disable proof verification for faster iteration. [working-directory: 'lez/indexer/service'] @@ -103,6 +115,11 @@ run-wallet +args: @echo "πŸ”‘ Running wallet" LEE_WALLET_HOME_DIR=$(pwd)/configs/debug cargo run --release -p wallet -- {{args}} +# Query sequencer metrics in raw format. Useful for quick debugging. For a more detailed view, use `just run-monitoring`. +get-sequencer-metrics: + @echo "πŸ“Š Querying sequencer's metrics" + curl http://localhost:9000/metrics + # Import test accounts supplied in sequencer configuration. wallet-import-test-accounts: @echo "βš™οΈ Initializing accounts" @@ -143,4 +160,5 @@ clean: rm -rf lez/wallet/configs/debug/storage.json rm -rf lez/wallet/configs/debug/statistics.json rm -rf rocksdb* - cd bedrock && docker compose down -v + cd bedrock && docker compose down -v && cd .. + cd monitoring && docker compose down -v && cd .. diff --git a/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin b/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin index b46f817a..7cbc7f94 100644 Binary files a/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin and b/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin differ diff --git a/artifacts/lez/programs/amm.bin b/artifacts/lez/programs/amm.bin index 819b2af3..2677bb68 100644 Binary files a/artifacts/lez/programs/amm.bin and b/artifacts/lez/programs/amm.bin differ diff --git a/artifacts/lez/programs/associated_token_account.bin b/artifacts/lez/programs/associated_token_account.bin index 985b8b0d..8b2bf53f 100644 Binary files a/artifacts/lez/programs/associated_token_account.bin and b/artifacts/lez/programs/associated_token_account.bin differ diff --git a/artifacts/lez/programs/authenticated_transfer.bin b/artifacts/lez/programs/authenticated_transfer.bin index 6bf6e53d..52509536 100644 Binary files a/artifacts/lez/programs/authenticated_transfer.bin and b/artifacts/lez/programs/authenticated_transfer.bin differ diff --git a/artifacts/lez/programs/bridge.bin b/artifacts/lez/programs/bridge.bin index 60c983f1..f75381af 100644 Binary files a/artifacts/lez/programs/bridge.bin and b/artifacts/lez/programs/bridge.bin differ diff --git a/artifacts/lez/programs/bridge_lock.bin b/artifacts/lez/programs/bridge_lock.bin index e0854454..6dda67f0 100644 Binary files a/artifacts/lez/programs/bridge_lock.bin and b/artifacts/lez/programs/bridge_lock.bin differ diff --git a/artifacts/lez/programs/clock.bin b/artifacts/lez/programs/clock.bin index 4eeb115c..f5fdb554 100644 Binary files a/artifacts/lez/programs/clock.bin and b/artifacts/lez/programs/clock.bin differ diff --git a/artifacts/lez/programs/cross_zone_inbox.bin b/artifacts/lez/programs/cross_zone_inbox.bin index a95c1998..532173a0 100644 Binary files a/artifacts/lez/programs/cross_zone_inbox.bin and b/artifacts/lez/programs/cross_zone_inbox.bin differ diff --git a/artifacts/lez/programs/cross_zone_outbox.bin b/artifacts/lez/programs/cross_zone_outbox.bin index b81340ec..ad36bdba 100644 Binary files a/artifacts/lez/programs/cross_zone_outbox.bin and b/artifacts/lez/programs/cross_zone_outbox.bin differ diff --git a/artifacts/lez/programs/faucet.bin b/artifacts/lez/programs/faucet.bin index 91e7aeeb..06d032b0 100644 Binary files a/artifacts/lez/programs/faucet.bin and b/artifacts/lez/programs/faucet.bin differ diff --git a/artifacts/lez/programs/pinata.bin b/artifacts/lez/programs/pinata.bin index d235bd9f..67e746ec 100644 Binary files a/artifacts/lez/programs/pinata.bin and b/artifacts/lez/programs/pinata.bin differ diff --git a/artifacts/lez/programs/pinata_token.bin b/artifacts/lez/programs/pinata_token.bin index e278ac48..fd945598 100644 Binary files a/artifacts/lez/programs/pinata_token.bin and b/artifacts/lez/programs/pinata_token.bin differ diff --git a/artifacts/lez/programs/ping_receiver.bin b/artifacts/lez/programs/ping_receiver.bin index 2ea1889a..a300a4e6 100644 Binary files a/artifacts/lez/programs/ping_receiver.bin and b/artifacts/lez/programs/ping_receiver.bin differ diff --git a/artifacts/lez/programs/ping_sender.bin b/artifacts/lez/programs/ping_sender.bin index 38a006d6..ffb096c9 100644 Binary files a/artifacts/lez/programs/ping_sender.bin and b/artifacts/lez/programs/ping_sender.bin differ diff --git a/artifacts/lez/programs/token.bin b/artifacts/lez/programs/token.bin index 156ea2da..865d618e 100644 Binary files a/artifacts/lez/programs/token.bin and b/artifacts/lez/programs/token.bin differ diff --git a/artifacts/lez/programs/vault.bin b/artifacts/lez/programs/vault.bin index 0f19b084..368a7235 100644 Binary files a/artifacts/lez/programs/vault.bin and b/artifacts/lez/programs/vault.bin differ diff --git a/artifacts/lez/programs/wrapped_token.bin b/artifacts/lez/programs/wrapped_token.bin index aa0de9ee..dbe74cf7 100644 Binary files a/artifacts/lez/programs/wrapped_token.bin and b/artifacts/lez/programs/wrapped_token.bin differ diff --git a/docker-compose.yml b/docker-compose.yml index 3644b2aa..4da0d04c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,3 +11,5 @@ include: lez/indexer/service/docker-compose.yml - path: lez/explorer_service/docker-compose.yml + - path: + monitoring/docker-compose.yml diff --git a/docs/metrics/metrics.md b/docs/metrics/metrics.md new file mode 100644 index 00000000..4d758157 --- /dev/null +++ b/docs/metrics/metrics.md @@ -0,0 +1,113 @@ +# Metrics + +Services expose Prometheus metrics; Grafana dashboards are generated from Rust so panel queries and metric names cannot drift apart. + +## Metrics crates + +Every crate that emits metrics gets a sibling `metrics` crate β€” `lez/sequencer/core/metrics` β†’ `sequencer_core_metrics`. Each has two halves: + +| Module | Gated by | Contents | +|---|---|---| +| `names` | always compiled | `pub const BLOCKS_PRODUCED_TOTAL: &str = "blocks_produced_total";` β€” one const per metric | +| `record` | `record` feature | `record_*` / `increment_*` functions, plus `init()` | + +The emitting crate depends on it with `features = ["record"]`; consumers that only need the names (i.e. `dashboard_gen`) take the default features and pull in nothing. Dashboards reference the same consts the recording code does, so **renaming a metric is a compile error rather than a silently empty panel**. + +## Naming + +The recorder runs with `with_recommended_naming(true)`, which enforces Prometheus convention: + +| Kind | Suffix | Example | +|---|---|---| +| Counter | `_total` | `blocks_produced_total`, `submitted_transactions_total` | +| Histogram | unit | `block_creation_time_seconds` | +| Gauge | none | `mempool_size` | + +**Spell the suffix in the const.** The exporter appends a missing unit suffix to the *rendered* name, but bucket matchers (below) run against the registered name β€” a duration metric named without `_seconds` renders correctly yet silently gets the wrong buckets. + +## Metric types + +| Type | Use for | Example | +|---|---|---| +| Counter | monotonically increasing event counts | `mempool_failed_transactions_total` | +| Gauge | a value that moves both ways | `mempool_size`, `chain_height` (a reorg lowers it) | +| Histogram | distributions β€” latencies, sizes, per-batch counts | `mempool_transaction_application_time_seconds` | + +Each metric gets a private constructor plus a public recording wrapper, so its description, unit and labels are declared once: + +```rust +fn blocks_produced_total_counter() -> Counter { + counter!( + description: "Number of blocks produced by this sequencer and applied to the head", + unit: Unit::Count, + names::BLOCKS_PRODUCED_TOTAL + ) +} + +pub fn increment_blocks_produced_total() { + blocks_produced_total_counter().increment(1); +} +``` + +Labels are passed as `"origin" => <&'static str>::from(origin)`; keep them low-cardinality (enums, never IDs or hashes). + +## `init()` + +Each `record` module exposes `init()`, called once at startup after the recorder is installed. It publishes every metric at zero. + +This is not cosmetic. A metric only materialises when first touched, and `rate()`/`increase()` need a sample from *before* an increment to see it β€” a series that springs into existence at `1` reads as `0` until the second event, so the first one is lost forever. Zero-publishing also means an idle service exports `0` instead of nothing at all. + +For histograms, creating the handle publishes zeroed buckets without recording an observation (recording a fake `0` would skew the distribution). Label combinations must each be registered, so `init()` iterates the label enums via `strum::EnumIter`. + +## Metrics in libraries + +**Yes, record metrics from library crates.** The `metrics` facade is a no-op until a recorder is installed, so a library that records costs nothing to a consumer that never installs one β€” including tests. Libraries record; only the binary installs the exporter. + +## Exporter setup + +`sequencer_service`'s `main.rs` installs the Prometheus recorder on the config's `metrics_address` (default `0.0.0.0:9000`) with **explicit histogram buckets**. This matters: without buckets, `metrics-exporter-prometheus` renders histograms as rolling-window summaries whose quantiles **reset to `0`** once the window (default 60 s) drains β€” an idle period reads as "took 0 s" rather than "no data". With buckets you get real `_bucket`/`_sum`/`_count` counters that never decay, are aggregatable, and honour the dashboard's time range. + +Ladders are matched by name suffix, so a new timing metric is covered automatically: + +```rust +.set_buckets(COUNT_BUCKETS) // fallback +.set_buckets_for_metric(Matcher::Suffix("_seconds".to_owned()), LATENCY_BUCKETS) +``` + +## `dashboard_gen` + +`tools/dashboard_gen` is a small Grafana dashboard builder plus the dashboard definitions. It prints JSON to stdout; the result is committed under `monitoring/grafana/dashboards/` and CI fails if it is stale. + +``` +src/lib.rs, schema.rs, styling.rs, unit.rs the builder library +src/dashboards/.rs one dashboard per module +src/main.rs CLI: `dashboard_gen sequencer` +``` + +Panels are built fluently, and every query is composed from the `names` consts: + +```rust +Panel::timeseries("Block production rate") + .width(18) + .target(rate_per_min(sequencer_core_metrics::names::BLOCKS_PRODUCED_TOTAL, "blocks/min")) +``` + +Query helpers: `rate_per_min` for counters, `avg` for histograms, and `selected_percentile` for percentile lines β€” the latter reads a `percentile` dashboard dropdown created by `percentile_variable`, so one panel serves p50/p90/p95/p99 instead of drawing all four. Rate windows use `$__rate_interval`, which tracks the panel's zoom. + +**Extending it:** + +| Goal | Change | +|---|---| +| New panel | Add a `Panel::…` to a row in the dashboard module | +| New dashboard | `src/dashboards/.rs` with `pub fn dashboard()`, a `pub mod` line, a `DashboardKind` variant, and a `just regenerate-dashboards` line | +| Grafana option we don't model yet | Add the field to `schema.rs` and a setter on `Panel` (styling setters live in `styling.rs` and panic when handed a redundant default) | + +The builder deliberately models only the subset of Grafana's schema we use. + +## Justfile + +| Recipe | Purpose | +|---|---| +| `just regenerate-dashboards` | Rebuild the committed dashboard JSON. Run after touching metric names or dashboard code β€” CI checks it is current. | +| `just run-monitoring` | Prometheus (`:9090`) + Grafana (`:3000`, anonymous admin) in docker, scraping the sequencer every 5 s | +| `just get-sequencer-metrics` | `curl` the raw `/metrics` endpoint β€” quickest way to confirm a metric name and value | diff --git a/integration_tests/tests/auth_transfer/private.rs b/integration_tests/tests/auth_transfer/private.rs index f172b27e..825a089f 100644 --- a/integration_tests/tests/auth_transfer/private.rs +++ b/integration_tests/tests/auth_transfer/private.rs @@ -81,9 +81,9 @@ async fn private_transfer_to_foreign_account() -> Result<()> { .context("Failed to get private account commitment for sender")?; let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await; - assert!(tx.message.new_commitments.contains(&new_commitment1)); + assert!(tx.message.commitments().contains(&new_commitment1)); - for commitment in tx.message.new_commitments { + for commitment in tx.message.commitments() { assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); } @@ -210,9 +210,9 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> { .wallet() .get_private_account_commitment(from) .context("Failed to get private account commitment for sender")?; - assert!(tx.message.new_commitments.contains(&sender_commitment)); + assert!(tx.message.commitments().contains(&sender_commitment)); - for commitment in tx.message.new_commitments { + for commitment in tx.message.commitments() { assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); } @@ -286,7 +286,7 @@ async fn shielded_transfer_to_foreign_account() -> Result<()> { let acc_1_balance = account_balance(&ctx, from).await?; - for commitment in tx.message.new_commitments { + for commitment in tx.message.commitments() { assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); } @@ -342,7 +342,7 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Verify commitments are in state - for commitment in tx.message.new_commitments { + for commitment in tx.message.commitments() { assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); } @@ -698,8 +698,9 @@ async fn init_with_dummy_commitment_root_produces_valid_root() -> Result<()> { let output = prove_init_with_commitment_root(&ctx, expected_digest).await?; - assert_eq!(output.new_nullifiers.len(), 1); - let (nullifier, digest) = &output.new_nullifiers[0]; + assert_eq!(output.private_actions.len(), 1); + let action = &output.private_actions[0]; + let (nullifier, digest) = (&action.nullifier, &action.root); assert_eq!( *nullifier, Nullifier::for_account_initialization(&recipient_account_id) @@ -719,14 +720,14 @@ async fn init_nullifier_digest_is_bound_to_commitment_root() -> Result<()> { let output_with_root = prove_init_with_commitment_root(&ctx, expected_digest).await?; let output_without_root = prove_init_with_commitment_root(&ctx, DUMMY_COMMITMENT_HASH).await?; - assert_eq!(output_with_root.new_nullifiers[0].1, expected_digest); + assert_eq!(output_with_root.private_actions[0].root, expected_digest); assert_eq!( - output_without_root.new_nullifiers[0].1, + output_without_root.private_actions[0].root, DUMMY_COMMITMENT_HASH ); assert_ne!( - output_with_root.new_nullifiers[0].1, - output_without_root.new_nullifiers[0].1, + output_with_root.private_actions[0].root, + output_without_root.private_actions[0].root, ); Ok(()) diff --git a/integration_tests/tests/bridge.rs b/integration_tests/tests/bridge.rs index e980ffac..7e59d6c6 100644 --- a/integration_tests/tests/bridge.rs +++ b/integration_tests/tests/bridge.rs @@ -191,16 +191,14 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { .context("Failed to execute/prove bridge deposit")?; // Create privacy-preserving transaction from circuit output - let message = privacy_preserving_transaction::Message::try_from_circuit_output( - vec![bridge_account_id, recipient_vault_id, receipt_id], + let message = privacy_preserving_transaction::Message::from_circuit_output( vec![ bridge_pre.account.nonce, vault_pre.account.nonce, receipt_pre.account.nonce, ], output, - ) - .context("Failed to build privacy-preserving bridge deposit message")?; + ); let witness_set = privacy_preserving_transaction::WitnessSet::for_message(&message, proof, &[]); let attack_tx = LeeTransaction::PrivacyPreserving(lee::PrivacyPreservingTransaction::new( diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs index 919b3dbc..0c334088 100644 --- a/integration_tests/tests/cross_zone_bridge.rs +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -30,7 +30,7 @@ use lee::{ AccountId, PrivateKey, PublicKey, PublicTransaction, public_transaction::{Message, WitnessSet}, }; -use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, GenesisAction}; +use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute, GenesisAction}; use sequencer_service_rpc::RpcClient as _; use tokio::test; @@ -58,7 +58,10 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { let cross_zone = CrossZoneConfig { peers: vec![CrossZonePeer { channel_id: *channel_a.as_ref(), - allowed_targets: vec![wrapped_token_id], + allowed_routes: vec![CrossZoneRoute { + src_program_id: programs::bridge_lock().id(), + target_program_id: wrapped_token_id, + }], expected_block_signing_pubkey: None, }], }; diff --git a/integration_tests/tests/cross_zone_ping.rs b/integration_tests/tests/cross_zone_ping.rs index f106bda1..c4724b13 100644 --- a/integration_tests/tests/cross_zone_ping.rs +++ b/integration_tests/tests/cross_zone_ping.rs @@ -23,7 +23,7 @@ use integration_tests::{ use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; -use sequencer_core::config::{CrossZoneConfig, CrossZonePeer}; +use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use tokio::test; @@ -49,7 +49,10 @@ async fn ping_crosses_from_zone_a_to_zone_b() -> Result<()> { let cross_zone = CrossZoneConfig { peers: vec![CrossZonePeer { channel_id: zone_a, - allowed_targets: vec![receiver_id], + allowed_routes: vec![CrossZoneRoute { + src_program_id: programs::ping_sender().id(), + target_program_id: receiver_id, + }], expected_block_signing_pubkey: None, }], }; diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index f1080c8c..f9d92a31 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -13,7 +13,7 @@ use std::collections::BTreeMap; use cross_zone_inbox_core::{ - CrossZoneMessage, InboxConfig, Instruction as InboxInstruction, SeenShard, + CrossZoneMessage, CrossZoneRoute, InboxConfig, Instruction as InboxInstruction, SeenShard, inbox_config_account_id, inbox_seen_shard_account_id, message_key, }; use cross_zone_outbox_core::{OutboxRecord, outbox_pda}; @@ -44,15 +44,21 @@ fn seed_inbox_config( state: &mut V03State, self_zone: [u8; 32], src_zone: [u8; 32], + src_program_id: lee_core::program::ProgramId, target: lee_core::program::ProgramId, ) { let inbox_id = programs::cross_zone_inbox().id(); - let mut allowed_targets = BTreeMap::new(); - allowed_targets.insert(src_zone, vec![target]); + let mut allowed_routes = BTreeMap::new(); + allowed_routes.insert( + src_zone, + vec![CrossZoneRoute { + src_program_id, + target_program_id: target, + }], + ); let config = InboxConfig { self_zone, - allowed_peers: BTreeMap::new(), - allowed_targets, + allowed_routes, }; *state = std::mem::replace(state, V03State::new()).with_public_accounts([( inbox_config_account_id(inbox_id), @@ -109,7 +115,7 @@ fn inbox_dispatch_delivers_payload_to_ping_receiver() { let src_block_id = 5; let mut state = base_state(); - seed_inbox_config(&mut state, self_zone, src_zone, receiver_id); + seed_inbox_config(&mut state, self_zone, src_zone, [9_u32; 8], receiver_id); // The payload is the ping_receiver instruction, serialized as risc0 words in // little-endian bytes (the contract the inbox reverses when forwarding). @@ -243,7 +249,13 @@ fn inbox_dispatch_mints_wrapped_token() { let src_block_id = 5; let mut state = base_state(); - seed_inbox_config(&mut state, self_zone, src_zone, wrapped_token_id); + seed_inbox_config( + &mut state, + self_zone, + src_zone, + [9_u32; 8], + wrapped_token_id, + ); seed_wrapped_config(&mut state); let msg = CrossZoneMessage { @@ -285,6 +297,126 @@ fn inbox_dispatch_mints_wrapped_token() { ); } +/// A zone that bridges must allow `wrapped_token` as a target. When that +/// allowance was per peer rather than per source program, it was enough for any +/// emitter on the peer to reach it, and `ping_sender` lets its caller choose the +/// target and payload freely. Any user on the peer could therefore mint wrapped +/// tokens with no lock and no escrow behind them, by routing a `Mint` payload +/// through the ping emitter. The route is the pair, so this must not execute. +#[test] +fn a_mint_from_an_unrouted_emitter_is_rejected() { + let inbox_id = programs::cross_zone_inbox().id(); + let wrapped_token_id = programs::wrapped_token().id(); + + let self_zone = [1_u8; 32]; + let src_zone = [2_u8; 32]; + let src_block_id = 5; + + let mut state = base_state(); + // The config a bridging zone writes: the lock program may mint, nothing else. + seed_inbox_config( + &mut state, + self_zone, + src_zone, + programs::bridge_lock().id(), + wrapped_token_id, + ); + seed_wrapped_config(&mut state); + + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_tx_index: 0, + // The emitter a user can drive directly, aimed at the bridge's target. + src_program_id: programs::ping_sender().id(), + target_program_id: wrapped_token_id, + payload: mint_payload(), + l1_inclusion_witness: None, + }; + + let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); + let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id); + let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); + + let message = Message::try_new( + inbox_id, + vec![ + inbox_config_account_id(inbox_id), + seen_id, + wrapped_config_id, + holding_id, + ], + vec![], + InboxInstruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + assert!( + ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0).is_err(), + "a delivery from an emitter with no route to wrapped_token must not mint" + ); +} + +/// The same target reached by the emitter the route names still works. Without +/// this, the test above would pass equally against an inbox that rejected every +/// delivery. +#[test] +fn a_mint_from_the_routed_emitter_is_accepted() { + let inbox_id = programs::cross_zone_inbox().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let bridge_lock_id = programs::bridge_lock().id(); + + let self_zone = [1_u8; 32]; + let src_zone = [2_u8; 32]; + let src_block_id = 5; + + let mut state = base_state(); + seed_inbox_config( + &mut state, + self_zone, + src_zone, + bridge_lock_id, + wrapped_token_id, + ); + seed_wrapped_config(&mut state); + + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_tx_index: 0, + src_program_id: bridge_lock_id, + target_program_id: wrapped_token_id, + payload: mint_payload(), + l1_inclusion_witness: None, + }; + + let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); + let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id); + let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); + + let message = Message::try_new( + inbox_id, + vec![ + inbox_config_account_id(inbox_id), + seen_id, + wrapped_config_id, + holding_id, + ], + vec![], + InboxInstruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) + .expect("the routed emitter must still deliver"); + let minted = wrapped_token_core::read_balance( + &diff.public_diff()[&holding_id].data.clone().into_inner(), + ); + assert_eq!(minted, LOCK_AMOUNT); +} + /// A dispatch whose message key is already in the seen-shard is an idempotent /// no-op: the inbox makes no chained call, so the wrapped token is not minted a /// second time. This is the bridge's replay defense. @@ -299,7 +431,13 @@ fn mint_replay_rejected() { let src_tx_index = 0; let mut state = base_state(); - seed_inbox_config(&mut state, self_zone, src_zone, wrapped_token_id); + seed_inbox_config( + &mut state, + self_zone, + src_zone, + [9_u32; 8], + wrapped_token_id, + ); seed_wrapped_config(&mut state); // Seed the seen-shard as already containing this message's key, so the inbox diff --git a/integration_tests/tests/cross_zone_verified.rs b/integration_tests/tests/cross_zone_verified.rs index cc21c42a..92ccdacb 100644 --- a/integration_tests/tests/cross_zone_verified.rs +++ b/integration_tests/tests/cross_zone_verified.rs @@ -22,7 +22,7 @@ use integration_tests::{ use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; -use sequencer_core::config::{CrossZoneConfig, CrossZonePeer}; +use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::RpcClient as _; use tokio::test; @@ -46,7 +46,10 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> { let cross_zone = CrossZoneConfig { peers: vec![CrossZonePeer { channel_id: zone_a, - allowed_targets: vec![receiver_id], + allowed_routes: vec![CrossZoneRoute { + src_program_id: programs::ping_sender().id(), + target_program_id: receiver_id, + }], expected_block_signing_pubkey: None, }], }; diff --git a/integration_tests/tests/cross_zone_watcher_restart.rs b/integration_tests/tests/cross_zone_watcher_restart.rs index 155a29fc..86dfc705 100644 --- a/integration_tests/tests/cross_zone_watcher_restart.rs +++ b/integration_tests/tests/cross_zone_watcher_restart.rs @@ -26,7 +26,7 @@ use integration_tests::{ use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; -use sequencer_core::config::{CrossZoneConfig, CrossZonePeer}; +use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use tokio::test; @@ -55,7 +55,10 @@ async fn restarted_watcher_resumes_instead_of_replaying_the_peer_channel() -> Re let cross_zone = CrossZoneConfig { peers: vec![CrossZonePeer { channel_id: zone_a, - allowed_targets: vec![receiver_id], + allowed_routes: vec![CrossZoneRoute { + src_program_id: programs::ping_sender().id(), + target_program_id: receiver_id, + }], expected_block_signing_pubkey: None, }], }; diff --git a/integration_tests/tests/keys.rs b/integration_tests/tests/keys.rs index 1631dc07..c7e5d3c2 100644 --- a/integration_tests/tests/keys.rs +++ b/integration_tests/tests/keys.rs @@ -71,9 +71,9 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> { .wallet() .get_private_account_commitment(from) .context("Failed to get private account commitment for sender")?; - assert!(tx.message.new_commitments.contains(&new_commitment1)); + assert!(tx.message.commitments().contains(&new_commitment1)); - for commitment in tx.message.new_commitments { + for commitment in tx.message.commitments() { assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); } diff --git a/integration_tests/tests/multi_sequencer.rs b/integration_tests/tests/multi_sequencer.rs index 1b01b62d..34eae1e5 100644 --- a/integration_tests/tests/multi_sequencer.rs +++ b/integration_tests/tests/multi_sequencer.rs @@ -72,6 +72,7 @@ async fn multi_sequencer_committee_converges() -> Result<()> { node_url: config::addr_to_url(config::UrlProtocol::Http, bedrock_addr)?, funding_key: config::bedrock_funding_key(), auth: None, + priority_fee: sequencer_core::config::default_priority_fee(), }, &Ed25519Key::from_bytes(&key_a), vec![pub_a, pub_b], diff --git a/integration_tests/tests/private_pda.rs b/integration_tests/tests/private_pda.rs index af78aa74..d7ca565d 100644 --- a/integration_tests/tests/private_pda.rs +++ b/integration_tests/tests/private_pda.rs @@ -83,9 +83,7 @@ async fn fund_private_pda( ) .map_err(|e| anyhow::anyhow!("circuit proving failed: {e}"))?; - let message = - Message::try_from_circuit_output(vec![sender], vec![sender_account.nonce], output) - .map_err(|e| anyhow::anyhow!("message build failed: {e}"))?; + let message = Message::from_circuit_output(vec![sender_account.nonce], output); let witness_set = WitnessSet::for_message(&message, proof, &[sender_sk]); let tx = PrivacyPreservingTransaction::new(message, witness_set); diff --git a/integration_tests/tests/private_transaction_padding.rs b/integration_tests/tests/private_transaction_padding.rs index d9fceefe..925d058d 100644 --- a/integration_tests/tests/private_transaction_padding.rs +++ b/integration_tests/tests/private_transaction_padding.rs @@ -26,9 +26,7 @@ async fn private_transaction_pads_notes_to_max() -> Result<()> { let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await; - assert_eq!(tx.message.new_commitments.len(), 7); - assert_eq!(tx.message.new_nullifiers.len(), 7); - assert_eq!(tx.message.encrypted_private_post_states.len(), 7); + assert_eq!(tx.message.private_actions.len(), 7); Ok(()) } diff --git a/integration_tests/tests/tps.rs b/integration_tests/tests/tps.rs index bfcd4c64..55d88d3f 100644 --- a/integration_tests/tests/tps.rs +++ b/integration_tests/tests/tps.rs @@ -310,7 +310,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction { &program.into(), ) .unwrap(); - let message = pptx::message::Message::try_from_circuit_output(vec![], vec![], output).unwrap(); + let message = pptx::message::Message::from_circuit_output(vec![], output); let witness_set = pptx::witness_set::WitnessSet::for_message(&message, proof, &[]); pptx::PrivacyPreservingTransaction::new(message, witness_set) } diff --git a/lee/privacy_preserving_circuit/src/output.rs b/lee/privacy_preserving_circuit/src/output.rs index fe31e71a..d4b9f1af 100644 --- a/lee/privacy_preserving_circuit/src/output.rs +++ b/lee/privacy_preserving_circuit/src/output.rs @@ -1,7 +1,8 @@ use lee_core::{ Commitment, CommitmentSetDigest, DummyInput, EncryptedAccountData, EncryptionScheme, EphemeralSecretKey, InputAccountIdentity, MembershipProof, Nullifier, NullifierPublicKey, - NullifierSecretKey, PrivacyPreservingCircuitOutput, PrivateAccountKind, SharedSecretKey, + NullifierSecretKey, PrivacyPreservingCircuitOutput, PrivateAccountKind, PrivateAction, + PublicAction, SharedSecretKey, account::{Account, AccountId, Nonce}, compute_digest_for_path, encryption::{ViewTag, ViewingPublicKey}, @@ -17,11 +18,8 @@ pub fn compute_circuit_output( 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(), - encrypted_private_post_states: Vec::new(), - new_commitments: Vec::new(), - new_nullifiers: Vec::new(), + public_actions: Vec::new(), + private_actions: Vec::new(), block_validity_window, timestamp_validity_window, }; @@ -37,8 +35,10 @@ pub fn compute_circuit_output( { match account_identity { InputAccountIdentity::Public => { - output.public_pre_states.push(pre_state); - output.public_post_states.push(post_state); + output.public_actions.push(PublicAction { + pre: pre_state, + post: post_state, + }); } InputAccountIdentity::PrivateAuthorizedInit { vpk, @@ -267,16 +267,20 @@ pub fn compute_circuit_output( } fn obfuscate_output_ordering(output: &mut PrivacyPreservingCircuitOutput) { - output - .new_commitments - .sort_unstable_by_key(Commitment::to_byte_array); - - let mut notes: Vec<_> = core::mem::take(&mut output.new_nullifiers) - .into_iter() - .zip(core::mem::take(&mut output.encrypted_private_post_states)) + let mut commitments: Vec<_> = output + .private_actions + .iter() + .map(|action| action.commitment) .collect(); - notes.sort_unstable_by_key(|((nullifier, _), _)| nullifier.to_byte_array()); - (output.new_nullifiers, output.encrypted_private_post_states) = notes.into_iter().unzip(); + commitments.sort_unstable_by_key(Commitment::to_byte_array); + + output + .private_actions + .sort_unstable_by_key(|action| action.nullifier.to_byte_array()); + + for (action, commitment) in output.private_actions.iter_mut().zip(commitments) { + action.commitment = commitment; + } } fn emit_dummy_output(output: &mut PrivacyPreservingCircuitOutput, dummy: DummyInput) { @@ -284,17 +288,18 @@ fn emit_dummy_output(output: &mut PrivacyPreservingCircuitOutput, dummy: DummyIn // The prover is responsible for their randomness. let nullifier = Nullifier::for_dummy(&dummy.nullifier_seed); let commitment = Commitment::for_dummy(&nullifier, &dummy.commitment_seed); - output - .new_nullifiers - .push((nullifier, dummy.commitment_root)); - output.new_commitments.push(commitment); // Note: the encrypted post states are pushed as fed into the circuit. // That means that the prover is responsible for managing the randomness // so as to not reveal the padding. // // In particular, it is recommended to generate the ML KEM ciphertext // explicitly as these are not uniformly random. - output.encrypted_private_post_states.push(dummy.note); + output.private_actions.push(PrivateAction { + nullifier, + root: dummy.commitment_root, + commitment, + encrypted_post_state: dummy.note, + }); } #[expect( @@ -327,15 +332,16 @@ fn emit_private_output( &new_nullifier.0, ); - output.new_nullifiers.push(new_nullifier); - output.new_commitments.push(commitment_post); - output - .encrypted_private_post_states - .push(EncryptedAccountData { + output.private_actions.push(PrivateAction { + nullifier: new_nullifier.0, + root: new_nullifier.1, + commitment: commitment_post, + encrypted_post_state: EncryptedAccountData { ciphertext: encrypted_account, epk, view_tag, - }); + }, + }); } fn compute_update_nullifier_and_set_digest( @@ -358,7 +364,7 @@ mod tests { use super::*; - fn note(tag: u8) -> (Nullifier, Commitment, EncryptedAccountData) { + fn note(tag: u8) -> PrivateAction { let nullifier = Nullifier::for_dummy(&[tag; 32]); let commitment = Commitment::for_dummy(&nullifier, &[tag; 32]); let ciphertext = EncryptionScheme::encrypt( @@ -367,37 +373,36 @@ mod tests { &SharedSecretKey([0; 32]), &nullifier, ); - let encrypted = EncryptedAccountData { - ciphertext, - epk: EphemeralPublicKey(vec![tag]), - view_tag: 0, - }; - (nullifier, commitment, encrypted) + PrivateAction { + nullifier, + root: DUMMY_COMMITMENT_HASH, + commitment, + encrypted_post_state: EncryptedAccountData { + ciphertext, + epk: EphemeralPublicKey(vec![tag]), + view_tag: 0, + }, + } } #[test] fn obfuscate_byte_sorts_commitments_and_nullifiers() { let mut output = PrivacyPreservingCircuitOutput::default(); for tag in 0..3 { - let (nullifier, commitment, encrypted) = note(tag); - output - .new_nullifiers - .push((nullifier, DUMMY_COMMITMENT_HASH)); - output.new_commitments.push(commitment); - output.encrypted_private_post_states.push(encrypted); + output.private_actions.push(note(tag)); } obfuscate_output_ordering(&mut output); assert!( output - .new_commitments - .is_sorted_by_key(Commitment::to_byte_array) + .private_actions + .is_sorted_by_key(|action| action.nullifier.to_byte_array()) ); assert!( output - .new_nullifiers - .is_sorted_by_key(|(nullifier, _)| nullifier.to_byte_array()) + .private_actions + .is_sorted_by_key(|action| action.commitment.to_byte_array()) ); } @@ -405,27 +410,26 @@ mod tests { fn obfuscate_keeps_each_nullifier_with_its_ciphertext() { let mut output = PrivacyPreservingCircuitOutput::default(); for tag in 0..3 { - let (nullifier, _, encrypted) = note(tag); - output - .new_nullifiers - .push((nullifier, DUMMY_COMMITMENT_HASH)); - output.encrypted_private_post_states.push(encrypted); + output.private_actions.push(note(tag)); } let paired: HashMap<[u8; 32], EphemeralPublicKey> = output - .new_nullifiers + .private_actions .iter() - .zip(&output.encrypted_private_post_states) - .map(|((nullifier, _), note)| (nullifier.to_byte_array(), note.epk.clone())) + .map(|action| { + ( + action.nullifier.to_byte_array(), + action.encrypted_post_state.epk.clone(), + ) + }) .collect(); obfuscate_output_ordering(&mut output); - for ((nullifier, _), note) in output - .new_nullifiers - .iter() - .zip(&output.encrypted_private_post_states) - { - assert_eq!(paired[&nullifier.to_byte_array()], note.epk); + for action in &output.private_actions { + assert_eq!( + paired[&action.nullifier.to_byte_array()], + action.encrypted_post_state.epk + ); } } } diff --git a/lee/state_machine/core/src/circuit_io.rs b/lee/state_machine/core/src/circuit_io.rs index baa2d0c5..0044f025 100644 --- a/lee/state_machine/core/src/circuit_io.rs +++ b/lee/state_machine/core/src/circuit_io.rs @@ -1,3 +1,4 @@ +use borsh::{BorshDeserialize, BorshSerialize}; use serde::{Deserialize, Serialize}; use crate::{ @@ -147,18 +148,56 @@ impl InputAccountIdentity { } } +#[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[cfg_attr( + any(feature = "host", test), + derive(Debug, Clone, Default, PartialEq, Eq) +)] +pub struct PrivateAction { + pub nullifier: Nullifier, + pub root: CommitmentSetDigest, + // IMPORTANT: The commitment in the action is not necessarily connected + // to the nullifier in content. That is, the commitment's plaintext is + // not necessarily the updated account state of the nullifier's plaintext. + pub commitment: Commitment, + pub encrypted_post_state: EncryptedAccountData, +} + +#[derive(Serialize, Deserialize)] +#[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq))] +pub struct PublicAction { + pub pre: AccountWithMetadata, + pub post: Account, +} + #[derive(Serialize, Deserialize)] #[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq, Default))] pub struct PrivacyPreservingCircuitOutput { - pub public_pre_states: Vec, - pub public_post_states: Vec, - pub encrypted_private_post_states: Vec, - pub new_commitments: Vec, - pub new_nullifiers: Vec<(Nullifier, CommitmentSetDigest)>, + pub public_actions: Vec, + pub private_actions: Vec, pub block_validity_window: BlockValidityWindow, pub timestamp_validity_window: TimestampValidityWindow, } +#[cfg(any(feature = "host", test))] +impl PrivacyPreservingCircuitOutput { + #[must_use] + pub fn commitments(&self) -> Vec { + self.private_actions + .iter() + .map(|action| action.commitment) + .collect() + } + + #[must_use] + pub fn nullifiers(&self) -> Vec<(Nullifier, CommitmentSetDigest)> { + self.private_actions + .iter() + .map(|action| (action.nullifier, action.root)) + .collect() + } +} + #[cfg(feature = "host")] impl PrivacyPreservingCircuitOutput { /// Serializes the circuit output to a byte vector. @@ -183,50 +222,57 @@ mod tests { #[test] fn privacy_preserving_circuit_output_to_bytes_is_compatible_with_from_slice() { let output = PrivacyPreservingCircuitOutput { - public_pre_states: vec![ - AccountWithMetadata::new( - Account { + public_actions: vec![ + PublicAction { + pre: AccountWithMetadata::new( + Account { + program_owner: [1, 2, 3, 4, 5, 6, 7, 8], + balance: 12_345_678_901_234_567_890, + data: b"test data".to_vec().try_into().unwrap(), + nonce: Nonce(0xFFFF_FFFF_FFFF_FFFE), + }, + true, + AccountId::new([0; 32]), + ), + post: Account { program_owner: [1, 2, 3, 4, 5, 6, 7, 8], - balance: 12_345_678_901_234_567_890, - data: b"test data".to_vec().try_into().unwrap(), - nonce: Nonce(0xFFFF_FFFF_FFFF_FFFE), + balance: 100, + data: b"post state data".to_vec().try_into().unwrap(), + nonce: Nonce(0xFFFF_FFFF_FFFF_FFFF), }, - true, - AccountId::new([0; 32]), - ), - AccountWithMetadata::new( - Account { - program_owner: [9, 9, 9, 8, 8, 8, 7, 7], - balance: 123_123_123_456_456_567_112, - data: b"test data".to_vec().try_into().unwrap(), - nonce: Nonce(9_999_999_999_999_999_999_999), + }, + PublicAction { + pre: AccountWithMetadata::new( + Account { + program_owner: [9, 9, 9, 8, 8, 8, 7, 7], + balance: 123_123_123_456_456_567_112, + data: b"test data".to_vec().try_into().unwrap(), + nonce: Nonce(9_999_999_999_999_999_999_999), + }, + false, + AccountId::new([1; 32]), + ), + post: Account { + program_owner: [2, 3, 4, 5, 6, 7, 8, 9], + balance: 200, + data: b"post state data 2".to_vec().try_into().unwrap(), + nonce: Nonce(0xFFFF_FFFF_FFFF_FFFD), }, - false, - AccountId::new([1; 32]), - ), + }, ], - public_post_states: vec![Account { - program_owner: [1, 2, 3, 4, 5, 6, 7, 8], - balance: 100, - data: b"post state data".to_vec().try_into().unwrap(), - nonce: Nonce(0xFFFF_FFFF_FFFF_FFFF), - }], - encrypted_private_post_states: vec![EncryptedAccountData { - ciphertext: Ciphertext(vec![255, 255, 1, 1, 2, 2]), - epk: EphemeralPublicKey(vec![9, 9, 9]), - view_tag: 42, - }], - new_commitments: vec![Commitment::new( - &AccountId::new([1; 32]), - &Account::default(), - )], - new_nullifiers: vec![( - Nullifier::for_account_update( + private_actions: vec![PrivateAction { + nullifier: Nullifier::for_account_update( &Commitment::new(&AccountId::new([2; 32]), &Account::default()), &[1; 32], ), - [0xab; 32], - )], + root: [0xab; 32], + commitment: Commitment::new(&AccountId::new([1; 32]), &Account::default()), + encrypted_post_state: EncryptedAccountData { + ciphertext: Ciphertext(vec![255, 255, 1, 1, 2, 2]), + epk: EphemeralPublicKey(vec![9, 9, 9]), + view_tag: 42, + }, + }], block_validity_window: (1..).into(), timestamp_validity_window: TimestampValidityWindow::new_unbounded(), }; diff --git a/lee/state_machine/core/src/commitment.rs b/lee/state_machine/core/src/commitment.rs index da861eed..bee311f2 100644 --- a/lee/state_machine/core/src/commitment.rs +++ b/lee/state_machine/core/src/commitment.rs @@ -32,10 +32,10 @@ pub const DUMMY_COMMITMENT_HASH: [u8; 32] = [ 129, 241, 118, 39, 41, 253, 141, 171, 184, 71, 8, 41, ]; -#[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[derive(Copy, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] #[cfg_attr( any(feature = "host", test), - derive(Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord) + derive(Default, PartialEq, Eq, Hash, PartialOrd, Ord) )] pub struct Commitment(pub(super) [u8; 32]); diff --git a/lee/state_machine/core/src/encryption/mod.rs b/lee/state_machine/core/src/encryption/mod.rs index 19f7e741..639404e1 100644 --- a/lee/state_machine/core/src/encryption/mod.rs +++ b/lee/state_machine/core/src/encryption/mod.rs @@ -45,13 +45,15 @@ pub struct SharedSecretKey(pub [u8; 32]); /// The ML-KEM-768 ciphertext produced during encapsulation; transmitted on-wire in place of the /// former ECDH ephemeral public key. Always `ML_KEM_768_CIPHERTEXT_LEN` (1088) bytes. -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +#[derive( + Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize, +)] pub struct EphemeralPublicKey(pub Vec); pub struct EncryptionScheme; #[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize)] -#[cfg_attr(any(feature = "host", test), derive(Clone, PartialEq, Eq))] +#[cfg_attr(any(feature = "host", test), derive(Clone, Default, PartialEq, Eq))] pub struct Ciphertext(pub(crate) Vec); #[cfg(any(feature = "host", test))] @@ -71,7 +73,10 @@ pub type ViewTag = u8; /// Encrypted private-account note for one output. #[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize)] -#[cfg_attr(any(feature = "host", test), derive(Debug, Clone, PartialEq, Eq))] +#[cfg_attr( + any(feature = "host", test), + derive(Debug, Clone, Default, PartialEq, Eq) +)] pub struct EncryptedAccountData { pub ciphertext: Ciphertext, pub epk: EphemeralPublicKey, diff --git a/lee/state_machine/core/src/lib.rs b/lee/state_machine/core/src/lib.rs index 62dbd3cc..df247532 100644 --- a/lee/state_machine/core/src/lib.rs +++ b/lee/state_machine/core/src/lib.rs @@ -4,7 +4,8 @@ )] pub use circuit_io::{ - DummyInput, InputAccountIdentity, PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput, + DummyInput, InputAccountIdentity, PrivacyPreservingCircuitInput, + PrivacyPreservingCircuitOutput, PrivateAction, PublicAction, }; pub use commitment::{ Commitment, CommitmentSetDigest, DUMMY_COMMITMENT, DUMMY_COMMITMENT_HASH, MembershipProof, diff --git a/lee/state_machine/core/src/nullifier.rs b/lee/state_machine/core/src/nullifier.rs index 59e7b5c2..755a2adc 100644 --- a/lee/state_machine/core/src/nullifier.rs +++ b/lee/state_machine/core/src/nullifier.rs @@ -72,7 +72,7 @@ pub type NullifierSecretKey = [u8; 32]; #[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize)] #[cfg_attr( any(feature = "host", test), - derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash) + derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash) )] pub struct Nullifier(pub(super) [u8; 32]); diff --git a/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs index 5a74727a..033984ce 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs @@ -24,9 +24,9 @@ fn decrypt_kind( idx: usize, ) -> PrivateAccountKind { let (kind, _) = EncryptionScheme::decrypt( - &output.encrypted_private_post_states[idx].ciphertext, + &output.private_actions[idx].encrypted_post_state.ciphertext, ssk, - &output.new_nullifiers[idx].0, + &output.private_actions[idx].nullifier, ) .unwrap(); kind @@ -102,18 +102,16 @@ fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts() assert!(proof.is_valid_for(&output)); - let [sender_pre] = output.public_pre_states.try_into().unwrap(); - let [sender_post] = output.public_post_states.try_into().unwrap(); + let [action] = output.public_actions.try_into().unwrap(); + let (sender_pre, sender_post) = (action.pre, action.post); assert_eq!(sender_pre, expected_sender_pre); assert_eq!(sender_post, expected_sender_post); - assert_eq!(output.new_commitments.len(), 1); - assert_eq!(output.new_nullifiers.len(), 1); - assert_eq!(output.encrypted_private_post_states.len(), 1); + assert_eq!(output.private_actions.len(), 1); let (_identifier, recipient_post) = EncryptionScheme::decrypt( - &output.encrypted_private_post_states[0].ciphertext, + &output.private_actions[0].encrypted_post_state.ciphertext, &shared_secret, - &output.new_nullifiers[0].0, + &output.private_actions[0].nullifier, ) .unwrap(); assert_eq!(recipient_post, expected_recipient_post); @@ -216,43 +214,46 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { .unwrap(); assert!(proof.is_valid_for(&output)); - assert!(output.public_pre_states.is_empty()); - assert!(output.public_post_states.is_empty()); + assert!(output.public_actions.is_empty()); let sender_nullifier = expected_new_nullifiers[0].0; let recipient_nullifier = expected_new_nullifiers[1].0; - let mut expected_new_commitments = expected_new_commitments; - expected_new_commitments.sort_unstable_by_key(Commitment::to_byte_array); - assert_eq!(output.new_commitments, expected_new_commitments); + let mut sorted_commitments = expected_new_commitments; + sorted_commitments.sort_unstable_by_key(Commitment::to_byte_array); + assert_eq!(output.commitments(), sorted_commitments); - let mut expected_new_nullifiers = expected_new_nullifiers; - expected_new_nullifiers.sort_unstable_by_key(|(nullifier, _)| nullifier.to_byte_array()); - assert_eq!(output.new_nullifiers, expected_new_nullifiers); + let mut sorted_nullifiers = expected_new_nullifiers; + sorted_nullifiers.sort_unstable_by_key(|(nullifier, _)| nullifier.to_byte_array()); + assert_eq!(output.nullifiers(), sorted_nullifiers); - assert_eq!(output.encrypted_private_post_states.len(), 2); + assert_eq!(output.private_actions.len(), 2); let sender_slot = output - .new_nullifiers + .private_actions .iter() - .position(|(nullifier, _)| *nullifier == sender_nullifier) + .position(|action| action.nullifier == sender_nullifier) .unwrap(); let (_identifier, sender_post) = EncryptionScheme::decrypt( - &output.encrypted_private_post_states[sender_slot].ciphertext, + &output.private_actions[sender_slot] + .encrypted_post_state + .ciphertext, &shared_secret_1, - &output.new_nullifiers[sender_slot].0, + &output.private_actions[sender_slot].nullifier, ) .unwrap(); assert_eq!(sender_post, expected_private_account_1); let recipient_slot = output - .new_nullifiers + .private_actions .iter() - .position(|(nullifier, _)| *nullifier == recipient_nullifier) + .position(|action| action.nullifier == recipient_nullifier) .unwrap(); let (_identifier, recipient_post) = EncryptionScheme::decrypt( - &output.encrypted_private_post_states[recipient_slot].ciphertext, + &output.private_actions[recipient_slot] + .encrypted_post_state + .ciphertext, &shared_secret_2, - &output.new_nullifiers[recipient_slot].0, + &output.private_actions[recipient_slot].nullifier, ) .unwrap(); assert_eq!(recipient_post, expected_private_account_2); @@ -281,9 +282,9 @@ fn init_note_view_tag_is_derived_from_account_keys() { .unwrap(); assert!(proof.is_valid_for(&output)); - assert_eq!(output.encrypted_private_post_states.len(), 1); + assert_eq!(output.private_actions.len(), 1); assert_eq!( - output.encrypted_private_post_states[0].view_tag, + output.private_actions[0].encrypted_post_state.view_tag, EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()), ); } @@ -324,8 +325,11 @@ fn update_note_view_tag_is_the_supplied_value() { .unwrap(); assert!(proof.is_valid_for(&output)); - assert_eq!(output.encrypted_private_post_states.len(), 1); - assert_eq!(output.encrypted_private_post_states[0].view_tag, fed_tag); + assert_eq!(output.private_actions.len(), 1); + assert_eq!( + output.private_actions[0].encrypted_post_state.view_tag, + fed_tag + ); } #[test] @@ -448,7 +452,7 @@ fn private_pda_init() { ); let (output, _proof) = result.expect("PDA init should succeed"); - assert_eq!(output.new_commitments.len(), 1); + assert_eq!(output.private_actions.len(), 1); } /// PDA withdraw: chains to `simple_balance_transfer` to move balance from PDA to recipient. @@ -502,7 +506,7 @@ fn private_pda_withdraw() { ); let (output, _proof) = result.expect("PDA withdraw should succeed"); - assert_eq!(output.new_commitments.len(), 1); + assert_eq!(output.private_actions.len(), 1); } /// Shared regular private account: receives funds via `authenticated_transfer` directly, @@ -554,7 +558,7 @@ fn shared_account_receives_via_simple_transfer() { 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); + assert_eq!(output.private_actions.len(), 1); } /// `PrivateAuthorizedInit` with a non-default identifier produces a ciphertext that decrypts diff --git a/lee/state_machine/src/privacy_preserving_transaction/message.rs b/lee/state_machine/src/privacy_preserving_transaction/message.rs index 3b6704ff..9df45236 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/message.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/message.rs @@ -1,24 +1,27 @@ use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ - Commitment, CommitmentSetDigest, Nullifier, PrivacyPreservingCircuitOutput, + Commitment, CommitmentSetDigest, Nullifier, PrivacyPreservingCircuitOutput, PrivateAction, account::{Account, Nonce}, program::{BlockValidityWindow, TimestampValidityWindow}, }; pub use lee_core::{EncryptedAccountData, ViewTag}; use sha2::{Digest as _, Sha256}; -use crate::{AccountId, error::LeeError}; +use crate::AccountId; const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Privacy/\x00\x00\x00\x00\x00\x00"; +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct PublicActionWithID { + pub account_id: AccountId, + pub post_state: Account, +} + #[derive(Clone, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct Message { - pub public_account_ids: Vec, + pub public_actions: Vec, pub nonces: Vec, - pub public_post_states: Vec, - pub encrypted_private_post_states: Vec, - pub new_commitments: Vec, - pub new_nullifiers: Vec<(Nullifier, CommitmentSetDigest)>, + pub private_actions: Vec, pub block_validity_window: BlockValidityWindow, pub timestamp_validity_window: TimestampValidityWindow, } @@ -31,21 +34,22 @@ impl std::fmt::Debug for Message { write!(f, "{}", hex::encode(self.0)) } } - let nullifiers: Vec<_> = self - .new_nullifiers + let private_actions: Vec<_> = self + .private_actions .iter() - .map(|(n, d)| (n, HexDigest(d))) + .map(|a| { + ( + &a.nullifier, + HexDigest(&a.root), + &a.commitment, + &a.encrypted_post_state, + ) + }) .collect(); f.debug_struct("Message") - .field("public_account_ids", &self.public_account_ids) + .field("public_actions", &self.public_actions) .field("nonces", &self.nonces) - .field("public_post_states", &self.public_post_states) - .field( - "encrypted_private_post_states", - &self.encrypted_private_post_states, - ) - .field("new_commitments", &self.new_commitments) - .field("new_nullifiers", &nullifiers) + .field("private_actions", &private_actions) .field("block_validity_window", &self.block_validity_window) .field("timestamp_validity_window", &self.timestamp_validity_window) .finish() @@ -53,21 +57,47 @@ impl std::fmt::Debug for Message { } impl Message { - pub fn try_from_circuit_output( - public_account_ids: Vec, - nonces: Vec, - output: PrivacyPreservingCircuitOutput, - ) -> Result { - Ok(Self { - public_account_ids, + #[must_use] + pub fn from_circuit_output(nonces: Vec, output: PrivacyPreservingCircuitOutput) -> Self { + let public_actions = output + .public_actions + .into_iter() + .map(|action| PublicActionWithID { + account_id: action.pre.account_id, + post_state: action.post, + }) + .collect(); + Self { + public_actions, nonces, - public_post_states: output.public_post_states, - encrypted_private_post_states: output.encrypted_private_post_states, - new_commitments: output.new_commitments, - new_nullifiers: output.new_nullifiers, + private_actions: output.private_actions, block_validity_window: output.block_validity_window, timestamp_validity_window: output.timestamp_validity_window, - }) + } + } + + #[must_use] + pub fn commitments(&self) -> Vec { + self.private_actions + .iter() + .map(|action| action.commitment) + .collect() + } + + #[must_use] + pub fn nullifiers(&self) -> Vec<(Nullifier, CommitmentSetDigest)> { + self.private_actions + .iter() + .map(|action| (action.nullifier, action.root)) + .collect() + } + + #[must_use] + pub fn public_account_ids(&self) -> Vec { + self.public_actions + .iter() + .map(|action| action.account_id) + .collect() } #[must_use] @@ -84,34 +114,20 @@ impl Message { Sha256::digest(bytes).into() } - - /// Ensure that the commitments, nullifiers, and ciphertexts agree. - pub fn validate_note_lengths(&self) -> Result { - let count = self.new_nullifiers.len(); - if self.new_commitments.len() != count || self.encrypted_private_post_states.len() != count - { - return Err(LeeError::InvalidInput(format!( - "Note vectors disagree in length with {count} nullifiers, {} commitments, and {} ciphertexts", - self.new_commitments.len(), - self.encrypted_private_post_states.len(), - ))); - } - Ok(count) - } } #[cfg(test)] pub mod tests { use lee_core::{ - Commitment, EncryptionScheme, EphemeralSecretKey, Nullifier, NullifierPublicKey, - PrivateAccountKind, SharedSecretKey, + Commitment, EncryptionScheme, EphemeralPublicKey, EphemeralSecretKey, Nullifier, + NullifierPublicKey, PrivateAccountKind, PrivateAction, SharedSecretKey, account::{Account, AccountId, Nonce}, - encryption::ViewingPublicKey, + encryption::{Ciphertext, ViewingPublicKey}, program::{BlockValidityWindow, TimestampValidityWindow}, }; use sha2::{Digest as _, Sha256}; - use super::{EncryptedAccountData, Message, PREFIX}; + use super::{EncryptedAccountData, Message, PREFIX, PublicActionWithID}; #[must_use] pub fn message_for_tests() -> Message { @@ -125,78 +141,57 @@ pub mod tests { let npk2 = NullifierPublicKey::from(&nsk2); let vpk = ViewingPublicKey::from_seed(&[7; 32], &[8; 32]); - let public_account_ids = vec![AccountId::new([1; 32])]; - let nonces = vec![1_u128.into(), 2_u128.into(), 3_u128.into()]; - let public_post_states = vec![Account::default()]; - - let encrypted_private_post_states = Vec::new(); - let account_id2 = lee_core::account::AccountId::for_regular_private_account(&npk2, &vpk, 0); - let new_commitments = vec![Commitment::new(&account_id2, &account2)]; + let commitment = Commitment::new(&account_id2, &account2); let account_id1 = lee_core::account::AccountId::for_regular_private_account(&npk1, &vpk, 0); let old_commitment = Commitment::new(&account_id1, &account1); - let new_nullifiers = vec![( - Nullifier::for_account_update(&old_commitment, &nsk1), - [0; 32], - )]; + let nullifier = Nullifier::for_account_update(&old_commitment, &nsk1); Message { - public_account_ids, + public_actions: vec![PublicActionWithID { + account_id: AccountId::new([1; 32]), + post_state: Account::default(), + }], nonces, - public_post_states, - encrypted_private_post_states, - new_commitments, - new_nullifiers, + private_actions: vec![PrivateAction { + nullifier, + root: [0; 32], + commitment, + encrypted_post_state: EncryptedAccountData { + ciphertext: Ciphertext::from_inner(vec![]), + epk: EphemeralPublicKey(vec![]), + view_tag: 0, + }, + }], block_validity_window: BlockValidityWindow::new_unbounded(), timestamp_validity_window: TimestampValidityWindow::new_unbounded(), } } - #[test] - fn validate_note_lengths_accepts_matching_and_rejects_mismatched() { - assert_eq!(Message::default().validate_note_lengths().unwrap(), 0); - - let mismatched = Message { - new_commitments: vec![Commitment::new( - &AccountId::new([0; 32]), - &Account::default(), - )], - ..Default::default() - }; - assert!(mismatched.validate_note_lengths().is_err()); - } - #[test] fn hash_privacy_pinned() { let msg = Message { - public_account_ids: vec![AccountId::new([42_u8; 32])], + public_actions: vec![], nonces: vec![Nonce(5)], - public_post_states: vec![], - encrypted_private_post_states: vec![], - new_commitments: vec![], - new_nullifiers: vec![], + private_actions: vec![], block_validity_window: BlockValidityWindow::new_unbounded(), timestamp_validity_window: TimestampValidityWindow::new_unbounded(), }; - let public_account_ids_bytes: &[u8] = &[42_u8; 32]; + // empty vec fields: u32 len=0 + let public_actions_bytes: &[u8] = &[0, 0, 0, 0]; 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]; + let private_actions_bytes: &[u8] = &[0, 0, 0, 0]; // validity windows: unbounded = {from: None (0_u8), to: None (0_u8)} - let unbounded_window_bytes: &[u8] = &[0_u8; 2]; + let unbounded_window_bytes: &[u8] = &[0, 0]; let expected_borsh_vec: Vec = [ - &[1_u8, 0, 0, 0], // public_account_ids - public_account_ids_bytes, + public_actions_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 + private_actions_bytes, unbounded_window_bytes, // block_validity_window unbounded_window_bytes, // timestamp_validity_window ] diff --git a/lee/state_machine/src/privacy_preserving_transaction/transaction.rs b/lee/state_machine/src/privacy_preserving_transaction/transaction.rs index 055d65c1..1b0a6567 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/transaction.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/transaction.rs @@ -53,7 +53,12 @@ impl PrivacyPreservingTransaction { .signer_account_ids() .into_iter() .collect::>(); - acc_set.extend(&self.message.public_account_ids); + acc_set.extend( + self.message + .public_actions + .iter() + .map(|action| action.account_id), + ); acc_set.into_iter().collect() } diff --git a/lee/state_machine/src/state/tests/circuit.rs b/lee/state_machine/src/state/tests/circuit.rs index 17492824..591235b0 100644 --- a/lee/state_machine/src/state/tests/circuit.rs +++ b/lee/state_machine/src/state/tests/circuit.rs @@ -402,11 +402,8 @@ fn private_pda_claim_succeeds() { ); let (output, _proof) = result.expect("private PDA claim should succeed"); - assert_eq!(output.new_nullifiers.len(), 1); - assert_eq!(output.new_commitments.len(), 1); - assert_eq!(output.encrypted_private_post_states.len(), 1); - assert!(output.public_pre_states.is_empty()); - assert!(output.public_post_states.is_empty()); + assert_eq!(output.private_actions.len(), 1); + assert!(output.public_actions.is_empty()); } /// An npk is supplied that does not match the `pre_state`'s `account_id` under @@ -482,8 +479,7 @@ fn caller_pda_seeds_authorize_private_pda_for_callee() { 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); + assert_eq!(output.private_actions.len(), 1); } /// The delegator chains with a different seed than the one it claimed with. In the callee @@ -756,7 +752,7 @@ fn private_authorized_uninitialized_account() { .unwrap(); // Create message from circuit output - let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); + let message = Message::from_circuit_output(vec![], output); let witness_set = WitnessSet::for_message(&message, proof, &[]); @@ -801,7 +797,7 @@ fn private_unauthorized_uninitialized_account_can_still_be_claimed() { ) .unwrap(); - let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); + let message = Message::from_circuit_output(vec![], output); let witness_set = WitnessSet::for_message(&message, proof, &[]); let tx = PrivacyPreservingTransaction::new(message, witness_set); @@ -851,7 +847,7 @@ fn private_account_claimed_then_used_without_init_flag_should_fail() { ) .unwrap(); - let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); + let message = Message::from_circuit_output(vec![], output); let witness_set = WitnessSet::for_message(&message, proof, &[]); let tx = PrivacyPreservingTransaction::new(message, witness_set); @@ -961,8 +957,7 @@ fn two_private_pda_family_members_receive_and_spend() { &simple_transfer.clone().into(), ) .unwrap(); - let message = - Message::try_from_circuit_output(vec![funder_id], vec![funder_nonce], output).unwrap(); + let message = Message::from_circuit_output(vec![funder_nonce], output); let witness_set = WitnessSet::for_message(&message, proof, &[&funder_keys.signing_key]); state .transition_from_privacy_preserving_transaction( @@ -997,8 +992,7 @@ fn two_private_pda_family_members_receive_and_spend() { &simple_transfer.into(), ) .unwrap(); - let message = - Message::try_from_circuit_output(vec![funder_id], vec![funder_nonce], output).unwrap(); + let message = Message::from_circuit_output(vec![funder_nonce], output); let witness_set = WitnessSet::for_message(&message, proof, &[&funder_keys.signing_key]); state .transition_from_privacy_preserving_transaction( @@ -1041,8 +1035,7 @@ fn two_private_pda_family_members_receive_and_spend() { &spend_with_deps, ) .unwrap(); - let message = - Message::try_from_circuit_output(vec![recipient_id], vec![Nonce(0)], output).unwrap(); + let message = Message::from_circuit_output(vec![Nonce(0)], output); let witness_set = WitnessSet::for_message(&message, proof, &[&recipient_signing_key]); state .transition_from_privacy_preserving_transaction( @@ -1079,7 +1072,7 @@ fn two_private_pda_family_members_receive_and_spend() { &spend_with_deps, ) .unwrap(); - let message = Message::try_from_circuit_output(vec![recipient_id], vec![], output).unwrap(); + let message = Message::from_circuit_output(vec![], output); let witness_set = WitnessSet::for_message(&message, proof, &[]); state .transition_from_privacy_preserving_transaction( @@ -1130,9 +1123,7 @@ fn two_private_pda_family_members_receive_and_spend() { &crate::test_methods::simple_balance_transfer().into(), ) .unwrap(); - let message = - Message::try_from_circuit_output(vec![recipient_id], vec![recipient_nonce], output) - .unwrap(); + let message = Message::from_circuit_output(vec![recipient_nonce], output); let witness_set = WitnessSet::for_message(&message, proof, &[&recipient_signing_key]); state .transition_from_privacy_preserving_transaction( diff --git a/lee/state_machine/src/state/tests/claiming.rs b/lee/state_machine/src/state/tests/claiming.rs index 3bdc315c..a5e1c3b2 100644 --- a/lee/state_machine/src/state/tests/claiming.rs +++ b/lee/state_machine/src/state/tests/claiming.rs @@ -341,9 +341,7 @@ fn authorized_public_account_claiming_succeeds_when_executed_privately() { ) .unwrap(); - let message = - Message::try_from_circuit_output(vec![recipient_account_id], vec![Nonce(0)], output) - .unwrap(); + let message = Message::from_circuit_output(vec![Nonce(0)], output); let witness_set = WitnessSet::for_message(&message, proof, &[&recipient_private_key]); let tx = PrivacyPreservingTransaction::new(message, witness_set); @@ -466,7 +464,7 @@ fn private_chained_call(number_of_calls: u32) { ) .unwrap(); - let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); + let message = Message::from_circuit_output(vec![], output); let witness_set = WitnessSet::for_message(&message, proof, &[]); let transaction = PrivacyPreservingTransaction::new(message, witness_set); diff --git a/lee/state_machine/src/state/tests/mod.rs b/lee/state_machine/src/state/tests/mod.rs index 399d268f..06a05000 100644 --- a/lee/state_machine/src/state/tests/mod.rs +++ b/lee/state_machine/src/state/tests/mod.rs @@ -291,12 +291,7 @@ fn shielded_balance_transfer_for_tests( ) .unwrap(); - let message = Message::try_from_circuit_output( - vec![sender_keys.account_id()], - vec![sender_nonce], - output, - ) - .unwrap(); + let message = Message::from_circuit_output(vec![sender_nonce], output); let witness_set = WitnessSet::for_message(&message, proof, &[&sender_keys.signing_key]); PrivacyPreservingTransaction::new(message, witness_set) @@ -350,7 +345,7 @@ fn private_balance_transfer_for_tests( ) .unwrap(); - let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); + let message = Message::from_circuit_output(vec![], output); let witness_set = WitnessSet::for_message(&message, proof, &[]); @@ -399,8 +394,7 @@ fn deshielded_balance_transfer_for_tests( ) .unwrap(); - let message = - Message::try_from_circuit_output(vec![*recipient_account_id], vec![], output).unwrap(); + let message = Message::from_circuit_output(vec![], output); let witness_set = WitnessSet::for_message(&message, proof, &[]); diff --git a/lee/state_machine/src/state/tests/privacy_preserving.rs b/lee/state_machine/src/state/tests/privacy_preserving.rs index bc51ce4f..e01cacbd 100644 --- a/lee/state_machine/src/state/tests/privacy_preserving.rs +++ b/lee/state_machine/src/state/tests/privacy_preserving.rs @@ -26,7 +26,7 @@ fn transition_from_privacy_preserving_transaction_shielded() { this }; - let [expected_new_commitment] = tx.message().new_commitments.clone().try_into().unwrap(); + let [expected_new_commitment] = tx.message().commitments().try_into().unwrap(); assert!(!state.private_state.0.contains(&expected_new_commitment)); state @@ -128,7 +128,7 @@ fn privacy_tampered_epk_is_rejected() { ); // Flip a byte of the first note's epk - tx.message.encrypted_private_post_states[0].epk.0[0] ^= 0xFF; + tx.message.private_actions[0].encrypted_post_state.epk.0[0] ^= 0xFF; assert!( matches!( @@ -154,7 +154,7 @@ fn privacy_tampered_view_tag_is_rejected() { ); // Flip the first note's view_tag - tx.message.encrypted_private_post_states[0].view_tag ^= 0xFF; + tx.message.private_actions[0].encrypted_post_state.view_tag ^= 0xFF; assert!( matches!( diff --git a/lee/state_machine/src/state/tests/validity_window.rs b/lee/state_machine/src/state/tests/validity_window.rs index 7e314bc2..be51b1f6 100644 --- a/lee/state_machine/src/state/tests/validity_window.rs +++ b/lee/state_machine/src/state/tests/validity_window.rs @@ -149,7 +149,7 @@ fn validity_window_works_in_privacy_preserving_transactions( ) .unwrap(); - let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); + let message = Message::from_circuit_output(vec![], output); let witness_set = WitnessSet::for_message(&message, proof, &[]); PrivacyPreservingTransaction::new(message, witness_set) @@ -214,7 +214,7 @@ fn timestamp_validity_window_works_in_privacy_preserving_transactions( ) .unwrap(); - let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap(); + let message = Message::from_circuit_output(vec![], output); let witness_set = WitnessSet::for_message(&message, proof, &[]); PrivacyPreservingTransaction::new(message, witness_set) diff --git a/lee/state_machine/src/validated_state_diff/mod.rs b/lee/state_machine/src/validated_state_diff/mod.rs index ad80f63f..738cc515 100644 --- a/lee/state_machine/src/validated_state_diff/mod.rs +++ b/lee/state_machine/src/validated_state_diff/mod.rs @@ -4,7 +4,7 @@ use std::{ }; use lee_core::{ - BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, Timestamp, + BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, PublicAction, Timestamp, account::{Account, AccountId, AccountWithMetadata}, program::{ ChainedCall, Claim, DEFAULT_PROGRAM_ID, ProgramId, compute_public_authorized_pdas, @@ -335,10 +335,13 @@ impl ValidatedStateDiff { ) -> Result { let message = &tx.message; let witness_set = &tx.witness_set; + let commitments = message.commitments(); + let nullifiers = message.nullifiers(); + let public_account_ids = message.public_account_ids(); // 1. Commitments or nullifiers are non empty ensure!( - !message.new_commitments.is_empty() || !message.new_nullifiers.is_empty(), + !message.private_actions.is_empty(), LeeError::InvalidInput( "Empty commitments and empty nullifiers found in message".into(), ) @@ -346,25 +349,19 @@ impl ValidatedStateDiff { // 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(), + n_unique(&public_account_ids) == public_account_ids.len(), LeeError::InvalidInput("Duplicate account_ids found in message".into()) ); // Check there are no duplicate nullifiers in the new_nullifiers list ensure!( - n_unique( - &message - .new_nullifiers - .iter() - .map(|(n, _)| n) - .collect::>() - ) == message.new_nullifiers.len(), + n_unique(&nullifiers.iter().map(|(n, _)| n).collect::>()) == nullifiers.len(), LeeError::InvalidInput("Duplicate nullifiers found in message".into()) ); // Check there are no duplicate commitments in the new_commitments list ensure!( - n_unique(&message.new_commitments) == message.new_commitments.len(), + n_unique(&commitments) == commitments.len(), LeeError::InvalidInput("Duplicate commitments found in message".into()) ); @@ -401,8 +398,7 @@ impl ValidatedStateDiff { ); // Build pre_states for proof verification - let public_pre_states: Vec<_> = message - .public_account_ids + let public_pre_states: Vec<_> = public_account_ids .iter() .map(|account_id| { AccountWithMetadata::new( @@ -421,28 +417,22 @@ impl ValidatedStateDiff { )?; // 5. Commitment freshness - state.check_commitments_are_new(&message.new_commitments)?; + state.check_commitments_are_new(&commitments)?; // 6. Nullifier uniqueness - state.check_nullifiers_are_valid(&message.new_nullifiers)?; + state.check_nullifiers_are_valid(&nullifiers)?; let public_diff = message - .public_account_ids + .public_actions .iter() - .copied() - .zip(message.public_post_states.clone()) - .collect(); - let new_nullifiers = message - .new_nullifiers - .iter() - .copied() - .map(|(nullifier, _)| nullifier) + .map(|action| (action.account_id, action.post_state.clone())) .collect(); + let new_nullifiers = nullifiers.iter().map(|(nullifier, _)| *nullifier).collect(); Ok(Self(StateDiff { signer_account_ids, public_diff, - new_commitments: message.new_commitments.clone(), + new_commitments: commitments, new_nullifiers, program: None, })) @@ -523,11 +513,16 @@ fn check_privacy_preserving_circuit_proof_is_valid( message: &Message, ) -> Result<(), LeeError> { let output = PrivacyPreservingCircuitOutput { - public_pre_states: public_pre_states.to_vec(), - public_post_states: message.public_post_states.clone(), - encrypted_private_post_states: message.encrypted_private_post_states.clone(), - new_commitments: message.new_commitments.clone(), - new_nullifiers: message.new_nullifiers.clone(), + public_actions: public_pre_states + .iter() + .cloned() + .zip(&message.public_actions) + .map(|(pre, action)| PublicAction { + pre, + post: action.post_state.clone(), + }) + .collect(), + private_actions: message.private_actions.clone(), block_validity_window: message.block_validity_window, timestamp_validity_window: message.timestamp_validity_window, }; diff --git a/lee/state_machine/src/validated_state_diff/tests.rs b/lee/state_machine/src/validated_state_diff/tests.rs index e8bb3f14..19b00939 100644 --- a/lee/state_machine/src/validated_state_diff/tests.rs +++ b/lee/state_machine/src/validated_state_diff/tests.rs @@ -188,12 +188,10 @@ fn privacy_malicious_programs_cannot_drain_public_victim() { // 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], + let message = Message::from_circuit_output( vec![], // no public signers, no nonces circuit_output, - ) - .unwrap(); + ); let witness_set = WitnessSet::for_message(&message, proof, &[]); // no signatures let tx = PrivacyPreservingTransaction::new(message, witness_set); @@ -350,12 +348,10 @@ fn privacy_malicious_programs_cannot_drain_private_victim() { // 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], + let message = Message::from_circuit_output( vec![], // no public signers, no nonces circuit_output, - ) - .unwrap(); + ); let witness_set = WitnessSet::for_message(&message, proof, &[]); // no signatures let tx = PrivacyPreservingTransaction::new(message, witness_set); @@ -480,8 +476,9 @@ fn malicious_programs_cannot_drain_victim_without_signature() { #[test] fn privacy_garbage_proof_is_rejected() { use lee_core::{ - Commitment, + Commitment, EncryptedAccountData, Nullifier, PrivateAction, account::Account, + encryption::{Ciphertext, EphemeralPublicKey}, program::{BlockValidityWindow, TimestampValidityWindow}, }; @@ -503,12 +500,18 @@ fn privacy_garbage_proof_is_rejected() { )); let commitment = Commitment::new(&account_id, &Account::default()); let message = Message { - public_account_ids: vec![], + public_actions: vec![], nonces: vec![], - public_post_states: vec![], - encrypted_private_post_states: vec![], - new_commitments: vec![commitment], - new_nullifiers: vec![], + private_actions: vec![PrivateAction { + nullifier: Nullifier::for_account_initialization(&account_id), + root: [0; 32], + commitment, + encrypted_post_state: EncryptedAccountData { + ciphertext: Ciphertext::from_inner(vec![]), + epk: EphemeralPublicKey(vec![]), + view_tag: 0, + }, + }], block_validity_window: BlockValidityWindow::new_unbounded(), timestamp_validity_window: TimestampValidityWindow::new_unbounded(), }; diff --git a/lez/common/src/transaction.rs b/lez/common/src/transaction.rs index 13b2ada5..9970bf7d 100644 --- a/lez/common/src/transaction.rs +++ b/lez/common/src/transaction.rs @@ -35,6 +35,15 @@ impl LeeTransaction { }) } + #[must_use] + pub const fn kind(&self) -> TxKind { + match self { + Self::Public(_) => TxKind::Public, + Self::PrivacyPreserving(_) => TxKind::PrivacyPreserving, + Self::ProgramDeployment(_) => TxKind::ProgramDeployment, + } + } + #[must_use] pub fn affected_public_account_ids(&self) -> Vec { match self { diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs index 06ebd8a0..38f35d24 100644 --- a/lez/cross_zone/src/lib.rs +++ b/lez/cross_zone/src/lib.rs @@ -143,17 +143,16 @@ pub fn build_dispatch_from_emission( build_inbox_dispatch_tx(programs::cross_zone_inbox().id(), &msg, target_ids) } -/// The inbox config a zone derives from its cross-zone config: the per-peer target -/// allowlists plus its own zone id. +/// The inbox config a zone derives from its cross-zone config: the per-peer +/// delivery routes plus its own zone id. fn inbox_config(self_zone: ZoneId, cross_zone: &CrossZoneConfig) -> InboxConfig { - let mut allowed_targets = BTreeMap::new(); + let mut allowed_routes = BTreeMap::new(); for peer in &cross_zone.peers { - allowed_targets.insert(peer.channel_id, peer.allowed_targets.clone()); + allowed_routes.insert(peer.channel_id, peer.allowed_routes.clone()); } InboxConfig { self_zone, - allowed_peers: BTreeMap::new(), - allowed_targets, + allowed_routes, } } diff --git a/lez/explorer_service/src/components/transaction_details.rs b/lez/explorer_service/src/components/transaction_details.rs index c82f7d80..01e5bc48 100644 --- a/lez/explorer_service/src/components/transaction_details.rs +++ b/lez/explorer_service/src/components/transaction_details.rs @@ -68,15 +68,18 @@ pub fn PrivacyPreservingTxDetails(tx: PrivacyPreservingTransaction) -> impl Into witness_set, } = tx; let PrivacyPreservingMessage { - public_account_ids, + public_actions, nonces, - public_post_states: _, - encrypted_private_post_states, - new_commitments, - new_nullifiers, + private_actions, block_validity_window, timestamp_validity_window, } = message; + let private_action_count = private_actions.len(); + let public_account_ids: Vec<_> = public_actions + .into_iter() + .map(|action| action.account_id) + .collect(); + let public_account_count = public_account_ids.len(); let WitnessSet { signatures_and_public_keys: _, proof, @@ -90,22 +93,12 @@ pub fn PrivacyPreservingTxDetails(tx: PrivacyPreservingTransaction) -> impl Into
"Public Accounts:" - {public_account_ids.len().to_string()} + {public_account_count.to_string()}
- "New Commitments:" - {new_commitments.len().to_string()} -
-
- "Nullifiers:" - {new_nullifiers.len().to_string()} -
-
- "Encrypted States:" - - {encrypted_private_post_states.len().to_string()} - + "Private Actions:" + {private_action_count.to_string()}
"Proof Size:" diff --git a/lez/explorer_service/src/components/transaction_preview.rs b/lez/explorer_service/src/components/transaction_preview.rs index 094ca4ff..20c26235 100644 --- a/lez/explorer_service/src/components/transaction_preview.rs +++ b/lez/explorer_service/src/components/transaction_preview.rs @@ -40,8 +40,8 @@ pub fn TransactionPreview(transaction: Transaction) -> impl IntoView { } = tx; format!( "{} public accounts, {} commitments", - message.public_account_ids.len(), - message.new_commitments.len() + message.public_actions.len(), + message.private_actions.len() ) } Transaction::ProgramDeployment(tx) => { diff --git a/lez/indexer/ffi/indexer_ffi.h b/lez/indexer/ffi/indexer_ffi.h index 26857af7..16e5a17c 100644 --- a/lez/indexer/ffi/indexer_ffi.h +++ b/lez/indexer/ffi/indexer_ffi.h @@ -225,13 +225,18 @@ typedef struct FfiAccount { struct FfiU128 nonce; } FfiAccount; -typedef struct FfiVec_FfiAccount { - struct FfiAccount *entries; +typedef struct FfiPublicAction { + FfiAccountId account_id; + struct FfiAccount post_state; +} FfiPublicAction; + +typedef struct FfiVec_FfiPublicAction { + struct FfiPublicAction *entries; uintptr_t len; uintptr_t capacity; -} FfiVec_FfiAccount; +} FfiVec_FfiPublicAction; -typedef struct FfiVec_FfiAccount FfiAccountList; +typedef struct FfiVec_FfiPublicAction FfiPublicActionList; typedef struct FfiVec_u8 { uint8_t *entries; @@ -247,42 +252,25 @@ typedef struct FfiEncryptedAccountData { 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 { +typedef struct FfiPrivateAction { struct FfiBytes32 nullifier; - struct FfiBytes32 commitment_set_digest; -} FfiNullifierCommitmentSet; + struct FfiBytes32 root; + struct FfiBytes32 commitment; + struct FfiEncryptedAccountData encrypted_post_state; +} FfiPrivateAction; -typedef struct FfiVec_FfiNullifierCommitmentSet { - struct FfiNullifierCommitmentSet *entries; +typedef struct FfiVec_FfiPrivateAction { + struct FfiPrivateAction *entries; uintptr_t len; uintptr_t capacity; -} FfiVec_FfiNullifierCommitmentSet; +} FfiVec_FfiPrivateAction; -typedef struct FfiVec_FfiNullifierCommitmentSet FfiNullifierCommitmentSetList; +typedef struct FfiVec_FfiPrivateAction FfiPrivateActionList; typedef struct FfiPrivacyPreservingMessage { - FfiAccountIdList public_account_ids; + FfiPublicActionList public_actions; FfiNonceList nonces; - FfiAccountList public_post_states; - FfiEncryptedAccountDataList encrypted_private_post_states; - FfiVecBytes32 new_commitments; - FfiNullifierCommitmentSetList new_nullifiers; + FfiPrivateActionList private_actions; uint64_t block_validity_window[2]; uint64_t timestamp_validity_window[2]; } FfiPrivacyPreservingMessage; diff --git a/lez/indexer/ffi/src/api/types/transaction.rs b/lez/indexer/ffi/src/api/types/transaction.rs index d5cb9035..83f50ede 100644 --- a/lez/indexer/ffi/src/api/types/transaction.rs +++ b/lez/indexer/ffi/src/api/types/transaction.rs @@ -1,17 +1,19 @@ 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, + PrivacyPreservingTransaction, PrivateAction, ProgramDeploymentMessage, + ProgramDeploymentTransaction, ProgramId, Proof, PublicActionWithID, PublicKey, PublicMessage, + PublicTransaction, Signature, Transaction, ValidityWindow, WitnessSet, }; use crate::api::types::{ - FfiBytes32, FfiHashType, FfiOption, FfiProgramId, FfiPublicKey, FfiSignature, FfiVec, + FfiAccountId, FfiBytes32, FfiHashType, FfiOption, FfiProgramId, FfiPublicKey, FfiSignature, + FfiVec, + account::FfiAccount, vectors::{ - FfiAccountIdList, FfiAccountList, FfiEncryptedAccountDataList, FfiInstructionDataList, - FfiNonceList, FfiNullifierCommitmentSetList, FfiProgramDeploymentMessage, FfiProof, - FfiSignaturePubKeyList, FfiVecBytes32, FfiVecU8, + FfiAccountIdList, FfiInstructionDataList, FfiNonceList, FfiPrivateActionList, + FfiProgramDeploymentMessage, FfiProof, FfiPublicActionList, FfiSignaturePubKeyList, + FfiVecU8, }, }; @@ -156,12 +158,15 @@ impl From> for PrivacyPreservingTransaction { Self { hash: HashType(value.hash.data), message: PrivacyPreservingMessage { - public_account_ids: { - let std_vec: Vec<_> = value.message.public_account_ids.into(); + public_actions: { + let std_vec: Vec<_> = value.message.public_actions.into(); std_vec .into_iter() - .map(|ffi_val| AccountId { - value: ffi_val.data, + .map(|ffi_val| PublicActionWithID { + account_id: AccountId { + value: ffi_val.account_id.data, + }, + post_state: ffi_val.post_state.into(), }) .collect() }, @@ -169,37 +174,21 @@ impl From> for PrivacyPreservingTransaction { 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(); + private_actions: { + let std_vec: Vec<_> = value.message.private_actions.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), - ) + .map(|ffi_val| PrivateAction { + nullifier: Nullifier(ffi_val.nullifier.data), + root: CommitmentSetDigest(ffi_val.root.data), + commitment: Commitment(ffi_val.commitment.data), + encrypted_post_state: EncryptedAccountData { + ciphertext: Ciphertext( + ffi_val.encrypted_post_state.ciphertext.into(), + ), + epk: EphemeralPublicKey(ffi_val.encrypted_post_state.epk.into()), + view_tag: ffi_val.encrypted_post_state.view_tag, + }, }) .collect() }, @@ -229,14 +218,53 @@ impl From> for PrivacyPreservingTransaction { } } +#[repr(C)] +pub struct FfiPublicAction { + pub account_id: FfiAccountId, + pub post_state: FfiAccount, +} + +impl From for FfiPublicAction { + fn from(value: PublicActionWithID) -> Self { + let post_state: lee::Account = value + .post_state + .try_into() + .expect("Source is in blocks, must fit"); + Self { + account_id: value.account_id.into(), + post_state: post_state.into(), + } + } +} + +#[repr(C)] +pub struct FfiPrivateAction { + pub nullifier: FfiBytes32, + pub root: FfiBytes32, + pub commitment: FfiBytes32, + pub encrypted_post_state: FfiEncryptedAccountData, +} + +impl From for FfiPrivateAction { + fn from(value: PrivateAction) -> Self { + Self { + nullifier: FfiBytes32 { + data: value.nullifier.0, + }, + root: FfiBytes32 { data: value.root.0 }, + commitment: FfiBytes32 { + data: value.commitment.0, + }, + encrypted_post_state: value.encrypted_post_state.into(), + } + } +} + #[repr(C)] pub struct FfiPrivacyPreservingMessage { - pub public_account_ids: FfiAccountIdList, + pub public_actions: FfiPublicActionList, pub nonces: FfiNonceList, - pub public_post_states: FfiAccountList, - pub encrypted_private_post_states: FfiEncryptedAccountDataList, - pub new_commitments: FfiVecBytes32, - pub new_nullifiers: FfiNullifierCommitmentSetList, + pub private_actions: FfiPrivateActionList, pub block_validity_window: [u64; 2], pub timestamp_validity_window: [u64; 2], } @@ -244,18 +272,15 @@ pub struct FfiPrivacyPreservingMessage { impl From for FfiPrivacyPreservingMessage { fn from(value: PrivacyPreservingMessage) -> Self { let PrivacyPreservingMessage { - public_account_ids, + public_actions, nonces, - public_post_states, - encrypted_private_post_states, - new_commitments, - new_nullifiers, + private_actions, block_validity_window, timestamp_validity_window, } = value; Self { - public_account_ids: public_account_ids + public_actions: public_actions .into_iter() .map(Into::into) .collect::>() @@ -265,25 +290,7 @@ impl From for FfiPrivacyPreservingMessage { .map(Into::into) .collect::>() .into(), - public_post_states: public_post_states - .into_iter() - .map(|acc_ind| -> lee::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 + private_actions: private_actions .into_iter() .map(Into::into) .collect::>() @@ -294,21 +301,6 @@ impl From for FfiPrivacyPreservingMessage { } } -#[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, diff --git a/lez/indexer/ffi/src/api/types/vectors.rs b/lez/indexer/ffi/src/api/types/vectors.rs index 46f08737..4cccb949 100644 --- a/lez/indexer/ffi/src/api/types/vectors.rs +++ b/lez/indexer/ffi/src/api/types/vectors.rs @@ -1,19 +1,12 @@ use crate::api::types::{ - FfiAccountId, FfiBytes32, FfiNonce, FfiVec, - account::FfiAccount, - transaction::{ - FfiEncryptedAccountData, FfiNullifierCommitmentSet, FfiSignaturePubKeyEntry, FfiTransaction, - }, + FfiAccountId, FfiNonce, FfiVec, + transaction::{FfiPrivateAction, FfiPublicAction, 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; @@ -26,6 +19,6 @@ pub type FfiProof = FfiVecU8; pub type FfiProgramDeploymentMessage = FfiVecU8; -pub type FfiEncryptedAccountDataList = FfiVec; +pub type FfiPublicActionList = FfiVec; -pub type FfiNullifierCommitmentSetList = FfiVec; +pub type FfiPrivateActionList = FfiVec; diff --git a/lez/indexer/service/protocol/src/convert.rs b/lez/indexer/service/protocol/src/convert.rs index 55c4dc6c..79ea0fe7 100644 --- a/lez/indexer/service/protocol/src/convert.rs +++ b/lez/indexer/service/protocol/src/convert.rs @@ -6,9 +6,9 @@ use crate::{ Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, BlockIngestError, Ciphertext, Commitment, CommitmentSetDigest, Data, EncryptedAccountData, EphemeralPublicKey, HashType, IndexerStatus, IndexerSyncState, Nullifier, PrivacyPreservingMessage, - PrivacyPreservingTransaction, ProgramDeploymentMessage, ProgramDeploymentTransaction, - ProgramId, Proof, PublicKey, PublicMessage, PublicTransaction, Signature, StallReason, - Transaction, ValidityWindow, WitnessSet, + PrivacyPreservingTransaction, PrivateAction, ProgramDeploymentMessage, + ProgramDeploymentTransaction, ProgramId, Proof, PublicActionWithID, PublicKey, PublicMessage, + PublicTransaction, Signature, StallReason, Transaction, ValidityWindow, WitnessSet, }; // ============================================================================ @@ -279,71 +279,97 @@ impl From for lee::public_transaction::Message { } } +impl From for PublicActionWithID { + fn from(value: lee::privacy_preserving_transaction::message::PublicActionWithID) -> Self { + Self { + account_id: value.account_id.into(), + post_state: value.post_state.into(), + } + } +} + +impl From for PrivateAction { + fn from(value: lee_core::PrivateAction) -> Self { + Self { + nullifier: value.nullifier.into(), + root: value.root.into(), + commitment: value.commitment.into(), + encrypted_post_state: value.encrypted_post_state.into(), + } + } +} + impl From for PrivacyPreservingMessage { fn from(value: lee::privacy_preserving_transaction::message::Message) -> Self { let lee::privacy_preserving_transaction::message::Message { - public_account_ids, + public_actions, nonces, - public_post_states, - encrypted_private_post_states, - new_commitments, - new_nullifiers, + private_actions, block_validity_window, timestamp_validity_window, } = value; Self { - public_account_ids: public_account_ids.into_iter().map(Into::into).collect(), + public_actions: public_actions.into_iter().map(Into::into).collect(), nonces: nonces.iter().map(|x| x.0).collect(), - public_post_states: public_post_states.into_iter().map(Into::into).collect(), - encrypted_private_post_states: encrypted_private_post_states - .into_iter() - .map(Into::into) - .collect(), - new_commitments: new_commitments.into_iter().map(Into::into).collect(), - new_nullifiers: new_nullifiers - .into_iter() - .map(|(n, d)| (n.into(), d.into())) - .collect(), + private_actions: private_actions.into_iter().map(Into::into).collect(), block_validity_window: block_validity_window.into(), timestamp_validity_window: timestamp_validity_window.into(), } } } +impl TryFrom + for lee::privacy_preserving_transaction::message::PublicActionWithID +{ + type Error = lee::error::LeeError; + + fn try_from(value: PublicActionWithID) -> Result { + Ok(Self { + account_id: value.account_id.into(), + post_state: value + .post_state + .try_into() + .map_err(|e| lee::error::LeeError::InvalidInput(format!("{e}")))?, + }) + } +} + +impl From for lee_core::PrivateAction { + fn from(value: PrivateAction) -> Self { + Self { + nullifier: value.nullifier.into(), + root: value.root.into(), + commitment: value.commitment.into(), + encrypted_post_state: value.encrypted_post_state.into(), + } + } +} + impl TryFrom for lee::privacy_preserving_transaction::message::Message { type Error = lee::error::LeeError; fn try_from(value: PrivacyPreservingMessage) -> Result { let PrivacyPreservingMessage { - public_account_ids, + public_actions, nonces, - public_post_states, - encrypted_private_post_states, - new_commitments, - new_nullifiers, + private_actions, block_validity_window, timestamp_validity_window, } = value; + + let public_actions = public_actions + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()?; + let private_actions = private_actions.into_iter().map(Into::into).collect(); + Ok(Self { - public_account_ids: public_account_ids.into_iter().map(Into::into).collect(), + public_actions, nonces: nonces .iter() .map(|x| lee_core::account::Nonce(*x)) .collect(), - public_post_states: public_post_states - .into_iter() - .map(TryInto::try_into) - .collect::, _>>() - .map_err(|e| lee::error::LeeError::InvalidInput(format!("{e}")))?, - encrypted_private_post_states: encrypted_private_post_states - .into_iter() - .map(Into::into) - .collect(), - new_commitments: new_commitments.into_iter().map(Into::into).collect(), - new_nullifiers: new_nullifiers - .into_iter() - .map(|(n, d)| (n.into(), d.into())) - .collect(), + private_actions, block_validity_window: block_validity_window .try_into() .map_err(|e| lee::error::LeeError::InvalidInput(format!("{e}")))?, diff --git a/lez/indexer/service/protocol/src/lib.rs b/lez/indexer/service/protocol/src/lib.rs index e17d539b..fe1fa525 100644 --- a/lez/indexer/service/protocol/src/lib.rs +++ b/lez/indexer/service/protocol/src/lib.rs @@ -226,14 +226,28 @@ pub struct PublicMessage { pub type InstructionData = Vec; +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub struct PublicActionWithID { + pub account_id: AccountId, + pub post_state: Account, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub struct PrivateAction { + pub nullifier: Nullifier, + pub root: CommitmentSetDigest, + // IMPORTANT: The commitment in the action is not necessarily connected + // to the nullifier in content. That is, the commitment's plaintext is + // not necessarily the updated account state of the nullifier's plaintext. + pub commitment: Commitment, + pub encrypted_post_state: EncryptedAccountData, +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] pub struct PrivacyPreservingMessage { - pub public_account_ids: Vec, + pub public_actions: Vec, pub nonces: Vec, - pub public_post_states: Vec, - pub encrypted_private_post_states: Vec, - pub new_commitments: Vec, - pub new_nullifiers: Vec<(Nullifier, CommitmentSetDigest)>, + pub private_actions: Vec, pub block_validity_window: ValidityWindow, pub timestamp_validity_window: ValidityWindow, } diff --git a/lez/indexer/service/src/mock_service.rs b/lez/indexer/service/src/mock_service.rs index 70af6239..c9a1912a 100644 --- a/lez/indexer/service/src/mock_service.rs +++ b/lez/indexer/service/src/mock_service.rs @@ -11,9 +11,9 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use indexer_service_protocol::{ Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, BlockId, Commitment, CommitmentSetDigest, Data, EncryptedAccountData, HashType, IndexerStatus, IndexerSyncState, - PrivacyPreservingMessage, PrivacyPreservingTransaction, ProgramDeploymentMessage, - ProgramDeploymentTransaction, ProgramId, PublicMessage, PublicTransaction, Signature, - Transaction, ValidityWindow, WitnessSet, + PrivacyPreservingMessage, PrivacyPreservingTransaction, PrivateAction, + ProgramDeploymentMessage, ProgramDeploymentTransaction, ProgramId, PublicActionWithID, + PublicMessage, PublicTransaction, Signature, Transaction, ValidityWindow, WitnessSet, }; use jsonrpsee::{ core::{SubscriptionResult, async_trait}, @@ -300,9 +300,11 @@ impl indexer_service_rpc::RpcServer for MockIndexerService { .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::PrivacyPreserving(priv_tx) => priv_tx + .message + .public_actions + .iter() + .any(|action| action.account_id == account_id), Transaction::ProgramDeployment(_) => false, }) .cloned() @@ -381,24 +383,26 @@ fn mock_privacy_preserving_tx( Transaction::PrivacyPreserving(PrivacyPreservingTransaction { hash: tx_hash, message: PrivacyPreservingMessage { - public_account_ids: vec![account_ids[tx_idx as usize % account_ids.len()]], + public_actions: vec![PublicActionWithID { + account_id: account_ids[tx_idx as usize % account_ids.len()], + post_state: Account { + program_owner: ProgramId([1_u32; 8]), + balance: 500, + data: Data(vec![0xdd, 0xee]), + nonce: block_id as u128, + }, + }], 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, + private_actions: vec![PrivateAction { + nullifier: indexer_service_protocol::Nullifier([tx_idx as u8; 32]), + root: CommitmentSetDigest([0xff; 32]), + commitment: Commitment([block_id as u8; 32]), + encrypted_post_state: EncryptedAccountData { + ciphertext: indexer_service_protocol::Ciphertext(vec![0x01, 0x02, 0x03, 0x04]), + epk: indexer_service_protocol::EphemeralPublicKey(vec![0xaa; 32]), + view_tag: 42, + }, }], - 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)), }, diff --git a/lez/mempool/Cargo.toml b/lez/mempool/Cargo.toml index a2f51bc0..47550fc9 100644 --- a/lez/mempool/Cargo.toml +++ b/lez/mempool/Cargo.toml @@ -12,3 +12,4 @@ tokio = { workspace = true, features = ["sync"] } [dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +futures.workspace = true diff --git a/lez/mempool/src/lib.rs b/lez/mempool/src/lib.rs index 0006f2c3..c081e9e7 100644 --- a/lez/mempool/src/lib.rs +++ b/lez/mempool/src/lib.rs @@ -18,6 +18,19 @@ impl MemPool { (mem_pool, sender) } + /// Returns the total number of items in the mempool, including both the front buffer and the + /// channel. + #[must_use] + pub fn len(&self) -> usize { + self.front_buffer.len().saturating_add(self.receiver.len()) + } + + /// Returns true if the mempool is empty, false otherwise. + #[must_use] + pub fn is_empty(&self) -> bool { + self.front_buffer.is_empty() && self.receiver.is_empty() + } + /// Pop an item from the mempool first checking the front buffer (LIFO) then the channel (FIFO). pub fn pop(&mut self) -> Option { use tokio::sync::mpsc::error::TryRecvError; @@ -74,6 +87,7 @@ impl MemPoolHandle { #[cfg(test)] mod tests { + use futures::FutureExt as _; use tokio::test; use super::*; @@ -82,6 +96,7 @@ mod tests { async fn mempool_new() { let (mut pool, _handle): (MemPool, _) = MemPool::new(10); assert_eq!(pool.pop(), None); + assert_eq!(pool.len(), 0); } #[test] @@ -89,10 +104,12 @@ mod tests { let (mut pool, handle) = MemPool::new(10); handle.push(1).await.unwrap(); + assert_eq!(pool.len(), 1); let item = pool.pop(); assert_eq!(item, Some(1)); assert_eq!(pool.pop(), None); + assert_eq!(pool.len(), 0); } #[test] @@ -103,29 +120,23 @@ mod tests { handle.push(2).await.unwrap(); handle.push(3).await.unwrap(); + assert_eq!(pool.len(), 3); assert_eq!(pool.pop(), Some(1)); assert_eq!(pool.pop(), Some(2)); assert_eq!(pool.pop(), Some(3)); assert_eq!(pool.pop(), None); - } - - #[test] - async fn pop_empty() { - let (mut pool, _handle): (MemPool, _) = MemPool::new(10); - assert_eq!(pool.pop(), None); + assert_eq!(pool.len(), 0); } #[test] async fn max_size() { - let (mut pool, handle) = MemPool::new(2); + let (_pool, handle) = MemPool::new(2); handle.push(1).await.unwrap(); handle.push(2).await.unwrap(); - // This should block if buffer is full, but we'll use try_send in a real scenario - // For now, just verify we can pop items - assert_eq!(pool.pop(), Some(1)); - assert_eq!(pool.pop(), Some(2)); + // This should block if buffer is full + assert_eq!(handle.push(3).now_or_never(), None); } #[test] diff --git a/lez/programs/cross_zone_inbox/core/src/lib.rs b/lez/programs/cross_zone_inbox/core/src/lib.rs index 4323db6b..b3ea8d59 100644 --- a/lez/programs/cross_zone_inbox/core/src/lib.rs +++ b/lez/programs/cross_zone_inbox/core/src/lib.rs @@ -23,13 +23,30 @@ pub type ExpectedPubkey = [u8; 32]; /// Content-addressed replay key for a delivered message. pub type MessageKey = [u8; 32]; +/// One delivery a peer is allowed to make: a program on the peer that may emit, +/// paired with the program here it may reach. +/// +/// The pair is the unit rather than two independent lists. A bridging peer needs +/// `wrapped_token` reachable, and any emitter that lets its caller choose the +/// target (`ping_sender` does) would otherwise reach it too, minting tokens with +/// no lock behind them. Naming the pair is what stops two separately reasonable +/// entries composing into a route nobody wrote down. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub struct CrossZoneRoute { + /// The program on the peer zone that emitted the message. + pub src_program_id: ProgramId, + /// The program on this zone it may be delivered to. + pub target_program_id: ProgramId, +} + /// A peer zone whose outbox a zone watches for inbound cross-zone messages. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CrossZonePeer { /// The peer's Bedrock channel; its 32 bytes double as the peer's zone id. pub channel_id: ZoneId, - /// Programs on the local zone a message from this peer is allowed to target. - pub allowed_targets: Vec, + /// The deliveries this peer may make: which of its programs may emit, and + /// what each of them may reach here. + pub allowed_routes: Vec, /// The peer's block-signing public key, pinned to reject blocks inscribed by /// anyone other than that zone's sequencer. `None` skips the check (the /// channel signer is still authenticated by the zone-sdk). @@ -60,17 +77,32 @@ pub struct CrossZoneMessage { pub l1_inclusion_witness: Option>, } -/// Peer and per-peer target allowlists, plus this inbox's own zone id. +/// Per-peer delivery routes, plus this inbox's own zone id. #[derive( Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize, )] pub struct InboxConfig { pub self_zone: ZoneId, - pub allowed_peers: BTreeMap, - pub allowed_targets: BTreeMap>, + /// Which deliveries each peer may make. A peer absent from this map may + /// deliver nothing. + pub allowed_routes: BTreeMap>, } impl InboxConfig { + /// Whether `src_zone` may deliver from `src_program_id` to + /// `target_program_id`. A peer with no routes may deliver nothing. + #[must_use] + pub fn permits( + &self, + src_zone: &ZoneId, + src_program_id: ProgramId, + target_program_id: ProgramId, + ) -> bool { + self.allowed_routes + .get(src_zone) + .is_some_and(|routes| routes_permit(routes, src_program_id, target_program_id)) + } + /// Borsh-encoded form stored in the inbox config account. #[must_use] pub fn to_bytes(&self) -> Vec { @@ -122,6 +154,25 @@ pub enum Instruction { InitConfig(InboxConfig), } +/// Whether `routes` authorize a delivery from `src_program_id` to +/// `target_program_id`. +/// +/// The one place the rule lives. The inbox guest decides with it and the +/// sequencer's watcher drops unroutable messages with it, and those two must +/// agree: a watcher stricter than the guest loses messages silently, and one +/// looser records deliveries the guest will refuse, which production then feeds +/// in and gives up on. +#[must_use] +pub fn routes_permit( + routes: &[CrossZoneRoute], + src_program_id: ProgramId, + target_program_id: ProgramId, +) -> bool { + routes.iter().any(|route| { + route.src_program_id == src_program_id && route.target_program_id == target_program_id + }) +} + /// Content-addressed replay key for a delivered message. /// /// Hashes `(src_zone, src_block_id, src_tx_index)` under a domain separator. @@ -191,6 +242,63 @@ mod tests { [b; 32] } + fn program(n: u32) -> ProgramId { + [n; 8] + } + + /// The route is the pair. Two entries that are each reasonable on their own, + /// a lock program that may mint and a ping emitter that may reach a + /// receiver, must not compose into the lock program's target being + /// reachable from the ping emitter: that emitter lets its caller choose the + /// target, so it would mint with nothing locked behind it. + #[test] + fn a_route_authorizes_one_pair_and_does_not_compose() { + let lock = program(1); + let wrapped_token = program(2); + let ping_sender = program(3); + let ping_receiver = program(4); + + let mut allowed_routes = BTreeMap::new(); + allowed_routes.insert( + zone(9), + vec![ + CrossZoneRoute { + src_program_id: lock, + target_program_id: wrapped_token, + }, + CrossZoneRoute { + src_program_id: ping_sender, + target_program_id: ping_receiver, + }, + ], + ); + let config = InboxConfig { + self_zone: zone(1), + allowed_routes, + }; + + assert!(config.permits(&zone(9), lock, wrapped_token)); + assert!(config.permits(&zone(9), ping_sender, ping_receiver)); + + assert!( + !config.permits(&zone(9), ping_sender, wrapped_token), + "an emitter whose caller picks the target must not reach the bridge's target" + ); + assert!( + !config.permits(&zone(9), lock, ping_receiver), + "a route grants its own target, not every target the peer has" + ); + } + + #[test] + fn a_peer_with_no_routes_may_deliver_nothing() { + let config = InboxConfig { + self_zone: zone(1), + allowed_routes: BTreeMap::new(), + }; + assert!(!config.permits(&zone(9), program(1), program(2))); + } + #[test] fn message_key_is_stable_and_content_addressed() { assert_eq!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 3)); diff --git a/lez/programs/cross_zone_inbox/src/main.rs b/lez/programs/cross_zone_inbox/src/main.rs index bea71c69..4dec4478 100644 --- a/lez/programs/cross_zone_inbox/src/main.rs +++ b/lez/programs/cross_zone_inbox/src/main.rs @@ -85,13 +85,13 @@ fn dispatch( msg.src_zone != cfg.self_zone, "Source zone must not be this zone" ); - let allowed_targets = cfg - .allowed_targets - .get(&msg.src_zone) - .expect("Source zone is not an allowed peer"); + // Checked as a pair. The emitting program is as much a part of the + // authorization as the target: an emitter whose caller chooses the target + // reaches everything the peer may reach, so a target allowlist on its own + // lets any such emitter stand in for every other one. assert!( - allowed_targets.contains(&msg.target_program_id), - "Target program is not allowed for this peer" + cfg.permits(&msg.src_zone, msg.src_program_id, msg.target_program_id), + "No route from this source program to this target program for this peer" ); let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index); diff --git a/lez/sequencer/core/Cargo.toml b/lez/sequencer/core/Cargo.toml index 64b154f4..a2d8a21c 100644 --- a/lez/sequencer/core/Cargo.toml +++ b/lez/sequencer/core/Cargo.toml @@ -12,6 +12,7 @@ lee.workspace = true lee_core.workspace = true chain_state.workspace = true common.workspace = true +sequencer_core_metrics = { workspace = true, features = ["record"] } storage.workspace = true mempool.workspace = true logos-blockchain-zone-sdk.workspace = true diff --git a/lez/sequencer/core/metrics/Cargo.toml b/lez/sequencer/core/metrics/Cargo.toml new file mode 100644 index 00000000..c67496d3 --- /dev/null +++ b/lez/sequencer/core/metrics/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "sequencer_core_metrics" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[features] +default = [] +# Enable metrics record +record = ["dep:common", "dep:metrics", "dep:strum"] + +[dependencies] +common = { workspace = true, optional = true } + +metrics = { workspace = true, optional = true } +strum = { workspace = true, optional = true } diff --git a/lez/sequencer/core/metrics/src/lib.rs b/lez/sequencer/core/metrics/src/lib.rs new file mode 100644 index 00000000..b8884fa2 --- /dev/null +++ b/lez/sequencer/core/metrics/src/lib.rs @@ -0,0 +1,9 @@ +//! This crate provides all metrics exposed by the sequencer core crate. + +#[cfg(feature = "record")] +pub use record::*; + +pub mod names; + +#[cfg(feature = "record")] +pub mod record; diff --git a/lez/sequencer/core/metrics/src/names.rs b/lez/sequencer/core/metrics/src/names.rs new file mode 100644 index 00000000..92b1595e --- /dev/null +++ b/lez/sequencer/core/metrics/src/names.rs @@ -0,0 +1,9 @@ +pub const BLOCK_CREATION_TIME: &str = "block_creation_time_seconds"; +pub const CHAIN_HEIGHT: &str = "chain_height"; +pub const BLOCKS_PRODUCED_TOTAL: &str = "blocks_produced_total"; +pub const MEMPOOL_SIZE: &str = "mempool_size"; +pub const MEMPOOL_MAX_SIZE: &str = "mempool_max_size"; +pub const MEMPOOL_TRANSACTION_APPLICATION_TIME: &str = + "mempool_transaction_application_time_seconds"; +pub const TRANSACTIONS_PER_BLOCK: &str = "transactions_per_block"; +pub const MEMPOOL_FAILED_TRANSACTIONS_TOTAL: &str = "mempool_failed_transactions_total"; diff --git a/lez/sequencer/core/metrics/src/record.rs b/lez/sequencer/core/metrics/src/record.rs new file mode 100644 index 00000000..96b3304e --- /dev/null +++ b/lez/sequencer/core/metrics/src/record.rs @@ -0,0 +1,167 @@ +#![expect( + clippy::cast_precision_loss, + clippy::as_conversions, + reason = "It's okay for metrics" +)] + +use std::time::Duration; + +use metrics::{Counter, Histogram, Unit, counter, gauge, histogram}; +use strum::IntoEnumIterator as _; + +use crate::names; + +#[derive(Debug, Clone, Copy, strum::IntoStaticStr, strum::EnumIter)] +#[strum(serialize_all = "snake_case")] +pub enum TransactionOrigin { + User, + Sequencer, +} + +#[derive(Debug, Clone, Copy, strum::IntoStaticStr, strum::EnumIter)] +#[strum(serialize_all = "snake_case")] +pub enum TxKind { + Public, + PrivacyPreserving, + ProgramDeployment, +} + +/// Whether applying a transaction to the block's working state succeeded. +#[derive(Debug, Clone, Copy, strum::IntoStaticStr, strum::EnumIter)] +#[strum(serialize_all = "snake_case")] +pub enum ApplyStatus { + Applied, + Failed, +} + +impl From for TxKind { + fn from(kind: common::transaction::TxKind) -> Self { + match kind { + common::transaction::TxKind::Public => Self::Public, + common::transaction::TxKind::PrivacyPreserving => Self::PrivacyPreserving, + common::transaction::TxKind::ProgramDeployment => Self::ProgramDeployment, + } + } +} + +/// Initialize metrics. +pub fn init() { + blocks_produced_total_counter().increment(0); + mempool_failed_transactions_total_counter().increment(0); + record_mempool_size(0); + record_chain_height(0); + + drop(block_creation_time_histogram()); + drop(transactions_per_block_histogram()); + for origin in TransactionOrigin::iter() { + for kind in TxKind::iter() { + for status in ApplyStatus::iter() { + drop(mempool_transaction_application_time_histogram( + origin, kind, status, + )); + } + } + } +} + +fn block_creation_time_histogram() -> Histogram { + histogram!( + description: "Time taken to create a block", + unit: Unit::Seconds, + names::BLOCK_CREATION_TIME + ) +} + +pub fn record_block_creation_time(duration: Duration) { + block_creation_time_histogram().record(duration.as_secs_f64()); +} + +/// Height of the chain head, which moves backwards on a reorg, hence a gauge. +pub fn record_chain_height(height: u64) { + gauge!( + description: "Height of the chain head", + unit: Unit::Count, + names::CHAIN_HEIGHT + ) + .set(height as f64); +} + +fn blocks_produced_total_counter() -> Counter { + counter!( + description: "Number of blocks produced by this sequencer and applied to the head", + unit: Unit::Count, + names::BLOCKS_PRODUCED_TOTAL + ) +} + +pub fn increment_blocks_produced_total() { + blocks_produced_total_counter().increment(1); +} + +pub fn record_mempool_size(size: usize) { + gauge!( + description: "Size of the mempool", + unit: Unit::Count, + names::MEMPOOL_SIZE + ) + .set(u64::try_from(size).expect("Mempool size should fit into u64") as f64); +} + +pub fn record_mempool_max_size(size: usize) { + gauge!( + description: "Configured maximum size of the mempool", + unit: Unit::Count, + names::MEMPOOL_MAX_SIZE + ) + .set(u64::try_from(size).expect("Mempool max size should fit into u64") as f64); +} + +fn mempool_transaction_application_time_histogram( + origin: TransactionOrigin, + kind: TxKind, + status: ApplyStatus, +) -> Histogram { + histogram!( + description: "Time taken to apply a mempool transaction", + unit: Unit::Seconds, + names::MEMPOOL_TRANSACTION_APPLICATION_TIME, + "origin" => <&'static str>::from(origin), + "kind" => <&'static str>::from(kind), + "status" => <&'static str>::from(status), + ) +} + +pub fn record_mempool_transaction_application_time( + origin: TransactionOrigin, + kind: TxKind, + status: ApplyStatus, + duration: Duration, +) { + mempool_transaction_application_time_histogram(origin, kind, status) + .record(duration.as_secs_f64()); +} + +fn transactions_per_block_histogram() -> Histogram { + histogram!( + description: "Number of transactions from mempool included in block", + unit: Unit::Count, + names::TRANSACTIONS_PER_BLOCK + ) +} + +pub fn record_transactions_per_block(count: usize) { + transactions_per_block_histogram() + .record(u64::try_from(count).expect("Block transaction count should fit into u64") as f64); +} + +fn mempool_failed_transactions_total_counter() -> Counter { + counter!( + description: "Number of transactions from mempool that failed to be included in blocks", + unit: Unit::Count, + names::MEMPOOL_FAILED_TRANSACTIONS_TOTAL + ) +} + +pub fn increment_mempool_failed_transactions_total() { + mempool_failed_transactions_total_counter().increment(1); +} diff --git a/lez/sequencer/core/src/block_publisher.rs b/lez/sequencer/core/src/block_publisher.rs index e78f860a..ace60160 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -191,7 +191,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher { funding: Some(FundingConfig { funding_pk: config.funding_key, max_tx_fee: GasCost::new(logos_blockchain_core::mantle::Value::MAX), - priority_fee: FundingConfig::DEFAULT_PRIORITY_FEE, + priority_fee: config.priority_fee, }), ..ZoneSdkSequencerConfig::default() }; diff --git a/lez/sequencer/core/src/config.rs b/lez/sequencer/core/src/config.rs index aff0ae48..bce4a262 100644 --- a/lez/sequencer/core/src/config.rs +++ b/lez/sequencer/core/src/config.rs @@ -1,6 +1,7 @@ use std::{ fs::File, io::BufReader, + net::{IpAddr, Ipv4Addr, SocketAddr}, path::{Path, PathBuf}, time::Duration, }; @@ -8,7 +9,7 @@ use std::{ use anyhow::Result; use bytesize::ByteSize; use common::config::BasicAuth; -pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer}; +pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use humantime_serde; use lee::{AccountId, Balance}; use logos_blockchain_core::mantle::ops::channel::ChannelId; @@ -63,6 +64,9 @@ pub struct SequencerConfig { /// Cross-zone messaging configuration. `None` disables the watcher. #[serde(default)] pub cross_zone: Option, + /// Address the Prometheus metrics exporter binds to. + #[serde(default = "default_metrics_address")] + pub metrics_address: Option, } #[derive(Clone, Serialize, Deserialize)] @@ -74,9 +78,15 @@ pub struct BedrockConfig { /// Bedrock auth. pub auth: Option, pub funding_key: ZkPublicKey, + #[serde(default = "default_priority_fee")] + pub priority_fee: u64, } impl SequencerConfig { + /// Address [`Self::metrics_address`] falls back to when the config omits it. + pub const DEFAULT_METRICS_ADDRESS: SocketAddr = + SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 9000); + pub fn from_path(config_home: &Path) -> Result { let file = File::open(config_home)?; let reader = BufReader::new(file); @@ -88,3 +98,13 @@ impl SequencerConfig { const fn default_max_block_size() -> ByteSize { ByteSize::mib(1) } + +#[expect(clippy::unnecessary_wraps, reason = "Required by serde")] +const fn default_metrics_address() -> Option { + Some(SequencerConfig::DEFAULT_METRICS_ADDRESS) +} + +#[must_use] +pub const fn default_priority_fee() -> u64 { + logos_blockchain_zone_sdk::sequencer::FundingConfig::DEFAULT_PRIORITY_FEE +} diff --git a/lez/sequencer/core/src/cross_zone_watcher.rs b/lez/sequencer/core/src/cross_zone_watcher.rs index e3ca8ba3..3b2961f0 100644 --- a/lez/sequencer/core/src/cross_zone_watcher.rs +++ b/lez/sequencer/core/src/cross_zone_watcher.rs @@ -2,10 +2,9 @@ use std::{sync::Arc, time::Duration}; use common::{block::Block, transaction::LeeTransaction}; use cross_zone::{build_dispatch_from_emission, extract_emission}; -use cross_zone_inbox_core::message_key; +use cross_zone_inbox_core::{CrossZoneRoute, message_key, routes_permit}; use futures::{Stream, StreamExt as _}; use lee::PublicKey; -use lee_core::program::ProgramId; use log::{debug, error, info, warn}; use logos_blockchain_core::mantle::ops::channel::ChannelId; use logos_blockchain_zone_sdk::{ @@ -33,7 +32,7 @@ const DECODE_RETRY_LIMIT: u32 = 20; struct PeerContext { peer_zone: [u8; 32], self_zone: [u8; 32], - allowed_targets: Vec, + allowed_routes: Vec, expected_pubkey: Option, } @@ -216,7 +215,7 @@ pub fn spawn_watchers( PeerContext { peer_zone: peer.channel_id, self_zone, - allowed_targets: peer.allowed_targets, + allowed_routes: peer.allowed_routes, expected_pubkey, }, poll_interval, @@ -432,7 +431,7 @@ fn advance_cursor( fn record_block_deliveries(block: &Block, peer: &PeerContext, dbio: &RocksDBIO) -> bool { let peer_zone = peer.peer_zone; let self_zone = peer.self_zone; - let allowed_targets = peer.allowed_targets.as_slice(); + let allowed_routes = peer.allowed_routes.as_slice(); // Collected and written once. The pending list is a single value, so a write // per delivery would rewrite the whole list once per message, which is // quadratic in a peer block that carries many of them, on a task holding the @@ -450,9 +449,16 @@ fn record_block_deliveries(block: &Block, peer: &PeerContext, dbio: &RocksDBIO) if emission.target_zone != self_zone { continue; } - if !allowed_targets.contains(&emission.target_program_id) { + // Mirrors the inbox guest, which is the authority. Dropping here keeps + // an unroutable message from becoming a record that production would + // feed in and give up on three blocks later. + if !routes_permit( + allowed_routes, + message.program_id, + emission.target_program_id, + ) { warn!( - "Watcher dropping message to disallowed target from peer {}", + "Watcher dropping message from peer {}: no route from that source program to that target", hex::encode(peer_zone) ); continue; @@ -546,7 +552,10 @@ mod tests { PeerContext { peer_zone: PEER_ZONE, self_zone: SELF_ZONE, - allowed_targets: vec![programs::ping_receiver().id()], + allowed_routes: vec![CrossZoneRoute { + src_program_id: programs::ping_sender().id(), + target_program_id: programs::ping_receiver().id(), + }], expected_pubkey: None, } } @@ -561,11 +570,18 @@ mod tests { /// A `ping_sender` emission addressed to `SELF_ZONE`. fn emission() -> LeeTransaction { + emission_to(programs::ping_receiver().id()) + } + + /// A `ping_sender` emission aimed at `target_program_id`. The sender lets its + /// caller name any target, which is exactly why the route has to pin the + /// pair rather than the target alone. + fn emission_to(target_program_id: lee_core::program::ProgramId) -> LeeTransaction { let receiver_id = programs::ping_receiver().id(); let send = SenderInstruction::Send { outbox_program_id: programs::cross_zone_outbox().id(), target_zone: SELF_ZONE, - target_program_id: receiver_id, + target_program_id, target_accounts: vec![ping_record_pda(receiver_id).into_value()], payload: b"hi".to_vec(), ordinal: 0, @@ -594,6 +610,17 @@ mod tests { peer_msg(borsh::to_vec(&block).expect("block serializes"), slot) } + /// A stream item carrying a block whose one emission targets + /// `target_program_id`. + fn peer_block_msg_to( + block_id: u64, + slot: u64, + target_program_id: lee_core::program::ProgramId, + ) -> (ZoneMessage, Slot) { + let block = produce_dummy_block(block_id, None, vec![emission_to(target_program_id)]); + peer_msg(borsh::to_vec(&block).expect("block serializes"), slot) + } + fn undecodable_msg(slot: u64) -> (ZoneMessage, Slot) { peer_msg(b"not a block".to_vec(), slot) } @@ -781,6 +808,46 @@ mod tests { ); } + #[tokio::test] + async fn a_delivery_with_no_route_is_never_recorded() { + // The peer is routed to ping_receiver only. A bridging zone would also + // route its lock program to wrapped_token, and `ping_sender` lets its + // caller name wrapped_token as the target, so without the pair check + // this emission would be recorded and delivered, minting with nothing + // locked behind it. The guest rejects it too; dropping here keeps it + // from becoming a record production feeds in and gives up on. + let (_dir, dbio) = store(); + let mut cursor = None; + + let outcome = consume_peer_stream( + stream::iter(vec![peer_block_msg_to( + 1, + 0, + programs::wrapped_token().id(), + )]), + &peer_context(), + &dbio, + &mut cursor, + SkipPolicy::DeliverAll, + ) + .await; + + assert_eq!( + outcome, + PassOutcome::Drained, + "an unroutable message is not a failure" + ); + assert!( + recorded_keys(&dbio).is_empty(), + "a message with no route must not be recorded" + ); + assert_eq!( + get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(), + Some(Slot::from(0)), + "the slot was fully read, so the floor still advances" + ); + } + #[tokio::test] async fn watcher_records_every_delivery_it_reads() { let (_dir, dbio) = store(); diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 34ca24a5..e38a19ca 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -80,6 +80,15 @@ pub enum TransactionOrigin { Sequencer, } +impl From for sequencer_core_metrics::TransactionOrigin { + fn from(origin: TransactionOrigin) -> Self { + match origin { + TransactionOrigin::User => Self::User, + TransactionOrigin::Sequencer => Self::Sequencer, + } + } +} + #[derive(Clone, Debug, BorshDeserialize)] struct DepositMetadata { recipient_id: lee::AccountId, @@ -144,6 +153,9 @@ impl SequencerCore { ) .expect("Failed to create database with genesis block"); + // Incrementing count for genesis. + sequencer_core_metrics::increment_blocks_produced_total(); + (store, genesis_state) } } @@ -195,6 +207,8 @@ impl SequencerCore { pub async fn start_from_config( config: SequencerConfig, ) -> (Self, MemPoolHandle<(TransactionOrigin, LeeTransaction)>) { + sequencer_core_metrics::init(); + let bedrock_signing_key = load_or_create_signing_key(&config.home.join("bedrock_signing_key")) .expect("Failed to load or create bedrock signing key"); @@ -215,6 +229,7 @@ impl SequencerCore { let is_fresh_start = initial_checkpoint.is_none(); let (mempool, mempool_handle) = MemPool::new(config.mempool_max_size); + sequencer_core_metrics::record_mempool_max_size(config.mempool_max_size); let block_publisher = BP::new( &config.bedrock_config, @@ -299,6 +314,8 @@ impl SequencerCore { watchers, }; + sequencer_core_metrics::record_chain_height(sequencer_core.chain_height()); + (sequencer_core, mempool_handle) } @@ -596,6 +613,9 @@ impl SequencerCore { chain.head_state(), Some(&checkpoint_bytes), )?; + + sequencer_core_metrics::increment_blocks_produced_total(); + sequencer_core_metrics::record_chain_height(block.header.block_id); } // Neither branch persists anything, checkpoint included: the // inscription it holds as pending belongs to a block that is not @@ -802,6 +822,7 @@ impl SequencerCore { let clock_tx = clock_invocation(new_block_timestamp); let clock_lee_tx = LeeTransaction::Public(clock_tx.clone()); + sequencer_core_metrics::record_mempool_size(self.mempool.len()); // Everything drained from the store first, then user work. `from_store` // is not the same as a `Sequencer` origin: it says the transaction has a // record behind it and so needs no requeue, where the origin only says @@ -872,16 +893,31 @@ impl SequencerCore { break; } - if Self::apply_mempool_transaction( + let before_tx_apply = Instant::now(); + let applied = Self::apply_mempool_transaction( &mut working_state, origin, &tx, new_block_height, new_block_timestamp, &mut withdrawals, - ) { + ); + if applied { + sequencer_core_metrics::record_mempool_transaction_application_time( + origin.into(), + tx.kind().into(), + sequencer_core_metrics::ApplyStatus::Applied, + before_tx_apply.elapsed(), + ); valid_transactions.push(tx); } else { + sequencer_core_metrics::increment_mempool_failed_transactions_total(); + sequencer_core_metrics::record_mempool_transaction_application_time( + origin.into(), + tx.kind().into(), + sequencer_core_metrics::ApplyStatus::Failed, + before_tx_apply.elapsed(), + ); // A failed transaction is simply left out of the block, except a // dispatch: that one is re-fed from the store every turn, so one // that can never execute would fail on every block for ever. @@ -897,6 +933,7 @@ impl SequencerCore { .transition_from_public_transaction(&clock_tx, new_block_height, new_block_timestamp) .context("Clock transaction failed. Aborting block production.")?; valid_transactions.push(clock_lee_tx); + sequencer_core_metrics::record_transactions_per_block(valid_transactions.len()); let hashable_data = HashableBlockData { block_id: new_block_height, @@ -915,6 +952,8 @@ impl SequencerCore { now.elapsed().as_secs() ); + sequencer_core_metrics::record_block_creation_time(now.elapsed()); + Ok(BlockWithMeta { block, withdrawals }) } @@ -1159,7 +1198,7 @@ fn apply_follow_update( // The lock is held across the persist below so disk writes land in apply // order β€” the produce path persists under this same lock. - let (resubmit_txs, outcome) = { + let (resubmit_txs, outcome, head_height) = { let mut chain = chain.lock().expect("chain state mutex poisoned"); // Outcomes align with `adopted`. @@ -1259,9 +1298,11 @@ fn apply_follow_update( }) .unwrap_or_else(|err| panic!("Failed to persist follow update: {err:#}")); - (resubmit_txs, outcome) + (resubmit_txs, outcome, head_tip.map_or(0, |tip| tip.id)) }; + sequencer_core_metrics::record_chain_height(head_height); + if outcome.accepted_deposits > 0 { info!( "Recorded {} Bedrock Deposit event(s); their mints are drained from the store on our next turn", diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 44778a22..8d3f787b 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -36,7 +36,10 @@ use crate::{ block_publisher::FollowUpdate, block_store::SequencerStore, build_bridge_deposit_tx_from_event, build_genesis_state, classify_settled_deliveries, - config::{BedrockConfig, CrossZoneConfig, CrossZonePeer, GenesisAction, SequencerConfig}, + config::{ + self, BedrockConfig, CrossZoneConfig, CrossZonePeer, CrossZoneRoute, GenesisAction, + SequencerConfig, + }, deposit_already_minted, dispatch_already_delivered, extract_cross_zone_dispatch, extract_cross_zone_dispatch_key, is_sequencer_only_program, mock::{SequencerCoreWithMockClients, mock_checkpoint}, @@ -83,10 +86,12 @@ fn setup_sequencer_config() -> SequencerConfig { node_url: "http://not-used-in-unit-tests".parse().unwrap(), auth: None, funding_key: ZkPublicKey::zero(), + priority_fee: config::default_priority_fee(), }, retry_pending_blocks_timeout: Duration::from_mins(4), genesis: vec![], cross_zone: None, + metrics_address: None, } } @@ -174,7 +179,10 @@ fn cross_zone_test_config() -> SequencerConfig { cross_zone: Some(CrossZoneConfig { peers: vec![CrossZonePeer { channel_id: PEER_ZONE, - allowed_targets: vec![programs::ping_receiver().id()], + allowed_routes: vec![CrossZoneRoute { + src_program_id: programs::ping_sender().id(), + target_program_id: programs::ping_receiver().id(), + }], expected_block_signing_pubkey: None, }], }), diff --git a/lez/sequencer/service/Cargo.toml b/lez/sequencer/service/Cargo.toml index 338aac0d..396fbe6b 100644 --- a/lez/sequencer/service/Cargo.toml +++ b/lez/sequencer/service/Cargo.toml @@ -15,6 +15,7 @@ mempool.workspace = true sequencer_core = { workspace = true, features = ["testnet"] } sequencer_service_protocol.workspace = true sequencer_service_rpc = { workspace = true, features = ["server"] } +sequencer_service_metrics = { workspace = true, features = ["record"] } programs.workspace = true clap = { workspace = true, features = ["derive", "env"] } @@ -22,6 +23,7 @@ anyhow.workspace = true env_logger.workspace = true hex.workspace = true log.workspace = true +metrics-exporter-prometheus.workspace = true tokio.workspace = true tokio-util.workspace = true jsonrpsee.workspace = true diff --git a/lez/sequencer/service/Dockerfile b/lez/sequencer/service/Dockerfile index 1919f775..2e0b89a9 100644 --- a/lez/sequencer/service/Dockerfile +++ b/lez/sequencer/service/Dockerfile @@ -63,8 +63,9 @@ COPY --from=builder --chown=sequencer_service_user:sequencer_service_user /usr/l VOLUME /var/lib/sequencer_service -# Expose default port +# Expose default ports EXPOSE 3040 +EXPOSE 9000 # Health check (TODO #244: Replace when a real health endpoint is available) HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ diff --git a/lez/sequencer/service/docker-compose.yml b/lez/sequencer/service/docker-compose.yml index 477072ad..d9c573d3 100644 --- a/lez/sequencer/service/docker-compose.yml +++ b/lez/sequencer/service/docker-compose.yml @@ -18,6 +18,7 @@ services: container_name: sequencer_service ports: - "3040:3040" + - "9000:9000" volumes: # Mount configuration file - ./configs/docker/sequencer_config.json:/etc/sequencer_service/sequencer_config.json diff --git a/lez/sequencer/service/metrics/Cargo.toml b/lez/sequencer/service/metrics/Cargo.toml new file mode 100644 index 00000000..46dd2d5a --- /dev/null +++ b/lez/sequencer/service/metrics/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "sequencer_service_metrics" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[features] +default = [] +# Enable metrics record +record = ["dep:metrics"] + +[dependencies] +metrics = { workspace = true, optional = true } diff --git a/lez/sequencer/service/metrics/src/lib.rs b/lez/sequencer/service/metrics/src/lib.rs new file mode 100644 index 00000000..f375ff1b --- /dev/null +++ b/lez/sequencer/service/metrics/src/lib.rs @@ -0,0 +1,9 @@ +//! This crate provides all metrics exposed by the sequencer service crate. + +#[cfg(feature = "record")] +pub use record::*; + +pub mod names; + +#[cfg(feature = "record")] +pub mod record; diff --git a/lez/sequencer/service/metrics/src/names.rs b/lez/sequencer/service/metrics/src/names.rs new file mode 100644 index 00000000..1ac19948 --- /dev/null +++ b/lez/sequencer/service/metrics/src/names.rs @@ -0,0 +1,3 @@ +pub const SUBMITTED_TRANSACTIONS_TOTAL: &str = "submitted_transactions_total"; +pub const BEFORE_MEMPOOL_FAILED_TRANSACTIONS_TOTAL: &str = + "before_mempool_failed_transactions_total"; diff --git a/lez/sequencer/service/metrics/src/record.rs b/lez/sequencer/service/metrics/src/record.rs new file mode 100644 index 00000000..05eaac1a --- /dev/null +++ b/lez/sequencer/service/metrics/src/record.rs @@ -0,0 +1,32 @@ +use metrics::{Counter, Unit, counter}; + +use crate::names; + +pub fn init() { + submitted_transactions_total_counter().increment(0); + before_mempool_failed_transactions_total_counter().increment(0); +} + +fn submitted_transactions_total_counter() -> Counter { + counter!( + description: "Number of transactions submitted", + unit: Unit::Count, + names::SUBMITTED_TRANSACTIONS_TOTAL + ) +} + +pub fn increment_submitted_transactions_total() { + submitted_transactions_total_counter().increment(1); +} + +fn before_mempool_failed_transactions_total_counter() -> Counter { + counter!( + description: "Number of transactions that failed before reaching the mempool", + unit: Unit::Count, + names::BEFORE_MEMPOOL_FAILED_TRANSACTIONS_TOTAL + ) +} + +pub fn increment_before_mempool_failed_transactions_total() { + before_mempool_failed_transactions_total_counter().increment(1); +} diff --git a/lez/sequencer/service/src/lib.rs b/lez/sequencer/service/src/lib.rs index ba5b68ec..3073823a 100644 --- a/lez/sequencer/service/src/lib.rs +++ b/lez/sequencer/service/src/lib.rs @@ -211,6 +211,8 @@ async fn wait_for_store_release(store: &StoreRelease) { } pub async fn run(config: SequencerConfig, listen_addr: SocketAddr) -> Result { + sequencer_service_metrics::init(); + let block_timeout = config.block_create_timeout; let max_block_size = config.max_block_size; diff --git a/lez/sequencer/service/src/main.rs b/lez/sequencer/service/src/main.rs index 95d02e16..b3d5bf71 100644 --- a/lez/sequencer/service/src/main.rs +++ b/lez/sequencer/service/src/main.rs @@ -3,9 +3,10 @@ use std::{ path::PathBuf, }; -use anyhow::Result; +use anyhow::{Context as _, Result}; use clap::Parser; use log::{error, info}; +use metrics_exporter_prometheus::{Matcher, PrometheusBuilder}; use tokio::signal::unix::{SignalKind, signal}; use tokio_util::sync::CancellationToken; @@ -24,6 +25,10 @@ struct Args { /// so multiple instances can share one config file. #[clap(long)] home: Option, + /// Override the config's `metrics_address`, so multiple instances can share + /// one config file without fighting over the exporter port. + #[clap(long)] + metrics_address: Option, } #[tokio::main] @@ -34,21 +39,18 @@ struct Args { async fn main() -> Result<()> { env_logger::init(); - let Args { - config_path, - port, - listen_address, - home, - } = Args::parse(); + let args = Args::parse(); let cancellation_token = listen_for_shutdown_signal(); - let mut config = sequencer_service::SequencerConfig::from_path(&config_path)?; - if let Some(home) = home { - config.home = home; + let mut config = sequencer_service::SequencerConfig::from_path(&args.config_path)?; + apply_config_overrides(&args, &mut config); + + if let Some(metrics_address) = config.metrics_address { + install_prometheus_recorder(metrics_address)?; } let mut sequencer_handle = - sequencer_service::run(config, SocketAddr::new(listen_address, port)).await?; + sequencer_service::run(config, SocketAddr::new(args.listen_address, args.port)).await?; tokio::select! { () = cancellation_token.cancelled() => { @@ -71,6 +73,45 @@ async fn main() -> Result<()> { Ok(()) } +fn apply_config_overrides(args: &Args, config: &mut sequencer_service::SequencerConfig) { + let Args { + home, + metrics_address, + config_path: _, + port: _, + listen_address: _, + } = args; + + if let Some(home) = home { + config.home.clone_from(home); + } + if let Some(metrics_address) = metrics_address { + config.metrics_address = Some(*metrics_address); + } +} + +/// Installs the recorder on `metrics_address`. +fn install_prometheus_recorder(metrics_address: SocketAddr) -> Result<()> { + /// Ladder for `*_seconds` histograms, densest across the 1–100 ms band where + /// block production and transaction application actually land. + const LATENCY_BUCKETS: &[f64] = &[ + 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, + ]; + + /// Fallback ladder for histograms that count things rather than measure time. + const COUNT_BUCKETS: &[f64] = &[1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0]; + + PrometheusBuilder::new() + .with_http_listener(metrics_address) + .with_recommended_naming(true) + .set_buckets(COUNT_BUCKETS) + .context("Failed to set default histogram buckets")? + .set_buckets_for_metric(Matcher::Suffix("_seconds".to_owned()), LATENCY_BUCKETS) + .context("Failed to set latency histogram buckets")? + .install() + .context("Failed to install Prometheus recorder") +} + /// Cancelled on Ctrl-C or `SIGTERM`. /// /// `SIGTERM` is what a container runtime sends first, so without it every diff --git a/lez/sequencer/service/src/service.rs b/lez/sequencer/service/src/service.rs index 7ab9ed3c..e55735c0 100644 --- a/lez/sequencer/service/src/service.rs +++ b/lez/sequencer/service/src/service.rs @@ -6,7 +6,7 @@ use jsonrpsee::{ types::{ErrorCode, ErrorObjectOwned}, }; use lee; -use log::warn; +use log::{error, warn}; use mempool::MemPoolHandle; use sequencer_core::{ DbError, SequencerCore, TransactionOrigin, block_publisher::BlockPublisherTrait, @@ -44,49 +44,62 @@ impl sequencer_service_rpc::Rpc for SequencerService { async fn send_transaction(&self, tx: LeeTransaction) -> Result { - // Reserve ~200 bytes for block header overhead - const BLOCK_HEADER_OVERHEAD: u64 = 200; + sequencer_service_metrics::increment_submitted_transactions_total(); let tx_hash = tx.hash(); - let encoded_tx = - borsh::to_vec(&tx).expect("Transaction borsh serialization should not fail"); - let tx_size = u64::try_from(encoded_tx.len()).expect("Transaction size should fit in u64"); + let res = async move { + // Reserve ~200 bytes for block header overhead + const BLOCK_HEADER_OVERHEAD: u64 = 200; - let max_tx_size = self.max_block_size.saturating_sub(BLOCK_HEADER_OVERHEAD); + let encoded_tx = + borsh::to_vec(&tx).expect("Transaction borsh serialization should not fail"); + let tx_size = + u64::try_from(encoded_tx.len()).expect("Transaction size should fit in u64"); - if tx_size > max_tx_size { - return Err(ErrorObjectOwned::owned( - ErrorCode::InvalidParams.code(), - format!("Transaction too large: size {tx_size}, max {max_tx_size}"), - None::<()>, - )); - } + let max_tx_size = self.max_block_size.saturating_sub(BLOCK_HEADER_OVERHEAD); - let authenticated_tx = tx - .transaction_stateless_check() - .inspect_err(|err| warn!("Error at pre_check {err:#?}")) - .map_err(|err| { - ErrorObjectOwned::owned( + if tx_size > max_tx_size { + return Err(ErrorObjectOwned::owned( ErrorCode::InvalidParams.code(), - format!("{err:?}"), + format!("Transaction too large: size {tx_size}, max {max_tx_size}"), None::<()>, - ) - })?; + )); + } - // Sequencer-only programs (the cross-zone inbox) are injected by the - // watcher; a user must not invoke them top-level, or anyone could forge - // an inbound cross-zone delivery. Chained user calls are already rejected - // by the inbox guest's caller-is-none assertion. - if let LeeTransaction::Public(public_tx) = &authenticated_tx - && sequencer_core::is_sequencer_only_program(public_tx.message().program_id) - { - return Err(ErrorObjectOwned::owned( - ErrorCode::InvalidParams.code(), - "Program is sequencer-only and cannot be invoked by a user transaction".to_owned(), - None::<()>, - )); - } + let authenticated_tx = tx + .transaction_stateless_check() + .inspect_err(|err| warn!("Error at pre_check {err:#?}")) + .map_err(|err| { + ErrorObjectOwned::owned( + ErrorCode::InvalidParams.code(), + format!("{err:?}"), + None::<()>, + ) + })?; + + // Sequencer-only programs (the cross-zone inbox) are injected by the + // watcher; a user must not invoke them top-level, or anyone could forge + // an inbound cross-zone delivery. Chained user calls are already rejected + // by the inbox guest's caller-is-none assertion. + if let LeeTransaction::Public(public_tx) = &authenticated_tx + && sequencer_core::is_sequencer_only_program(public_tx.message().program_id) + { + return Err(ErrorObjectOwned::owned( + ErrorCode::InvalidParams.code(), + "Program is sequencer-only and cannot be invoked by a user transaction" + .to_owned(), + None::<()>, + )); + } + + Ok(authenticated_tx) + }; + + let authenticated_tx = res.await.inspect_err(|err| { + sequencer_service_metrics::increment_before_mempool_failed_transactions_total(); + error!("Transaction failed before reaching mempool: {err:#?}"); + })?; self.mempool_handle .push((TransactionOrigin::User, authenticated_tx)) diff --git a/lez/wallet/src/lib.rs b/lez/wallet/src/lib.rs index f54fd551..d6d7d316 100644 --- a/lez/wallet/src/lib.rs +++ b/lez/wallet/src/lib.rs @@ -456,7 +456,6 @@ impl WalletCore { let LeeTransaction::PrivacyPreserving(pp_tx) = &tx else { continue; }; - pp_tx.message.validate_note_lengths()?; // Sync updates while watching only the init nullifier. self.storage .key_chain_mut() @@ -677,7 +676,7 @@ impl WalletCore { tx: &lee::privacy_preserving_transaction::PrivacyPreservingTransaction, acc_decode_mask: &[AccDecodeData], ) -> Result<()> { - let note_count = tx.message.validate_note_lengths()?; + let note_count = tx.message.private_actions.len(); anyhow::ensure!( note_count >= acc_decode_mask.len(), "Decode mask has {} entries but the transaction has {note_count} notes", @@ -785,12 +784,10 @@ impl WalletCore { &program.to_owned(), )?; - let message = - lee::privacy_preserving_transaction::message::Message::try_from_circuit_output( - acc_manager.public_account_ids(), - acc_manager.public_account_nonces(), - output, - )?; + let message = lee::privacy_preserving_transaction::message::Message::from_circuit_output( + acc_manager.public_account_nonces(), + output, + ); let message_hash = message.hash(); let signatures_public_keys = acc_manager @@ -933,7 +930,6 @@ impl WalletCore { let LeeTransaction::PrivacyPreserving(pp_tx) = &tx else { continue; }; - pp_tx.message.validate_note_lengths()?; // Eagerly decrypt note updates using expected nullifiers. let handled = self .storage @@ -972,18 +968,19 @@ impl WalletCore { &key_chain.viewing_public_key, ); message - .encrypted_private_post_states + .private_actions .iter() .enumerate() - .filter(move |(ciph_id, encrypted_data)| { + .filter(move |(ciph_id, action)| { // If we have not decrypted the update using the nullifiers, // the note may be an initialized one, for which we should // scan. - !handled.contains(ciph_id) && encrypted_data.view_tag == view_tag + !handled.contains(ciph_id) + && action.encrypted_post_state.view_tag == view_tag }) - .filter_map(move |(ciph_id, encrypted_data)| { - let shared_secret = - key_chain.calculate_shared_secret_receiver(&encrypted_data.epk)?; + .filter_map(move |(ciph_id, action)| { + let shared_secret = key_chain + .calculate_shared_secret_receiver(&action.encrypted_post_state.epk)?; decrypt_note_at(message, ciph_id, &shared_secret).map(|(kind, res_acc)| { let npk = &key_chain.nullifier_public_key; @@ -1040,16 +1037,14 @@ impl WalletCore { for (account_id, npk, vpk, vsk, nsk) in shared_keys { let view_tag = EncryptedAccountData::compute_view_tag(&npk, &vpk); - for (ciph_id, encrypted_data) in - message.encrypted_private_post_states.iter().enumerate() - { + for (ciph_id, action) in message.private_actions.iter().enumerate() { // If already decrypted or the tag does not match, skip. - if handled.contains(&ciph_id) || encrypted_data.view_tag != view_tag { + if handled.contains(&ciph_id) || action.encrypted_post_state.view_tag != view_tag { continue; } let Some(shared_secret) = - SharedSecretKey::decapsulate(&encrypted_data.epk, &vsk.d, &vsk.z) + SharedSecretKey::decapsulate(&action.encrypted_post_state.epk, &vsk.d, &vsk.z) else { continue; }; @@ -1086,9 +1081,9 @@ fn decrypt_note_at( secret: &SharedSecretKey, ) -> Option<(lee_core::PrivateAccountKind, Account)> { lee_core::EncryptionScheme::decrypt( - &message.encrypted_private_post_states[i].ciphertext, + &message.private_actions[i].encrypted_post_state.ciphertext, secret, - &message.new_nullifiers[i].0, + &message.private_actions[i].nullifier, ) } diff --git a/lez/wallet/src/storage/key_chain.rs b/lez/wallet/src/storage/key_chain.rs index d5986e57..3f5eba05 100644 --- a/lez/wallet/src/storage/key_chain.rs +++ b/lez/wallet/src/storage/key_chain.rs @@ -390,9 +390,9 @@ impl UserKeyChain { index: &mut NullifierIndex, ) -> HashSet { let mut handled = HashSet::new(); - for (i, (old_nullifier, _)) in message.new_nullifiers.iter().enumerate() { + for (i, action) in message.private_actions.iter().enumerate() { // Get the nullifier information if awaiting the nullifier. - let Some(account_id) = index.account_for(old_nullifier) else { + let Some(account_id) = index.account_for(&action.nullifier) else { continue; }; // Try decrypting the commitment connected to the nullifier and get the next @@ -400,7 +400,7 @@ impl UserKeyChain { if let Some(new_nullifier) = self.apply_nullifier_update(account_id, message, i) { // Update the index to await for the new state of the account, i.e. // the new nullifier. - index.update(old_nullifier, new_nullifier, account_id); + index.update(&action.nullifier, new_nullifier, account_id); // Record that this nullifier's position can be skipped for scanning. handled.insert(i); } @@ -416,7 +416,7 @@ impl UserKeyChain { message: &Message, i: usize, ) -> Option { - let encrypted = &message.encrypted_private_post_states[i]; + let encrypted = &message.private_actions[i].encrypted_post_state; let (nsk, secret, is_shared) = if let Some(entry) = self.shared_private_account(account_id) { @@ -474,10 +474,9 @@ impl UserKeyChain { pub fn locate_spend(&self, account_id: AccountId, message: &Message) -> Option { let init = Nullifier::for_account_initialization(&account_id); let update = self.next_update_nullifier(account_id); - message - .new_nullifiers - .iter() - .position(|(nullifier, _)| *nullifier == init || Some(nullifier) == update.as_ref()) + message.private_actions.iter().position(|action| { + action.nullifier == init || Some(&action.nullifier) == update.as_ref() + }) } pub fn add_imported_public_account(&mut self, private_key: lee::PrivateKey) { @@ -890,7 +889,7 @@ impl Default for UserKeyChain { #[cfg(test)] mod tests { - use lee_core::{EncryptionScheme, encryption::EncryptedAccountData}; + use lee_core::{EncryptionScheme, PrivateAction, encryption::EncryptedAccountData}; use super::*; @@ -935,9 +934,12 @@ mod tests { ); let message = Message { - encrypted_private_post_states: vec![note], - new_commitments: vec![new_commitment], - new_nullifiers: vec![(old_nullifier, [0; 32])], + private_actions: vec![PrivateAction { + nullifier: old_nullifier, + commitment: new_commitment, + encrypted_post_state: note, + ..Default::default() + }], ..Default::default() }; @@ -999,9 +1001,12 @@ mod tests { ); let note = EncryptedAccountData::new(ciphertext, &npk, &vpk, epk); let message = Message { - encrypted_private_post_states: vec![note], - new_commitments: vec![new_commitment], - new_nullifiers: vec![(old_nullifier, [0; 32])], + private_actions: vec![PrivateAction { + nullifier: old_nullifier, + commitment: new_commitment, + encrypted_post_state: note, + ..Default::default() + }], ..Default::default() }; @@ -1061,9 +1066,12 @@ mod tests { ); let note = EncryptedAccountData::new(ciphertext, &npk, &vpk, epk); Message { - encrypted_private_post_states: vec![note], - new_commitments: vec![commitment], - new_nullifiers: vec![(spent, [0; 32])], + private_actions: vec![PrivateAction { + nullifier: spent, + commitment, + encrypted_post_state: note, + ..Default::default() + }], ..Default::default() } }; @@ -1124,7 +1132,10 @@ mod tests { &[9; 32], ); let message = Message { - new_nullifiers: vec![(unindexed, [0; 32])], + private_actions: vec![PrivateAction { + nullifier: unindexed, + ..Default::default() + }], ..Default::default() }; diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 00000000..5d6ab935 --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,36 @@ +# Prometheus + Grafana monitoring stack. +services: + prometheus: + image: prom/prometheus:v3.13.1 + container_name: prometheus + command: + - --config.file=/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + # Lets Prometheus reach services running natively on the host. + extra_hosts: + - "host.docker.internal:host-gateway" + + grafana: + image: grafana/grafana:13.1.1 + container_name: grafana + ports: + - "3000:3000" + environment: + # Dev-only: open Grafana with no login, full access. + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + - GF_AUTH_DISABLE_LOGIN_FORM=true + volumes: + - ./grafana/provisioning:/etc/grafana/provisioning:ro + - ./grafana/dashboards:/var/lib/grafana/dashboards:ro + - grafana_data:/var/lib/grafana + depends_on: + - prometheus + +volumes: + prometheus_data: + grafana_data: diff --git a/monitoring/grafana/dashboards/sequencer.json b/monitoring/grafana/dashboards/sequencer.json new file mode 100644 index 00000000..ede0ae4b --- /dev/null +++ b/monitoring/grafana/dashboards/sequencer.json @@ -0,0 +1,377 @@ +{ + "annotations": { "list": [ ] }, + "editable": true, + "graphTooltip": 1, + "panels": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "color": { "mode": "fixed", "fixedColor": "blue" }, "unit": "short", "decimals": 0 }, + "overrides": [ ] + }, + "gridPos": { "h": 7, "w": 6, "x": 0, "y": 0 }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "chain_height", + "legendFormat": "height", + "refId": "A" + } + ], + "title": "Chain height", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "color": { "mode": "fixed", "fixedColor": "green" }, "unit": "short", "decimals": 0 }, + "overrides": [ ] + }, + "gridPos": { "h": 7, "w": 6, "x": 6, "y": 0 }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "blocks_produced_total", + "legendFormat": "produced", + "refId": "A" + } + ], + "title": "Blocks produced by this sequencer since startup", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, "unit": "short" }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "produced Β· blocks/min" }, + "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] + } + ] + }, + "gridPos": { "h": 7, "w": 12, "x": 12, "y": 0 }, + "id": 3, + "options": { + "legend": { "displayMode": "list", "placement": "bottom", "calcs": [ "last", "max" ] }, + "tooltip": { "mode": "single" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rate(blocks_produced_total[$__rate_interval]) * 60", + "legendFormat": "produced Β· blocks/min", + "refId": "A" + } + ], + "title": "Block production rate", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, "unit": "s" }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "avg" }, + "properties": [ + { "id": "custom.lineStyle", "value": { "dash": [ 8, 4 ], "fill": "dash" } }, + { "id": "color", "value": { "mode": "fixed", "fixedColor": "text" } } + ] + } + ] + }, + "gridPos": { "h": 9, "w": 24, "x": 0, "y": 7 }, + "id": 4, + "options": { + "legend": { "displayMode": "table", "placement": "bottom", "calcs": [ "last", "max" ] }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(${percentile}, sum by (le) (rate(block_creation_time_seconds_bucket[$__rate_interval])))", + "legendFormat": "${percentile:text}", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rate(block_creation_time_seconds_sum[$__rate_interval]) / rate(block_creation_time_seconds_count[$__rate_interval])", + "legendFormat": "avg", + "refId": "B" + } + ], + "title": "Block creation time", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, "unit": "s" }, + "overrides": [ ] + }, + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 16 }, + "id": 5, + "options": { + "legend": { "displayMode": "table", "placement": "bottom", "calcs": [ "last", "max" ] }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(${percentile}, sum by (le, kind, origin, status) (rate(mempool_transaction_application_time_seconds_bucket[$__rate_interval])))", + "legendFormat": "{{kind}} Β· {{origin}} Β· {{status}}", + "refId": "A" + } + ], + "title": "Transaction application time", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, "unit": "short" }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "avg" }, + "properties": [ + { "id": "custom.lineStyle", "value": { "dash": [ 8, 4 ], "fill": "dash" } }, + { "id": "color", "value": { "mode": "fixed", "fixedColor": "text" } } + ] + } + ] + }, + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 16 }, + "id": 6, + "options": { + "legend": { "displayMode": "table", "placement": "bottom", "calcs": [ "last", "max" ] }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "histogram_quantile(${percentile}, sum by (le) (rate(transactions_per_block_bucket[$__rate_interval])))", + "legendFormat": "${percentile:text}", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rate(transactions_per_block_sum[$__rate_interval]) / rate(transactions_per_block_count[$__rate_interval])", + "legendFormat": "avg", + "refId": "B" + } + ], + "title": "Transactions per block", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "decimals": 1, + "min": 0.0, + "max": 100.0, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 70.0 }, + { "color": "red", "value": 90.0 } + ] + } + }, + "overrides": [ ] + }, + "gridPos": { "h": 8, "w": 6, "x": 0, "y": 25 }, + "id": 7, + "options": { + "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "100 * mempool_size / mempool_max_size", + "legendFormat": "utilization", + "refId": "A" + } + ], + "title": "Mempool utilization", + "type": "gauge" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10, "spanNulls": true }, + "unit": "short", + "min": 0.0 + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "capacity" }, + "properties": [ + { "id": "custom.lineStyle", "value": { "dash": [ 8, 4 ], "fill": "dash" } }, + { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } + ] + }, + { + "matcher": { "id": "byName", "options": "queued" }, + "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "blue" } } ] + } + ] + }, + "gridPos": { "h": 8, "w": 18, "x": 6, "y": 25 }, + "id": 8, + "options": { + "legend": { "displayMode": "table", "placement": "bottom", "calcs": [ "last", "max" ] }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "mempool_size", + "legendFormat": "queued", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "mempool_max_size", + "legendFormat": "capacity", + "refId": "B" + } + ], + "title": "Mempool size vs capacity", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "decimals": 2, + "min": 0.0, + "max": 100.0, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "orange", "value": 1.0 }, + { "color": "red", "value": 5.0 } + ] + } + }, + "overrides": [ ] + }, + "gridPos": { "h": 8, "w": 6, "x": 0, "y": 33 }, + "id": 9, + "options": { + "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "100 * (increase(before_mempool_failed_transactions_total[$__range]) + increase(mempool_failed_transactions_total[$__range])) / clamp_min(increase(submitted_transactions_total[$__range]), 1)", + "legendFormat": "failed", + "refId": "A" + } + ], + "title": "Failed transactions share", + "type": "gauge" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 35, "gradientMode": "opacity" }, + "unit": "short", + "min": 0.0 + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "submitted" }, + "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "green" } } ] + }, + { + "matcher": { "id": "byName", "options": "failed Β· before mempool" }, + "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] + }, + { + "matcher": { "id": "byName", "options": "failed Β· in mempool" }, + "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] + } + ] + }, + "gridPos": { "h": 8, "w": 18, "x": 6, "y": 33 }, + "id": 10, + "options": { + "legend": { "displayMode": "table", "placement": "bottom", "calcs": [ "last", "max" ] }, + "tooltip": { "mode": "multi", "sort": "desc" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rate(submitted_transactions_total[$__rate_interval]) * 60", + "legendFormat": "submitted", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rate(before_mempool_failed_transactions_total[$__rate_interval]) * 60", + "legendFormat": "failed Β· before mempool", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rate(mempool_failed_transactions_total[$__rate_interval]) * 60", + "legendFormat": "failed Β· in mempool", + "refId": "C" + } + ], + "title": "Submitted vs failed transactions (per minute)", + "type": "timeseries" + } + ], + "refresh": "5s", + "schemaVersion": 39, + "tags": [ "sequencer" ], + "templating": { + "list": [ + { + "current": { "selected": true, "text": "p95", "value": "0.95" }, + "includeAll": false, + "label": "Percentile", + "multi": false, + "name": "percentile", + "options": [ + { "selected": false, "text": "p50", "value": "0.5" }, + { "selected": false, "text": "p90", "value": "0.9" }, + { "selected": true, "text": "p95", "value": "0.95" }, + { "selected": false, "text": "p99", "value": "0.99" } + ], + "query": "p50 : 0.5, p90 : 0.9, p95 : 0.95, p99 : 0.99", + "type": "custom" + } + ] + }, + "time": { "from": "now-15m", "to": "now" }, + "timezone": "", + "title": "Sequencer", + "uid": "sequencer" +} diff --git a/monitoring/grafana/provisioning/dashboards/dashboards.yml b/monitoring/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 00000000..e26d8f8c --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +providers: + - name: sequencer + type: file + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/monitoring/grafana/provisioning/datasources/prometheus.yml b/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 00000000..9bf28c50 --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true + jsonData: + # Must match prometheus.yml's `scrape_interval` + timeInterval: 5s diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 00000000..a4793aba --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 5s + evaluation_interval: 5s + +scrape_configs: + - job_name: sequencer + metrics_path: /metrics + static_configs: + - targets: + - host.docker.internal:9000 diff --git a/test_fixtures/fixtures/prebuilt_sequencer_db.dump b/test_fixtures/fixtures/prebuilt_sequencer_db.dump index 52bea41d..d56c32dc 100644 Binary files a/test_fixtures/fixtures/prebuilt_sequencer_db.dump and b/test_fixtures/fixtures/prebuilt_sequencer_db.dump differ diff --git a/test_fixtures/src/config.rs b/test_fixtures/src/config.rs index 8df57fb3..b2835348 100644 --- a/test_fixtures/src/config.rs +++ b/test_fixtures/src/config.rs @@ -108,8 +108,10 @@ pub fn sequencer_config( .context("Failed to convert bedrock addr to URL")?, funding_key, auth: None, + priority_fee: sequencer_core::config::default_priority_fee(), }, cross_zone, + metrics_address: Some(SequencerConfig::DEFAULT_METRICS_ADDRESS), }) } diff --git a/tools/cross_zone_chat/src/main.rs b/tools/cross_zone_chat/src/main.rs index e9205d97..88f6d385 100644 --- a/tools/cross_zone_chat/src/main.rs +++ b/tools/cross_zone_chat/src/main.rs @@ -54,7 +54,7 @@ use axum::{ routing::{get, post}, }; use common::{block::BedrockStatus, transaction::LeeTransaction}; -use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer, Instruction, ZoneId}; +use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute, Instruction, ZoneId}; use cross_zone_outbox_core::outbox_pda; use lee::{ ProgramId, PublicTransaction, @@ -348,7 +348,10 @@ fn watch_peer(peer: ZoneId, receiver_id: ProgramId) -> CrossZoneConfig { CrossZoneConfig { peers: vec![CrossZonePeer { channel_id: peer, - allowed_targets: vec![receiver_id], + allowed_routes: vec![CrossZoneRoute { + src_program_id: programs::ping_sender().id(), + target_program_id: receiver_id, + }], expected_block_signing_pubkey: None, }], } diff --git a/tools/dashboard_gen/Cargo.toml b/tools/dashboard_gen/Cargo.toml new file mode 100644 index 00000000..4bc73e86 --- /dev/null +++ b/tools/dashboard_gen/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "dashboard_gen" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +sequencer_core_metrics.workspace = true +sequencer_service_metrics.workspace = true + +clap = { workspace = true, features = ["derive"] } +serde = { workspace = true, features = ["derive", "alloc"] } +serde_json.workspace = true +json-pretty-compact = "0.1.2" diff --git a/tools/dashboard_gen/src/dashboards.rs b/tools/dashboard_gen/src/dashboards.rs new file mode 100644 index 00000000..39ed51ab --- /dev/null +++ b/tools/dashboard_gen/src/dashboards.rs @@ -0,0 +1,3 @@ +//! One module per dashboard, each exposing a `dashboard()` builder. + +pub mod sequencer; diff --git a/tools/dashboard_gen/src/dashboards/sequencer.rs b/tools/dashboard_gen/src/dashboards/sequencer.rs new file mode 100644 index 00000000..0520251c --- /dev/null +++ b/tools/dashboard_gen/src/dashboards/sequencer.rs @@ -0,0 +1,195 @@ +//! The sequencer dashboard: chain progress, block timings, mempool and +//! transaction outcomes. + +#![expect( + clippy::non_ascii_literal, + reason = "legend separators use `Β·` intentionally, matching the rendered Grafana labels" +)] + +use dashboard_gen::{ + Color, Dashboard, FieldOverride, GradientMode, Panel, Target, Thresholds, Unit, avg, + percentile_legend, percentile_variable, rate_per_min, selected_percentile, +}; + +const PERCENTILES: &[u32] = &[50, 90, 95, 99]; +const DEFAULT_PERCENTILE: u32 = 95; + +pub fn dashboard() -> Dashboard { + Dashboard::new("Sequencer", "sequencer") + .tag("sequencer") + .variable(percentile_variable(PERCENTILES, DEFAULT_PERCENTILE)) + .row( + 7, + [ + Panel::stat("Chain height") + .width(6) + .unit(Unit::Short) + .decimals(0) + .color(Color::fixed("blue")) + .target( + Target::new(sequencer_core_metrics::names::CHAIN_HEIGHT).legend("height"), + ), + Panel::stat("Blocks produced by this sequencer since startup") + .width(6) + .unit(Unit::Short) + .decimals(0) + .color(Color::fixed("green")) + .target( + Target::new(sequencer_core_metrics::names::BLOCKS_PRODUCED_TOTAL) + .legend("produced"), + ), + Panel::timeseries("Block production rate") + .width(12) + .unit(Unit::Short) + .target(rate_per_min( + sequencer_core_metrics::names::BLOCKS_PRODUCED_TOTAL, + "produced Β· blocks/min", + )) + .with_override( + FieldOverride::by_name("produced Β· blocks/min").color(Color::fixed("green")), + ), + ], + ) + .row( + 9, + [Panel::timeseries("Block creation time") + .width(24) + .unit(Unit::Seconds) + .target(selected_percentile( + sequencer_core_metrics::names::BLOCK_CREATION_TIME, + &[], + &percentile_legend(), + )) + .target(avg(sequencer_core_metrics::names::BLOCK_CREATION_TIME)) + .with_override( + FieldOverride::by_name("avg") + .dashed_line() + .color(Color::fixed("text")), + )], + ) + .row( + 9, + [ + Panel::timeseries("Transaction application time") + .width(12) + .unit(Unit::Seconds) + .target(selected_percentile( + sequencer_core_metrics::names::MEMPOOL_TRANSACTION_APPLICATION_TIME, + &["kind", "origin", "status"], + "{{kind}} Β· {{origin}} Β· {{status}}", + )), + Panel::timeseries("Transactions per block") + .width(12) + .unit(Unit::Short) + .target(selected_percentile( + sequencer_core_metrics::names::TRANSACTIONS_PER_BLOCK, + &[], + &percentile_legend(), + )) + .target(avg(sequencer_core_metrics::names::TRANSACTIONS_PER_BLOCK)) + .with_override( + FieldOverride::by_name("avg") + .dashed_line() + .color(Color::fixed("text")), + ), + ], + ) + .row( + 8, + [ + Panel::gauge("Mempool utilization") + .width(6) + .unit(Unit::Percent) + .decimals(1) + .min(0.0) + .max(100.0) + .thresholds( + Thresholds::base("green") + .step(70.0, "orange") + .step(90.0, "red"), + ) + .target( + Target::new(format!( + "100 * {size} / {max_size}", + size = sequencer_core_metrics::names::MEMPOOL_SIZE, + max_size = sequencer_core_metrics::names::MEMPOOL_MAX_SIZE, + )) + .legend("utilization"), + ), + Panel::timeseries("Mempool size vs capacity") + .width(18) + .unit(Unit::Short) + .span_nulls() + .min(0.0) + .target( + Target::new(sequencer_core_metrics::names::MEMPOOL_SIZE).legend("queued"), + ) + .target( + Target::new(sequencer_core_metrics::names::MEMPOOL_MAX_SIZE) + .legend("capacity"), + ) + .with_override( + FieldOverride::by_name("capacity") + .dashed_line() + .color(Color::fixed("red")), + ) + .with_override(FieldOverride::by_name("queued").color(Color::fixed("blue"))), + ], + ) + .row( + 8, + [ + Panel::gauge("Failed transactions share") + .width(6) + .unit(Unit::Percent) + .decimals(2) + .min(0.0) + .max(100.0) + .thresholds( + Thresholds::base("green") + .step(1.0, "orange") + .step(5.0, "red"), + ) + .target( + Target::new(format!( + // Both failure stages against the same submission base; + // `clamp_min` keeps an idle window (nothing submitted) + // reading as 0% instead of a division by zero. + "100 * (increase({before_mempool}[$__range]) + increase({in_mempool}[$__range])) / clamp_min(increase({submitted}[$__range]), 1)", + before_mempool = sequencer_service_metrics::names::BEFORE_MEMPOOL_FAILED_TRANSACTIONS_TOTAL, + in_mempool = sequencer_core_metrics::names::MEMPOOL_FAILED_TRANSACTIONS_TOTAL, + submitted = sequencer_service_metrics::names::SUBMITTED_TRANSACTIONS_TOTAL, + )) + .legend("failed"), + ), + Panel::timeseries("Submitted vs failed transactions (per minute)") + .width(18) + .unit(Unit::Short) + .min(0.0) + .fill_opacity(35) + .gradient_mode(GradientMode::Opacity) + .target(rate_per_min( + sequencer_service_metrics::names::SUBMITTED_TRANSACTIONS_TOTAL, + "submitted", + )) + .target(rate_per_min( + sequencer_service_metrics::names::BEFORE_MEMPOOL_FAILED_TRANSACTIONS_TOTAL, + "failed Β· before mempool", + )) + .target(rate_per_min( + sequencer_core_metrics::names::MEMPOOL_FAILED_TRANSACTIONS_TOTAL, + "failed Β· in mempool", + )) + .with_override( + FieldOverride::by_name("submitted").color(Color::fixed("green")), + ) + .with_override( + FieldOverride::by_name("failed Β· before mempool") + .color(Color::fixed("orange")), + ) + .with_override( + FieldOverride::by_name("failed Β· in mempool").color(Color::fixed("red")), + ), + ], + ) +} diff --git a/tools/dashboard_gen/src/lib.rs b/tools/dashboard_gen/src/lib.rs new file mode 100644 index 00000000..113592c0 --- /dev/null +++ b/tools/dashboard_gen/src/lib.rs @@ -0,0 +1,603 @@ +//! A tiny, hand-rolled Grafana dashboard builder. +//! +//! This is a deliberately small subset of what the (Rust-less) Grafana +//! Foundation SDK does: model only the panel types and options we actually use, +//! expose a fluent builder, and render to the same dashboard JSON Grafana +//! provisions. The payoff over hand-written JSON: +//! +//! * metric names live in Rust `const`s, so a rename is a compile error here; +//! * repetitive structure (percentile targets, grid layout, legend/tooltip defaults) collapses into +//! a single call instead of copy-pasted JSON. +//! +//! Build a [`Dashboard`] and serialize it directly. + +// Styling vocabularies passed to the optional `styling` setters are part of the +// public API (and are used by this module's `Panel` fields and `finalize`). +pub use schema::{ + AxisPlacement, Color, GradientMode, LineInterpolation, ShowPoints, StackingMode, Thresholds, + Variable, +}; +use schema::{ + Calc, Custom, Datasource, Defaults, DrawStyle, EmptyList, FieldConfig, Fill, GaugeOptions, + GraphMode, GridPos, Legend, LegendDisplay, LineStyle, Matcher, MatcherKind, Options, + OverrideProperty, PanelModel, PanelType, Placement, PropertyId, PropertyValue, ReduceOptions, + SortOrder, Stacking, StatColorMode, StatOptions, Templating, TimeRange, TimeSeriesOptions, + Tooltip, TooltipMode, VariableKind, VariableOption, +}; +use serde::Serialize; +pub use unit::Unit; + +mod schema; +mod styling; +mod unit; + +/// Datasource uid every panel/target points at. Dashboards stay portable across +/// environments because they reference the datasource by this stable uid rather +/// than by a per-environment URL. +pub const DATASOURCE_UID: &str = "prometheus"; + +/// Window every rate query β€” counter and histogram alike β€” rates over. +/// `$__rate_interval` tracks the panel's zoom, so the window always covers the +/// step Grafana samples at: a fixed one shorter than the step leaves gaps the +/// query never looks at, dropping events from the graph entirely. +const RATE_WINDOW: &str = "$__rate_interval"; + +/// Dashboard variable holding the quantile every percentile query reads. +const PERCENTILE_VAR: &str = "percentile"; + +/// Area fill under a timeseries line, as a percentage. Enough to read a series' +/// shape at a glance without drowning the ones stacked behind it. +pub(crate) const DEFAULT_FILL_OPACITY: u32 = 10; + +/// A single Prometheus query within a panel. +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Target { + datasource: Datasource, + expr: String, + #[serde(rename = "legendFormat")] + legend: String, + ref_id: String, +} + +impl Target { + pub fn new(expr: impl Into) -> Self { + Self { + datasource: Datasource::prometheus(), + expr: expr.into(), + legend: String::new(), + ref_id: ref_letter(0), + } + } + + #[must_use] + pub fn legend(mut self, legend: impl Into) -> Self { + self.legend = legend.into(); + self + } +} + +/// A per-series style override, matched by series name. +#[derive(Serialize)] +pub struct FieldOverride { + matcher: Matcher, + properties: Vec, +} + +impl FieldOverride { + pub fn by_name(name: impl Into) -> Self { + Self { + matcher: Matcher { + id: MatcherKind::ByName, + options: name.into(), + }, + properties: Vec::new(), + } + } + + #[must_use] + pub fn color(mut self, color: Color) -> Self { + self.properties.push(OverrideProperty { + id: PropertyId::Color, + value: PropertyValue::Color(color), + }); + self + } + + #[must_use] + pub fn dashed_line(mut self) -> Self { + self.properties.push(OverrideProperty { + id: PropertyId::LineStyle, + value: PropertyValue::LineStyle(LineStyle { + dash: [8, 4], + fill: Fill::Dash, + }), + }); + self + } +} + +#[derive(Clone, Copy)] +enum Kind { + Stat, + TimeSeries, + Gauge, +} + +/// A dashboard panel builder. Grid position and panel id are assigned by +/// [`Dashboard::row`]; everything else is set here. +pub struct Panel { + title: String, + kind: Kind, + targets: Vec, + width: u32, + unit: Option, + decimals: Option, + color: Option, + min: Option, + max: Option, + thresholds: Option, + span_nulls: bool, + overrides: Vec, + // Optional timeseries styling, set via the `styling` setters. + fill_opacity: Option, + line_interpolation: Option, + show_points: Option, + gradient_mode: Option, + stacking: Option, + axis_placement: Option, + axis_label: Option, +} + +impl Panel { + fn new(title: impl Into, kind: Kind) -> Self { + Self { + title: title.into(), + kind, + targets: Vec::new(), + width: 0, + unit: None, + decimals: None, + color: None, + min: None, + max: None, + thresholds: None, + span_nulls: false, + overrides: Vec::new(), + fill_opacity: None, + line_interpolation: None, + show_points: None, + gradient_mode: None, + stacking: None, + axis_placement: None, + axis_label: None, + } + } + + /// A single big-number panel. + pub fn stat(title: impl Into) -> Self { + Self::new(title, Kind::Stat) + } + + /// A time-series line panel. + pub fn timeseries(title: impl Into) -> Self { + Self::new(title, Kind::TimeSeries) + } + + /// A radial gauge panel. Pair it with [`Panel::min`]/[`Panel::max`] β€” the + /// dial needs a range to fill β€” and [`Panel::thresholds`] for its coloring. + pub fn gauge(title: impl Into) -> Self { + Self::new(title, Kind::Gauge) + } + + /// Grid width in Grafana's 24-column units. Unset panels split the row's + /// remaining width evenly. + #[must_use] + pub const fn width(mut self, width: u32) -> Self { + self.width = width; + self + } + + #[must_use] + pub fn unit(mut self, unit: Unit) -> Self { + self.unit = Some(unit); + self + } + + #[must_use] + pub const fn decimals(mut self, decimals: u32) -> Self { + self.decimals = Some(decimals); + self + } + + #[must_use] + pub fn color(mut self, color: Color) -> Self { + self.color = Some(color); + self + } + + /// Lower bound of the value scale. + #[must_use] + pub const fn min(mut self, min: f64) -> Self { + self.min = Some(min); + self + } + + /// Upper bound of the value scale. + #[must_use] + pub const fn max(mut self, max: f64) -> Self { + self.max = Some(max); + self + } + + #[must_use] + pub fn thresholds(mut self, thresholds: Thresholds) -> Self { + self.thresholds = Some(thresholds); + self + } + + #[must_use] + pub const fn span_nulls(mut self) -> Self { + self.span_nulls = true; + self + } + + #[must_use] + pub fn target(mut self, target: Target) -> Self { + self.targets.push(target); + self + } + + #[must_use] + pub fn targets(mut self, targets: impl IntoIterator) -> Self { + self.targets.extend(targets); + self + } + + #[must_use] + pub fn with_override(mut self, over: FieldOverride) -> Self { + self.overrides.push(over); + self + } + + fn finalize(self, id: u32, grid_pos: GridPos) -> PanelModel { + let targets: Vec = self + .targets + .into_iter() + .enumerate() + .map(|(i, mut t)| { + t.ref_id = ref_letter(i); + t + }) + .collect(); + + let unit = self.unit.unwrap_or_else(default_unit); + let (defaults, options, panel_type) = match self.kind { + Kind::Stat => { + let defaults = Defaults { + color: self.color, + custom: None, + unit, + decimals: self.decimals, + min: self.min, + max: self.max, + thresholds: self.thresholds, + }; + let options = Options::Stat(StatOptions { + color_mode: StatColorMode::Value, + graph_mode: GraphMode::Area, + reduce_options: ReduceOptions { + calcs: vec![Calc::LastNotNull], + fields: String::new(), + values: false, + }, + }); + (defaults, options, PanelType::Stat) + } + Kind::Gauge => { + let defaults = Defaults { + color: self.color, + custom: None, + unit, + decimals: self.decimals, + min: self.min, + max: self.max, + thresholds: self.thresholds, + }; + let options = Options::Gauge(GaugeOptions { + reduce_options: ReduceOptions { + calcs: vec![Calc::LastNotNull], + fields: String::new(), + values: false, + }, + show_threshold_labels: false, + show_threshold_markers: true, + }); + (defaults, options, PanelType::Gauge) + } + Kind::TimeSeries => { + let defaults = Defaults { + color: None, + custom: Some(Custom { + draw_style: DrawStyle::Line, + line_width: 1, + fill_opacity: self.fill_opacity.unwrap_or(DEFAULT_FILL_OPACITY), + span_nulls: self.span_nulls.then_some(true), + line_interpolation: self.line_interpolation, + show_points: self.show_points, + gradient_mode: self.gradient_mode, + stacking: self.stacking.map(|mode| Stacking { + mode, + group: "A".to_owned(), + }), + axis_placement: self.axis_placement, + axis_label: self.axis_label, + }), + unit, + decimals: None, + min: self.min, + max: self.max, + thresholds: self.thresholds, + }; + // Panels with several series read better as a sortable table with + // a multi-series tooltip; single-series panels stay compact. A + // `{{label}}` legend fans one target out into a series per label + // value, so it counts as several too. + let multi = + targets.len() > 1 || targets.iter().any(|target| target.legend.contains("{{")); + let options = Options::TimeSeries(TimeSeriesOptions { + legend: Legend { + display_mode: if multi { + LegendDisplay::Table + } else { + LegendDisplay::List + }, + placement: Placement::Bottom, + calcs: vec![Calc::Last, Calc::Max], + }, + tooltip: if multi { + Tooltip { + mode: TooltipMode::Multi, + sort: Some(SortOrder::Desc), + } + } else { + Tooltip { + mode: TooltipMode::Single, + sort: None, + } + }, + }); + (defaults, options, PanelType::Timeseries) + } + }; + + PanelModel { + datasource: Datasource::prometheus(), + field_config: FieldConfig { + defaults, + overrides: self.overrides, + }, + grid_pos, + id, + options, + targets, + title: self.title, + panel_type, + } + } +} + +/// A dashboard, built row by row. Serialize it directly to get the JSON. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Dashboard { + annotations: EmptyList, + editable: bool, + graph_tooltip: u32, + panels: Vec, + refresh: String, + schema_version: u32, + tags: Vec, + templating: Templating, + time: TimeRange, + timezone: String, + title: String, + uid: String, + + // Layout cursor β€” not part of the dashboard schema. + #[serde(skip)] + next_id: u32, + #[serde(skip)] + cursor_y: u32, +} + +impl Dashboard { + pub fn new(title: impl Into, uid: impl Into) -> Self { + Self { + annotations: EmptyList::default(), + editable: true, + graph_tooltip: 1, + panels: Vec::new(), + refresh: "5s".to_owned(), + schema_version: 39, + tags: Vec::new(), + templating: Templating::default(), + time: TimeRange { + from: "now-15m".to_owned(), + to: "now".to_owned(), + }, + timezone: String::new(), + title: title.into(), + uid: uid.into(), + next_id: 1, + cursor_y: 0, + } + } + + #[must_use] + pub fn tag(mut self, tag: impl Into) -> Self { + self.tags.push(tag.into()); + self + } + + #[must_use] + pub fn refresh(mut self, refresh: impl Into) -> Self { + self.refresh = refresh.into(); + self + } + + /// Add a dropdown to the dashboard's top bar, e.g. [`percentile_variable`]. + #[must_use] + pub fn variable(mut self, variable: Variable) -> Self { + self.templating.list.push(variable); + self + } + + /// Place a horizontal row of panels at the current vertical cursor. Panel + /// ids, x offsets and y are assigned here; unset widths split the remaining + /// 24 columns evenly. + #[must_use] + pub fn row(mut self, height: u32, panels: impl IntoIterator) -> Self { + let panels: Vec = panels.into_iter().collect(); + let specified: u32 = panels.iter().map(|p| p.width).sum(); + let auto_count = u32::try_from(panels.iter().filter(|p| p.width == 0).count()).unwrap_or(0); + // `checked_div` yields `None` when there are no auto-width panels; the + // fallback width is unused in that case. + let auto_width = 24_u32 + .saturating_sub(specified) + .checked_div(auto_count) + .unwrap_or(0); + + let mut x = 0; + for panel in panels { + let w = if panel.width == 0 { + auto_width + } else { + panel.width + }; + let grid_pos = GridPos { + h: height, + w, + x, + y: self.cursor_y, + }; + let id = self.next_id; + self.next_id = self.next_id.saturating_add(1); + x = x.saturating_add(w); + self.panels.push(panel.finalize(id, grid_pos)); + } + self.cursor_y = self.cursor_y.saturating_add(height); + self + } +} + +/// The dropdown driving every [`selected_percentile`] query, offering +/// `percentiles` (e.g. `[50, 90, 95, 99]`) with `default` pre-selected. +/// +/// Panics if `default` is not one of `percentiles`, or if any of them falls +/// outside `p1..=p99`. +#[must_use] +pub fn percentile_variable(percentiles: &[u32], default: u32) -> Variable { + assert!( + percentiles.contains(&default), + "default p{default} is not one of the offered percentiles {percentiles:?}", + ); + + let options: Vec = percentiles + .iter() + .map(|&p| VariableOption { + selected: p == default, + text: format!("p{p}"), + value: quantile(p), + }) + .collect(); + let current = options + .iter() + .find(|option| option.selected) + .cloned() + .expect("`default` is one of `percentiles`, asserted above"); + let query = options + .iter() + .map(|option| format!("{} : {}", option.text, option.value)) + .collect::>() + .join(", "); + + Variable { + current, + include_all: false, + label: "Percentile".to_owned(), + // A quantile is a scalar argument to `histogram_quantile`, so exactly + // one may be selected. + multi: false, + name: PERCENTILE_VAR.to_owned(), + options, + query, + kind: VariableKind::Custom, + } +} + +/// The legend fragment that renders the dropdown's current choice, e.g. `p95`. +#[must_use] +pub fn percentile_legend() -> String { + format!("${{{PERCENTILE_VAR}:text}}") +} + +/// A [`histogram_quantile`] line over `metric`'s buckets, at whatever quantile +/// [`percentile_variable`] currently holds. `labels` stay split out into their +/// own series; every other label is summed away. +/// +/// [`histogram_quantile`]: https://prometheus.io/docs/prometheus/latest/querying/functions/#histogram_quantile +#[must_use] +pub fn selected_percentile(metric: &str, labels: &[&str], legend: &str) -> Target { + // `le` carries the bucket boundary, so it must survive the aggregation. + let grouping = std::iter::once("le") + .chain(labels.iter().copied()) + .collect::>() + .join(", "); + + Target::new(format!( + "histogram_quantile(${{{PERCENTILE_VAR}}}, sum by ({grouping}) (rate({metric}_bucket[{RATE_WINDOW}])))" + )) + .legend(legend) +} + +/// A percentile as its `histogram_quantile` argument, derived without float +/// math: zero-pad to two digits then drop trailing zeros (50 β†’ `0.5`). +/// +/// Panics outside `1..=99`, the only range two digits render: p100 would come +/// out as `0.1` and p0 as `0.`, neither of them loudly. +fn quantile(percentile: u32) -> String { + assert!( + (1..=99).contains(&percentile), + "p{percentile} is outside the supported range p1..=p99", + ); + + let quantile = format!("0.{percentile:02}"); + quantile.trim_end_matches('0').to_owned() +} + +/// An `avg` target for a histogram metric: `rate(sum) / rate(count)`. +#[must_use] +pub fn avg(metric: &str) -> Target { + Target::new(format!( + "rate({metric}_sum[{RATE_WINDOW}]) / rate({metric}_count[{RATE_WINDOW}])" + )) + .legend("avg") +} + +/// A per-minute rate target for a counter metric. +#[must_use] +pub fn rate_per_min(metric: &str, legend: &str) -> Target { + //`rate` is per-second whatever the window, so `* 60` is per-minute regardless of the panel's + //`rate` zoom. + Target::new(format!("rate({metric}[{RATE_WINDOW}]) * 60")).legend(legend) +} + +const fn default_unit() -> Unit { + Unit::Short +} + +fn ref_letter(index: usize) -> String { + let offset = u8::try_from(index).unwrap_or(0); + char::from(b'A'.saturating_add(offset)).to_string() +} diff --git a/tools/dashboard_gen/src/main.rs b/tools/dashboard_gen/src/main.rs new file mode 100644 index 00000000..b428817d --- /dev/null +++ b/tools/dashboard_gen/src/main.rs @@ -0,0 +1,45 @@ +//! Builds one of the Grafana dashboards and prints its JSON to stdout. + +#![expect( + clippy::print_stdout, + reason = "CLI tool: emitting the dashboard JSON on stdout is the deliverable" +)] + +use clap::{Parser, ValueEnum}; +use dashboard_gen::Dashboard; +use json_pretty_compact::PrettyCompactFormatter; +use serde::Serialize as _; + +mod dashboards; + +#[derive(Debug, Parser)] +#[clap(version)] +struct Args { + /// Which dashboard to build. + dashboard: DashboardKind, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum DashboardKind { + Sequencer, +} + +impl DashboardKind { + fn build(self) -> Dashboard { + match self { + Self::Sequencer => dashboards::sequencer::dashboard(), + } + } +} + +fn main() { + let Args { dashboard } = Args::parse(); + + let formatter = PrettyCompactFormatter::new(); + let mut output = Vec::new(); + let mut ser = serde_json::Serializer::with_formatter(&mut output, formatter); + dashboard.build().serialize(&mut ser).unwrap(); + + let json = String::from_utf8(output).unwrap(); + println!("{json}"); +} diff --git a/tools/dashboard_gen/src/schema.rs b/tools/dashboard_gen/src/schema.rs new file mode 100644 index 00000000..d04b6bad --- /dev/null +++ b/tools/dashboard_gen/src/schema.rs @@ -0,0 +1,434 @@ +//! The serializable Grafana dashboard schema β€” the internal data model the +//! public builders assemble into. +//! +//! Every type here is `Serialize`-only: the model is sized for what we emit. + +use serde::Serialize; + +use crate::{DATASOURCE_UID, FieldOverride, Target, unit::Unit}; + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DatasourceKind { + Prometheus, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Fill { + Dash, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DrawStyle { + Line, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum MatcherKind { + ByName, +} + +#[derive(Clone, Copy, Serialize)] +pub enum PropertyId { + #[serde(rename = "color")] + Color, + #[serde(rename = "custom.lineStyle")] + LineStyle, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Calc { + LastNotNull, + Last, + Max, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum StatColorMode { + Value, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum GraphMode { + Area, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum LegendDisplay { + Table, + List, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Placement { + Bottom, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum TooltipMode { + Single, + Multi, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum SortOrder { + Desc, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum PanelType { + Stat, + Timeseries, + Gauge, +} + +#[derive(Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ThresholdMode { + Absolute, + /// Steps expressed as a percentage of the min–max range. The builder never + /// emits this; it exists so Grafana exports using it still parse. + Percentage, +} + +/// One threshold step: the color values at or above `value` take. The base step +/// carries `value: null` β€” Grafana's "everything below the first threshold". +#[derive(Clone, Serialize)] +pub struct ThresholdStep { + pub color: String, + pub value: Option, +} + +/// A threshold ladder, driving gauge/stat coloring. +#[derive(Clone, Serialize)] +pub struct Thresholds { + pub mode: ThresholdMode, + pub steps: Vec, +} + +impl Thresholds { + /// Start a ladder with the color used below every threshold. + pub fn base(color: impl Into) -> Self { + Self { + mode: ThresholdMode::Absolute, + steps: vec![ThresholdStep { + color: color.into(), + value: None, + }], + } + } + + /// Add a step: values at or above `value` render in `color`. + #[must_use] + pub fn step(mut self, value: f64, color: impl Into) -> Self { + self.steps.push(ThresholdStep { + color: color.into(), + value: Some(value), + }); + self + } +} + +// Optional timeseries styling vocabularies. Each derives `PartialEq` so the +// public setters can panic when handed the Grafana default (see `styling`). + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LineInterpolation { + Linear, + Smooth, + StepBefore, + StepAfter, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ShowPoints { + Auto, + Never, + Always, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum GradientMode { + None, + Opacity, + Hue, + Scheme, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum StackingMode { + None, + Normal, + Percent, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum AxisPlacement { + Auto, + Left, + Right, + Hidden, +} + +#[derive(Serialize)] +pub struct Stacking { + pub mode: StackingMode, + pub group: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Custom { + pub draw_style: DrawStyle, + pub line_width: u32, + pub fill_opacity: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub span_nulls: Option, + // Optional styling β€” omitted (left at Grafana's default) unless a setter + // fills it in. See `styling`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub line_interpolation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub show_points: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gradient_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stacking: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub axis_placement: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub axis_label: Option, +} + +#[derive(Clone, Serialize)] +pub struct Datasource { + #[serde(rename = "type")] + pub kind: DatasourceKind, + pub uid: String, +} + +impl Datasource { + pub fn prometheus() -> Self { + Self { + kind: DatasourceKind::Prometheus, + uid: DATASOURCE_UID.to_owned(), + } + } +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "kebab-case")] +#[serde(tag = "mode")] +pub enum Color { + Fixed { + #[serde(rename = "fixedColor")] + fixed_color: String, + }, + PaletteClassic, +} + +impl Color { + pub fn fixed(color: impl Into) -> Self { + Self::Fixed { + fixed_color: color.into(), + } + } + + #[must_use] + pub const fn palette_classic() -> Self { + Self::PaletteClassic + } +} + +#[derive(Serialize)] +pub struct LineStyle { + pub dash: [u32; 2], + pub fill: Fill, +} + +#[derive(Serialize)] +#[serde(untagged)] +pub enum PropertyValue { + Color(Color), + LineStyle(LineStyle), +} + +#[derive(Serialize)] +pub struct OverrideProperty { + pub id: PropertyId, + pub value: PropertyValue, +} + +#[derive(Serialize)] +pub struct Matcher { + pub id: MatcherKind, + pub options: String, +} + +#[derive(Serialize)] +pub struct Defaults { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom: Option, + pub unit: Unit, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub decimals: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thresholds: Option, +} + +#[derive(Serialize)] +pub struct FieldConfig { + pub defaults: Defaults, + pub overrides: Vec, +} + +#[derive(Serialize)] +pub struct ReduceOptions { + pub calcs: Vec, + // Empty string means "all fields"; genuinely free-form, not a vocabulary. + pub fields: String, + pub values: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StatOptions { + pub color_mode: StatColorMode, + pub graph_mode: GraphMode, + pub reduce_options: ReduceOptions, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Legend { + pub display_mode: LegendDisplay, + pub placement: Placement, + pub calcs: Vec, +} + +#[derive(Serialize)] +pub struct Tooltip { + pub mode: TooltipMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sort: Option, +} + +#[derive(Serialize)] +pub struct TimeSeriesOptions { + pub legend: Legend, + pub tooltip: Tooltip, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GaugeOptions { + pub reduce_options: ReduceOptions, + pub show_threshold_labels: bool, + pub show_threshold_markers: bool, +} + +#[derive(Serialize)] +#[serde(untagged)] +pub enum Options { + Stat(StatOptions), + TimeSeries(TimeSeriesOptions), + Gauge(GaugeOptions), +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum VariableKind { + /// A fixed list of choices, spelled out in the dashboard itself. + Custom, +} + +/// One choice in a [`Variable`] dropdown: `text` is displayed, `value` is what +/// `$name` interpolates to in a query. +#[derive(Clone, Serialize)] +pub struct VariableOption { + pub selected: bool, + pub text: String, + pub value: String, +} + +/// A dashboard-level dropdown, rendered in the top bar. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Variable { + pub current: VariableOption, + pub include_all: bool, + pub label: String, + pub multi: bool, + pub name: String, + pub options: Vec, + /// Grafana's own encoding of `options`, as `text : value` pairs. + pub query: String, + #[serde(rename = "type")] + pub kind: VariableKind, +} + +#[derive(Serialize, Default)] +pub struct Templating { + pub list: Vec, +} + +#[derive(Clone, Copy, Serialize)] +pub struct GridPos { + pub h: u32, + pub w: u32, + pub x: u32, + pub y: u32, +} + +/// A fully positioned panel, ready to serialize. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PanelModel { + pub datasource: Datasource, + pub field_config: FieldConfig, + pub grid_pos: GridPos, + pub id: u32, + pub options: Options, + pub targets: Vec, + pub title: String, + #[serde(rename = "type")] + pub panel_type: PanelType, +} + +#[expect( + clippy::trailing_empty_array, + reason = "Grafana expects `list: []` for the blocks we don't populate" +)] +#[derive(Serialize, Default)] +pub struct EmptyList { + pub list: [u8; 0], +} + +#[derive(Serialize)] +pub struct TimeRange { + // Free-form Grafana time expressions, not a closed vocabulary. + pub from: String, + pub to: String, +} diff --git a/tools/dashboard_gen/src/styling.rs b/tools/dashboard_gen/src/styling.rs new file mode 100644 index 00000000..36b0cc38 --- /dev/null +++ b/tools/dashboard_gen/src/styling.rs @@ -0,0 +1,124 @@ +//! Optional timeseries styling setters. +//! +//! These are real setters β€” each fills in a field of the panel's `custom` block. +//! Every one documents Grafana's default and **panics if handed that default**: +//! passing the default is always redundant (Grafana emits it anyway), and the +//! generator only serializes fields that differ from the default. So if a call +//! wouldn't change the rendered panel, it's a mistake worth catching loudly at +//! generation time rather than shipping a no-op. +//! +//! These affect timeseries panels only; on a stat panel the `custom` block is +//! not emitted, so the value is silently dropped. + +use crate::{ + DEFAULT_FILL_OPACITY, Panel, + schema::{AxisPlacement, GradientMode, LineInterpolation, ShowPoints, StackingMode}, +}; + +#[expect( + clippy::multiple_inherent_impl, + reason = "styling setters intentionally live in their own file, so `Panel` has a second inherent impl here" +)] +impl Panel { + /// Area fill under the line, as a percentage. Builder default: + /// [`DEFAULT_FILL_OPACITY`]. + /// + /// Panics if passed that default, or a value above 100. + #[must_use] + pub fn fill_opacity(mut self, opacity: u32) -> Self { + assert!( + opacity <= 100, + "fill_opacity({opacity}) is not a percentage" + ); + assert_ne!( + opacity, DEFAULT_FILL_OPACITY, + "fill_opacity({DEFAULT_FILL_OPACITY}) is redundant: it is the builder's default. Omit the call.", + ); + self.fill_opacity = Some(opacity); + self + } + + /// Interpolation between points. Grafana default: `Linear`. + /// + /// Panics if passed `Linear` β€” that's the default and would be redundant. + #[must_use] + pub fn line_interpolation(mut self, value: LineInterpolation) -> Self { + assert_ne!( + value, + LineInterpolation::Linear, + "line_interpolation(Linear) is redundant: `linear` is Grafana's default. Omit the call.", + ); + self.line_interpolation = Some(value); + self + } + + /// Whether/when to draw point markers. Grafana default: `Auto`. + /// + /// Panics if passed `Auto` β€” that's the default and would be redundant. + #[must_use] + pub fn show_points(mut self, value: ShowPoints) -> Self { + assert_ne!( + value, + ShowPoints::Auto, + "show_points(Auto) is redundant: `auto` is Grafana's default. Omit the call.", + ); + self.show_points = Some(value); + self + } + + /// Area fill gradient. Grafana default: `None`. + /// + /// Panics if passed `None` β€” that's the default and would be redundant. + #[must_use] + pub fn gradient_mode(mut self, value: GradientMode) -> Self { + assert_ne!( + value, + GradientMode::None, + "gradient_mode(None) is redundant: `none` is Grafana's default. Omit the call.", + ); + self.gradient_mode = Some(value); + self + } + + /// Series stacking. Grafana default: `None`. + /// + /// Panics if passed `None` β€” that's the default and would be redundant. + #[must_use] + pub fn stacking(mut self, mode: StackingMode) -> Self { + assert_ne!( + mode, + StackingMode::None, + "stacking(None) is redundant: `none` is Grafana's default. Omit the call.", + ); + self.stacking = Some(mode); + self + } + + /// Y-axis placement. Grafana default: `Auto`. + /// + /// Panics if passed `Auto` β€” that's the default and would be redundant. + #[must_use] + pub fn axis_placement(mut self, value: AxisPlacement) -> Self { + assert_ne!( + value, + AxisPlacement::Auto, + "axis_placement(Auto) is redundant: `auto` is Grafana's default. Omit the call.", + ); + self.axis_placement = Some(value); + self + } + + /// Y-axis label. Grafana default: `""` (no label). + /// + /// Panics if passed an empty string β€” that's the default and would be redundant. + #[must_use] + pub fn axis_label(mut self, label: impl Into) -> Self { + let label = label.into(); + assert_ne!( + label, "", + "axis_label(\"\") is redundant: no label is Grafana's default. Omit the call.", + ); + self.axis_label = Some(label); + self + } +} diff --git a/tools/dashboard_gen/src/unit.rs b/tools/dashboard_gen/src/unit.rs new file mode 100644 index 00000000..a91f302b --- /dev/null +++ b/tools/dashboard_gen/src/unit.rs @@ -0,0 +1,69 @@ +//! Panel value units. +//! +//! Grafana identifies a field's unit by a short id string (`"s"`, `"bytes"`, +//! `"reqps"`, …). We model the handful we actually use as named variants and +//! fall back to [`Unit::custom`] for anything else, so the value always +//! serializes to the exact id Grafana expects. + +use serde::{Serialize, Serializer}; + +/// A panel value unit. Serializes to Grafana's unit id string. +/// +/// Only the most common units are named; [`Unit::custom`] carries any other +/// Grafana unit id verbatim. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Unit { + /// Plain number, SI-abbreviated (`short`). Grafana's default. + Short, + /// Percentage on a 0–100 scale (`percent`). + Percent, + /// Percentage on a 0.0–1.0 scale (`percentunit`). + PercentUnit, + /// Seconds (`s`). + Seconds, + /// Milliseconds (`ms`). + Milliseconds, + /// Nanoseconds (`ns`). + Nanoseconds, + /// Bytes, IEC/binary (`bytes`). + Bytes, + /// Bytes per second, SI (`Bps`). + BytesPerSec, + /// Requests per second (`reqps`). + RequestsPerSec, + /// Operations per second (`ops`). + OpsPerSec, + /// Any other Grafana unit id, kept verbatim. + Custom(String), +} + +impl Unit { + /// Wrap an arbitrary Grafana unit id (e.g. `"dtdurationms"`, `"celsius"`). + #[must_use] + pub fn custom(id: impl Into) -> Self { + Self::Custom(id.into()) + } + + /// The Grafana unit id this value serializes to. + fn as_id(&self) -> &str { + match self { + Self::Short => "short", + Self::Percent => "percent", + Self::PercentUnit => "percentunit", + Self::Seconds => "s", + Self::Milliseconds => "ms", + Self::Nanoseconds => "ns", + Self::Bytes => "bytes", + Self::BytesPerSec => "Bps", + Self::RequestsPerSec => "reqps", + Self::OpsPerSec => "ops", + Self::Custom(id) => id, + } + } +} + +impl Serialize for Unit { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_id()) + } +}