Merge pull request #682 from logos-blockchain/dev

Merge dev to main for v0.2.2-rc1 release
This commit is contained in:
Daniil Polyakov 2026-08-04 17:47:22 +03:00 committed by GitHub
commit d6e4ae694e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
105 changed files with 3918 additions and 724 deletions

View File

@ -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"

1
.gitignore vendored
View File

@ -11,6 +11,7 @@ data/
rocksdb*
sequencer/service/data/
storage.json
statistics.json
result

View File

@ -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

293
Cargo.lock generated
View File

@ -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"

View File

@ -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" }

View File

@ -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 ..

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -11,3 +11,5 @@ include:
lez/indexer/service/docker-compose.yml
- path:
lez/explorer_service/docker-compose.yml
- path:
monitoring/docker-compose.yml

113
docs/metrics/metrics.md Normal file
View File

@ -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/<name>.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/<name>.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 |

View File

@ -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(())

View File

@ -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(

View File

@ -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,
}],
};

View File

@ -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,
}],
};

View File

@ -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

View File

@ -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,
}],
};

View File

@ -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,
}],
};

View File

@ -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);
}

View File

@ -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],

View File

@ -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);

View File

@ -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(())
}

View File

@ -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)
}

View File

@ -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
);
}
}
}

View File

@ -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<AccountWithMetadata>,
pub public_post_states: Vec<Account>,
pub encrypted_private_post_states: Vec<EncryptedAccountData>,
pub new_commitments: Vec<Commitment>,
pub new_nullifiers: Vec<(Nullifier, CommitmentSetDigest)>,
pub public_actions: Vec<PublicAction>,
pub private_actions: Vec<PrivateAction>,
pub block_validity_window: BlockValidityWindow,
pub timestamp_validity_window: TimestampValidityWindow,
}
#[cfg(any(feature = "host", test))]
impl PrivacyPreservingCircuitOutput {
#[must_use]
pub fn commitments(&self) -> Vec<Commitment> {
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(),
};

View File

@ -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]);

View File

@ -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<u8>);
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<u8>);
#[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,

View File

@ -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,

View File

@ -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]);

View File

@ -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

View File

@ -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<AccountId>,
pub public_actions: Vec<PublicActionWithID>,
pub nonces: Vec<Nonce>,
pub public_post_states: Vec<Account>,
pub encrypted_private_post_states: Vec<EncryptedAccountData>,
pub new_commitments: Vec<Commitment>,
pub new_nullifiers: Vec<(Nullifier, CommitmentSetDigest)>,
pub private_actions: Vec<PrivateAction>,
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<AccountId>,
nonces: Vec<Nonce>,
output: PrivacyPreservingCircuitOutput,
) -> Result<Self, LeeError> {
Ok(Self {
public_account_ids,
#[must_use]
pub fn from_circuit_output(nonces: Vec<Nonce>, 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<Commitment> {
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<AccountId> {
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<usize, LeeError> {
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<u8> = [
&[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
]

View File

@ -53,7 +53,12 @@ impl PrivacyPreservingTransaction {
.signer_account_ids()
.into_iter()
.collect::<HashSet<_>>();
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()
}

View File

@ -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(

View File

@ -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);

View File

@ -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, &[]);

View File

@ -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!(

View File

@ -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)

View File

@ -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<Self, LeeError> {
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::<Vec<_>>()
) == message.new_nullifiers.len(),
n_unique(&nullifiers.iter().map(|(n, _)| n).collect::<Vec<_>>()) == 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,
};

View File

@ -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(),
};

View File

@ -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<AccountId> {
match self {

View File

@ -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,
}
}

View File

@ -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
<div class="info-row">
<span class="info-label">"Public Accounts:"</span>
<span class="info-value">
{public_account_ids.len().to_string()}
{public_account_count.to_string()}
</span>
</div>
<div class="info-row">
<span class="info-label">"New Commitments:"</span>
<span class="info-value">{new_commitments.len().to_string()}</span>
</div>
<div class="info-row">
<span class="info-label">"Nullifiers:"</span>
<span class="info-value">{new_nullifiers.len().to_string()}</span>
</div>
<div class="info-row">
<span class="info-label">"Encrypted States:"</span>
<span class="info-value">
{encrypted_private_post_states.len().to_string()}
</span>
<span class="info-label">"Private Actions:"</span>
<span class="info-value">{private_action_count.to_string()}</span>
</div>
<div class="info-row">
<span class="info-label">"Proof Size:"</span>

View File

@ -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) => {

View File

@ -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;

View File

@ -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<Box<FfiPrivateTransactionBody>> 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<Box<FfiPrivateTransactionBody>> 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<Box<FfiPrivateTransactionBody>> for PrivacyPreservingTransaction {
}
}
#[repr(C)]
pub struct FfiPublicAction {
pub account_id: FfiAccountId,
pub post_state: FfiAccount,
}
impl From<PublicActionWithID> 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<PrivateAction> 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<PrivacyPreservingMessage> 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::<Vec<_>>()
@ -265,25 +290,7 @@ impl From<PrivacyPreservingMessage> for FfiPrivacyPreservingMessage {
.map(Into::into)
.collect::<Vec<_>>()
.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::<Vec<_>>()
.into(),
encrypted_private_post_states: encrypted_private_post_states
.into_iter()
.map(Into::into)
.collect::<Vec<_>>()
.into(),
new_commitments: new_commitments
.into_iter()
.map(|comm| FfiBytes32 { data: comm.0 })
.collect::<Vec<_>>()
.into(),
new_nullifiers: new_nullifiers
private_actions: private_actions
.into_iter()
.map(Into::into)
.collect::<Vec<_>>()
@ -294,21 +301,6 @@ impl From<PrivacyPreservingMessage> 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,

View File

@ -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<u8>;
pub type FfiAccountList = FfiVec<FfiAccount>;
pub type FfiAccountIdList = FfiVec<FfiAccountId>;
pub type FfiVecBytes32 = FfiVec<FfiBytes32>;
pub type FfiBlockBody = FfiVec<FfiTransaction>;
pub type FfiNonceList = FfiVec<FfiNonce>;
@ -26,6 +19,6 @@ pub type FfiProof = FfiVecU8;
pub type FfiProgramDeploymentMessage = FfiVecU8;
pub type FfiEncryptedAccountDataList = FfiVec<FfiEncryptedAccountData>;
pub type FfiPublicActionList = FfiVec<FfiPublicAction>;
pub type FfiNullifierCommitmentSetList = FfiVec<FfiNullifierCommitmentSet>;
pub type FfiPrivateActionList = FfiVec<FfiPrivateAction>;

View File

@ -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<PublicMessage> for lee::public_transaction::Message {
}
}
impl From<lee::privacy_preserving_transaction::message::PublicActionWithID> 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<lee_core::PrivateAction> 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<lee::privacy_preserving_transaction::message::Message> 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<PublicActionWithID>
for lee::privacy_preserving_transaction::message::PublicActionWithID
{
type Error = lee::error::LeeError;
fn try_from(value: PublicActionWithID) -> Result<Self, Self::Error> {
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<PrivateAction> 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<PrivacyPreservingMessage> for lee::privacy_preserving_transaction::message::Message {
type Error = lee::error::LeeError;
fn try_from(value: PrivacyPreservingMessage) -> Result<Self, Self::Error> {
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::<Result<Vec<_>, _>>()?;
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::<Result<Vec<_>, _>>()
.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}")))?,

View File

@ -226,14 +226,28 @@ pub struct PublicMessage {
pub type InstructionData = Vec<u32>;
#[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<AccountId>,
pub public_actions: Vec<PublicActionWithID>,
pub nonces: Vec<Nonce>,
pub public_post_states: Vec<Account>,
pub encrypted_private_post_states: Vec<EncryptedAccountData>,
pub new_commitments: Vec<Commitment>,
pub new_nullifiers: Vec<(Nullifier, CommitmentSetDigest)>,
pub private_actions: Vec<PrivateAction>,
pub block_validity_window: ValidityWindow,
pub timestamp_validity_window: ValidityWindow,
}

View File

@ -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)),
},

View File

@ -12,3 +12,4 @@ tokio = { workspace = true, features = ["sync"] }
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
futures.workspace = true

View File

@ -18,6 +18,19 @@ impl<T> MemPool<T> {
(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<T> {
use tokio::sync::mpsc::error::TryRecvError;
@ -74,6 +87,7 @@ impl<T> MemPoolHandle<T> {
#[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<u64>, _) = 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<u64>, _) = 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]

View File

@ -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<ProgramId>,
/// The deliveries this peer may make: which of its programs may emit, and
/// what each of them may reach here.
pub allowed_routes: Vec<CrossZoneRoute>,
/// 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<Vec<u8>>,
}
/// 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<ZoneId, ExpectedPubkey>,
pub allowed_targets: BTreeMap<ZoneId, Vec<ProgramId>>,
/// Which deliveries each peer may make. A peer absent from this map may
/// deliver nothing.
pub allowed_routes: BTreeMap<ZoneId, Vec<CrossZoneRoute>>,
}
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<u8> {
@ -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));

View File

@ -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);

View File

@ -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

View File

@ -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 }

View File

@ -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;

View File

@ -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";

View File

@ -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<common::transaction::TxKind> 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);
}

View File

@ -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()
};

View File

@ -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<CrossZoneConfig>,
/// Address the Prometheus metrics exporter binds to.
#[serde(default = "default_metrics_address")]
pub metrics_address: Option<SocketAddr>,
}
#[derive(Clone, Serialize, Deserialize)]
@ -74,9 +78,15 @@ pub struct BedrockConfig {
/// Bedrock auth.
pub auth: Option<BasicAuth>,
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<Self> {
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<SocketAddr> {
Some(SequencerConfig::DEFAULT_METRICS_ADDRESS)
}
#[must_use]
pub const fn default_priority_fee() -> u64 {
logos_blockchain_zone_sdk::sequencer::FundingConfig::DEFAULT_PRIORITY_FEE
}

View File

@ -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<ProgramId>,
allowed_routes: Vec<CrossZoneRoute>,
expected_pubkey: Option<PublicKey>,
}
@ -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();

View File

@ -80,6 +80,15 @@ pub enum TransactionOrigin {
Sequencer,
}
impl From<TransactionOrigin> 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<BP: BlockPublisherTrait> SequencerCore<BP> {
)
.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<BP: BlockPublisherTrait> SequencerCore<BP> {
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<BP: BlockPublisherTrait> SequencerCore<BP> {
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<BP: BlockPublisherTrait> SequencerCore<BP> {
watchers,
};
sequencer_core_metrics::record_chain_height(sequencer_core.chain_height());
(sequencer_core, mempool_handle)
}
@ -596,6 +613,9 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
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<BP: BlockPublisherTrait> SequencerCore<BP> {
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<BP: BlockPublisherTrait> SequencerCore<BP> {
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<BP: BlockPublisherTrait> SequencerCore<BP> {
.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<BP: BlockPublisherTrait> SequencerCore<BP> {
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",

View File

@ -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,
}],
}),

View File

@ -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

View File

@ -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 \

View File

@ -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

View File

@ -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 }

View File

@ -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;

View File

@ -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";

View File

@ -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);
}

View File

@ -211,6 +211,8 @@ async fn wait_for_store_release(store: &StoreRelease) {
}
pub async fn run(config: SequencerConfig, listen_addr: SocketAddr) -> Result<SequencerHandle> {
sequencer_service_metrics::init();
let block_timeout = config.block_create_timeout;
let max_block_size = config.max_block_size;

View File

@ -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<PathBuf>,
/// 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<SocketAddr>,
}
#[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 1100 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

View File

@ -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<BC: BlockPublisherTrait + Send + Sync + 'static> sequencer_service_rpc::Rpc
for SequencerService<BC>
{
async fn send_transaction(&self, tx: LeeTransaction) -> Result<HashType, ErrorObjectOwned> {
// 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))

View File

@ -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,
)
}

View File

@ -390,9 +390,9 @@ impl UserKeyChain {
index: &mut NullifierIndex,
) -> HashSet<usize> {
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<Nullifier> {
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<usize> {
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()
};

View File

@ -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:

View File

@ -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"
}

View File

@ -0,0 +1,9 @@
apiVersion: 1
providers:
- name: sequencer
type: file
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false

View File

@ -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

View File

@ -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

View File

@ -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),
})
}

View File

@ -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,
}],
}

View File

@ -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"

View File

@ -0,0 +1,3 @@
//! One module per dashboard, each exposing a `dashboard()` builder.
pub mod sequencer;

View File

@ -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")),
),
],
)
}

Some files were not shown because too many files have changed in this diff Show More