Merge branch 'main' into marvin/keycard-commands

This commit is contained in:
Marvin Jones 2026-05-21 11:31:42 -04:00
commit 9331a0a8d7
89 changed files with 4452 additions and 702 deletions

View File

@ -16,6 +16,7 @@ ignore = [
{ id = "RUSTSEC-2026-0097", reason = "`rand` v0.8.5 is present transitively from logos crates, modification may break integration" },
{ id = "RUSTSEC-2026-0118", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration" },
{ id = "RUSTSEC-2026-0119", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration" },
{ id = "RUSTSEC-2026-0145", reason = "`astral-tokio-tar` v0.6.1 is pulled transitively via testcontainers (integration_tests dev/test path); waiting on upstream fix" },
]
yanked = "deny"
unused-ignored-advisory = "deny"

73
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,73 @@
# Contributing
We're glad you're interested in contributing to Logos Execution Zone!
This document describes the guidelines for contributing to the project. We will be updating it as we grow and we figure out what works best for us.
If you have any questions, come say hi to our [Discord](https://discord.gg/tGJwgGrSPN)!
## Commit and PR title format
We use [Conventional Commits](https://www.conventionalcommits.org/).
Use:
- `type(scope): description`
- `type(scope)!: description` for breaking changes
Allowed `type` values:
- `feat`
- `fix`
- `chore`
- `docs`
- `test`
- `refactor`
- `perf`
- `build`
- `ci`
- `revert`
Examples:
- `feat(nssa): add private PDA support`
- `fix(wallet): correct fee calculation`
- `feat(nssa)!: rename AccountId::from((prog, seed)) to AccountId::for_public_pda`
Breaking changes:
- Mark with `!` in the title.
- Optionally add a `BREAKING CHANGE:` footer in the PR body with migration notes.
`CHANGELOG.md` is generated from these markers on every `v*` tag via `git-cliff`, and GitHub Releases are created from the same content.
Before merging PR consider squashing non-meaningful commits. E.g.:
```
- refactor(wallet): move user keys to a separate module
- revert(wallet): revert "refactor(wallet): move user keys to a separate module"
```
Could be squashed to an empty commit if they belong to the same PR.
## Branch workflow
When bringing your feature branch up to date, prefer rebasing on top of `main`.
- Preferred: `git rebase main`
- Avoid: `git merge main` in feature branches
This keeps commit history cleaner and makes reviews easier.
## Useful commands
We have [`Justfile`](./Justfile) which contains some useful utilities which may help you.
To list all of them run the command: `just`.
Any change to our core crates may invalidate our RISC0 [`artifacts`](./artifacts/), in that case you're required to run `just build-artifacts` to update them.
## AI-assisted contributions
AI tools are allowed for drafting code, docs, tests, and review suggestions.
Requirements:
- A human author is fully responsible for all submitted code and text.
- The person opening the PR must review, verify, and be able to explain every change.
- Do not open PRs automatically via AI agents or bots. Automatic AI-created PRs are not allowed.

285
Cargo.lock generated
View File

@ -674,9 +674,9 @@ checksum = "4858a9d740c5007a9069007c3b4e91152d0506f13c1b31dd49051fd537656156"
[[package]]
name = "astral-tokio-tar"
version = "0.6.1"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ce73b17c62717c4b6a9af10b43e87c578b0cac27e00666d48304d3b7d2c0693"
checksum = "cb50a7aae84a03bf55b067832bc376f4961b790c97e64d3eacee97d389b90277"
dependencies = [
"filetime",
"futures-core",
@ -1281,19 +1281,6 @@ dependencies = [
"time",
]
[[package]]
name = "bonsai-sdk"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a381a5f681e536070483826412fcfcd6f6637921717c6aa0a3759926899ee9c2"
dependencies = [
"duplicate",
"maybe-async",
"reqwest",
"serde",
"thiserror 2.0.18",
]
[[package]]
name = "borsh"
version = "1.6.0"
@ -1951,6 +1938,18 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "crypto_primitives_bench"
version = "0.1.0"
dependencies = [
"anyhow",
"key_protocol",
"nssa_core",
"rand 0.8.5",
"serde",
"serde_json",
]
[[package]]
name = "ctr"
version = "0.9.2"
@ -1988,6 +1987,24 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "cycle_bench"
version = "0.1.0"
dependencies = [
"amm_core",
"anyhow",
"ata_core",
"borsh",
"clap",
"clock_core",
"nssa",
"nssa_core",
"risc0-zkvm",
"serde",
"serde_json",
"token_core",
]
[[package]]
name = "darling"
version = "0.20.11"
@ -2081,7 +2098,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
dependencies = [
"data-encoding",
"syn 2.0.117",
"syn 1.0.109",
]
[[package]]
@ -2331,17 +2348,6 @@ version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
[[package]]
name = "duplicate"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e92f10a49176cbffacaedabfaa11d51db1ea0f80a83c26e1873b43cd1742c24"
dependencies = [
"heck",
"proc-macro2",
"proc-macro2-diagnostics",
]
[[package]]
name = "dyn-clone"
version = "1.0.20"
@ -2576,7 +2582,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -3555,7 +3561,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@ -3918,15 +3924,6 @@ dependencies = [
"web-time",
]
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "inout"
version = "0.1.4"
@ -3945,6 +3942,25 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "integration_bench"
version = "0.1.0"
dependencies = [
"anyhow",
"borsh",
"clap",
"common",
"indexer_service_rpc",
"jsonrpsee",
"nssa",
"sequencer_service_rpc",
"serde",
"serde_json",
"test_fixtures",
"tokio",
"wallet",
]
[[package]]
name = "integration_tests"
version = "0.1.0"
@ -3954,28 +3970,22 @@ dependencies = [
"authenticated_transfer_core",
"bytesize",
"common",
"env_logger",
"faucet_core",
"futures",
"hex",
"indexer_ffi",
"indexer_service",
"indexer_service_protocol",
"indexer_service_rpc",
"jsonrpsee",
"key_protocol",
"log",
"nssa",
"nssa_core",
"sequencer_core",
"sequencer_service",
"sequencer_service_rpc",
"serde_json",
"tempfile",
"testcontainers",
"test_fixtures",
"token_core",
"tokio",
"url",
"vault_core",
"wallet",
"wallet-ffi",
@ -4382,17 +4392,6 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "keycard_wallet"
version = "0.1.0"
dependencies = [
"log",
"nssa",
"pyo3",
"serde",
"serde_json",
]
[[package]]
name = "lazy-regex"
version = "3.6.0"
@ -5949,17 +5948,6 @@ dependencies = [
"rawpointer",
]
[[package]]
name = "maybe-async"
version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "memchr"
version = "2.8.0"
@ -5975,15 +5963,6 @@ dependencies = [
"libc",
]
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]]
name = "mempool"
version = "0.1.0"
@ -7220,69 +7199,6 @@ dependencies = [
"parking_lot",
]
[[package]]
name = "pyo3"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219"
dependencies = [
"cfg-if",
"indoc",
"libc",
"memoffset",
"once_cell",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
"unindent",
]
[[package]]
name = "pyo3-build-config"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999"
dependencies = [
"once_cell",
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33"
dependencies = [
"libc",
"pyo3-build-config",
]
[[package]]
name = "pyo3-macros"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn 2.0.117",
]
[[package]]
name = "pyo3-macros-backend"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a"
dependencies = [
"heck",
"proc-macro2",
"pyo3-build-config",
"quote",
"syn 2.0.117",
]
[[package]]
name = "quick-protobuf"
version = "0.8.1"
@ -7319,7 +7235,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.6.3",
"socket2 0.5.10",
"thiserror 2.0.18",
"tokio",
"tracing",
@ -7356,9 +7272,9 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.3",
"socket2 0.5.10",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@ -7674,7 +7590,6 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2",
@ -8026,7 +7941,6 @@ dependencies = [
"addr2line",
"anyhow",
"bincode",
"bonsai-sdk",
"borsh",
"bytemuck",
"bytes",
@ -8099,17 +8013,6 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746"
[[package]]
name = "rpassword"
version = "7.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2501c67132bd19c3005b0111fba298907ef002c8c1cf68e25634707e38bf66fe"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]]
name = "rpds"
version = "1.2.0"
@ -8202,16 +8105,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "rtoolbox"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "ruint"
version = "1.17.2"
@ -8274,7 +8167,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -8332,7 +8225,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs 0.26.11",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@ -9235,12 +9128,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tempfile"
version = "3.26.0"
@ -9251,7 +9138,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -9287,6 +9174,34 @@ dependencies = [
"test-case-core",
]
[[package]]
name = "test_fixtures"
version = "0.1.0"
dependencies = [
"anyhow",
"bytesize",
"common",
"env_logger",
"futures",
"indexer_service",
"jsonrpsee",
"key_protocol",
"log",
"nssa",
"nssa_core",
"sequencer_core",
"sequencer_service",
"sequencer_service_rpc",
"serde",
"serde_json",
"tempfile",
"testcontainers",
"tokio",
"url",
"vault_core",
"wallet",
]
[[package]]
name = "test_program_methods"
version = "0.1.0"
@ -9300,6 +9215,7 @@ version = "0.1.0"
dependencies = [
"authenticated_transfer_core",
"clock_core",
"faucet_core",
"nssa_core",
"risc0-zkvm",
"serde",
@ -10081,12 +9997,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unindent"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
[[package]]
name = "unit-prefix"
version = "0.5.2"
@ -10268,14 +10178,11 @@ dependencies = [
"indicatif",
"itertools 0.14.0",
"key_protocol",
"keycard_wallet",
"log",
"nssa",
"nssa_core",
"optfield",
"pyo3",
"rand 0.8.5",
"rpassword",
"sequencer_service_rpc",
"serde",
"serde_json",
@ -10286,7 +10193,6 @@ dependencies = [
"token_core",
"tokio",
"url",
"zeroize",
]
[[package]]
@ -10563,7 +10469,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -10702,15 +10608,6 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.61.2"

View File

@ -42,6 +42,10 @@ members = [
"testnet_initial_state",
"indexer/ffi",
"keycard_wallet",
"test_fixtures",
"tools/cycle_bench",
"tools/crypto_primitives_bench",
"tools/integration_bench",
]
[workspace.dependencies]
@ -75,6 +79,7 @@ vault_core = { path = "programs/vault/core" }
test_program_methods = { path = "test_program_methods" }
testnet_initial_state = { path = "testnet_initial_state" }
keycard_wallet = { path = "keycard_wallet" }
test_fixtures = { path = "test_fixtures" }
tokio = { version = "1.50", features = [
"net",
@ -83,7 +88,7 @@ tokio = { version = "1.50", features = [
"fs",
] }
tokio-util = "0.7.18"
risc0-zkvm = { version = "3.0.5", features = ['std'] }
risc0-zkvm = { version = "3.0.5", default-features = false, features = ['std'] }
risc0-build = "3.0.5"
anyhow = "1.0.98"
derive_more = "2.1.1"

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.

Binary file not shown.

Binary file not shown.

View File

@ -67,26 +67,17 @@ impl NSSATransaction {
}
/// Validates the transaction against the current state and returns the resulting diff
/// without applying it. Rejects transactions that modify clock system accounts and
/// rejects unsafe modifications of the system faucet account. Also rejects direct
/// invocation of the faucet program for user-submitted transactions.
/// without applying it. Rejects transactions that modify clock or faucet system accounts,
/// whether directly or indirectly via chain calls.
///
/// This check is required for all user transactions. Only sequencer transaction may bypass this
/// check.
/// This check is required for all user transactions. Only sequencer transactions may bypass
/// this check.
pub fn validate_on_state(
&self,
state: &V03State,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<ValidatedStateDiff, nssa::error::NssaError> {
if let Self::Public(tx) = self
&& tx.message().program_id == nssa::program::Program::faucet().id()
{
return Err(nssa::error::NssaError::InvalidInput(
"Transaction invokes restricted faucet program".into(),
));
}
let diff = match self {
Self::Public(tx) => {
ValidatedStateDiff::from_public_transaction(tx, state, block_id, timestamp)
@ -111,6 +102,16 @@ impl NSSATransaction {
));
}
let faucet_id = nssa::system_faucet_account_id();
if public_diff
.get(&faucet_id)
.is_some_and(|post| *post != state.get_account_by_id(faucet_id))
{
return Err(nssa::error::NssaError::InvalidInput(
"Transaction modifies system faucet account".into(),
));
}
Ok(diff)
}

11
docs/benchmarks/README.md Normal file
View File

@ -0,0 +1,11 @@
# Benchmarks
Bench tools live under `tools/` with READMEs for how to run each one. This directory holds the result write-ups: machine, raw tables, and short findings.
| Bench | Doc |
|---|---|
| cycle_bench | [cycle_bench.md](cycle_bench.md) |
| crypto_primitives_bench | [crypto_primitives_bench.md](crypto_primitives_bench.md) |
| integration_bench | [integration_bench.md](integration_bench.md) |
All numbers are from a single M2 Pro dev box unless noted otherwise.

View File

@ -0,0 +1,43 @@
# crypto_primitives_bench
Cryptographic primitives used by client/wallet code. Measures the per-call cost of key derivation, sender-side DH for note encryption, and Account note symmetric encrypt/decrypt. Standalone host binary, no live stack required.
## Machine
| Field | Value |
|---|---|
| Chip | Apple M2 Pro (8P+4E) |
| RAM | 16 GB |
| OS | macOS 15.5 |
| Rust | 1.94.0 |
| Profile | release |
## Results
100 timed iterations per operation, 2 warmup discarded.
| Operation | best (µs) | mean (µs) | stdev (µs) |
|---|---:|---:|---:|
| KeyChain::new_os_random | 2,979.62 (2.98 ms) | 3,138.18 (3.14 ms) | 258.59 (0.26 ms) |
| KeyChain::new_mnemonic | 2,979.12 (2.98 ms) | 3,012.76 (3.01 ms) | 46.09 (0.05 ms) |
| SharedSecretKey::new (sender DH) | 74.17 (0.07 ms) | 74.48 (0.07 ms) | 0.22 (<0.01 ms) |
| EncryptionScheme::encrypt | 0.88 (<0.01 ms) | 0.92 (<0.01 ms) | 0.03 (<0.01 ms) |
| EncryptionScheme::decrypt | 0.75 (<0.01 ms) | 0.78 (<0.01 ms) | 0.04 (<0.01 ms) |
## Findings
- Keychain creation is dominated by the 2048-round HMAC-SHA512 PBKDF in the mnemonic-to-SSK path. ≈ 3 ms.
- Per-recipient DH (secp256k1) is ≈ 80 µs. Outbound shielded transfers to N recipients cost ≈ 80·N µs of crypto on top of proving.
- Symmetric encrypt/decrypt over a 49-byte Account note is sub-µs. Bulk encryption is not the bottleneck.
## Reproduce
```sh
cargo run --release -p crypto_primitives_bench
```
JSON output: `target/crypto_primitives_bench.json`.
## Caveats
- Single-thread, no SIMD acceleration. Bench dev box uses the pure-Rust secp256k1 backend.

View File

@ -0,0 +1,98 @@
# cycle_bench
Per-program Risc0 cycle counts, prover wall time, PPE composition cost, and verifier wall time for the built-in LEZ programs. Inputs for the fee model's `G_executor`, `G_prove`, `G_verify`, and `S_agg` parameters.
## Machine
| Field | Value |
|---|---|
| Chip | Apple M2 Pro (8P+4E) |
| RAM | 16 GB |
| OS | macOS 15.5 |
| Rust | 1.94.0 |
| Risc0 zkVM | 3.0.5 |
| Profile | release |
| GPU acceleration | none |
## Executor cycles
`SessionInfo::cycles()` per instruction. Deterministic across runs. Wall time is `best / mean ± stdev` over 5 timed iterations (1 warmup discarded).
| Program | Instruction | user_cycles | segments | exec_ms (best / mean ± stdev) |
|---|---|---:|---:|---|
| authenticated_transfer | Initialize | 43,642 | 1 | 18.86 / 19.41 ± 0.48 |
| authenticated_transfer | Transfer | 77,095 | 1 | 19.67 / 20.84 ± 1.16 |
| token | Burn | 116,546 | 1 | 24.86 / 25.46 ± 0.63 |
| token | Mint | 116,862 | 1 | 24.47 / 25.08 ± 0.42 |
| token | Transfer | 127,726 | 1 | 25.00 / 25.40 ± 0.29 |
| clock | Tick (no rollups) | 137,022 | 1 | 21.18 / 21.57 ± 0.41 |
| ata | Create | 175,056 | 1 | 23.64 / 24.94 ± 1.09 |
| amm | SwapExactInput | 508,634 | 1 | 34.21 / 34.77 ± 0.55 |
| amm | AddLiquidity | 642,774 | 1 | 37.59 / 37.87 ± 0.28 |
## Real proving (`--prove`)
`prover.prove(env, elf)` wall time per program on CPU. `total_cycles` is `user_cycles` rounded up to the next power of two (Risc0 padding).
| Program | Instruction | total_cycles | prove_ms | prove_s |
|---|---|---:|---:|---:|
| authenticated_transfer | Initialize | 131,072 | 11,881 | 11.9 |
| authenticated_transfer | Transfer | 131,072 | 13,705 | 13.7 |
| token | Burn | 262,144 | 22,893 | 22.9 |
| token | Mint | 262,144 | 23,927 | 23.9 |
| token | Transfer | 262,144 | 27,178 | 27.2 |
| clock | Tick | 262,144 | 23,486 | 23.5 |
| ata | Create | 262,144 | 21,093 | 21.1 |
| amm | AddLiquidity | 1,048,576 | 111,654 | 111.7 |
| amm | SwapExactInput | 1,048,576 | 126,400 | 126.4 |
Linear fit across po2 buckets: ≈ 100 µs per total cycle (≈ 10k cycles/s throughput on this CPU).
## PPE composition + chain-call sweep (`--ppe`)
Same `auth_transfer Transfer` instruction, standalone vs wrapped in the privacy circuit; plus the `chain_caller` test program with N chained `authenticated_transfer` calls. `proof_bytes` is the borsh-serialized. InnerReceipt (S_agg in the fee model).
| Case | prove_ms | prove_s | proof_bytes |
|---|---:|---:|---:|
| auth_transfer Transfer standalone | 13,705 | 13.7 | n/a |
| auth_transfer Transfer in PPE | 61,486 | 61.5 | 223,551 |
| chain_caller depth=1 | 122,590 | 122.6 | 223,551 |
| chain_caller depth=3 | 231,974 | 232.0 | 223,551 |
| chain_caller depth=5 | 372,123 | 372.1 | 223,551 |
| chain_caller depth=9 | 544,280 | 544.3 | 223,551 |
Linear fit depth=1..9: ≈ 53 s per additional chained call, intercept ≈ 73 s. Composition tax (single program PPE standalone): ≈ 48 s. `proof_bytes` is constant: the outer succinct proof has fixed size; the journal carried alongside it scales with public state and is reported separately by `--verify`.
## Verifier (`--verify`)
One PPE receipt generated once (auth_transfer Transfer in PPE), then `Receipt::verify(PRIVACY_PRESERVING_CIRCUIT_ID)` measured over 1000 iterations.
| Field | Value |
|---|---|
| case | auth_transfer Transfer in PPE |
| proof_bytes (S_agg) | 223,551 |
| journal_bytes | 412 |
| verify_ms (best / mean ± stdev, n=1000) | 11.71 / 12.06 ± 1.99 |
## Findings
- Proving cost scales with po2-bucketed `total_cycles`, not raw `user_cycles`. Trimming user_cycles only helps if it crosses a 2^N boundary.
- Single-program PPE composition tax on M2 Pro CPU: ≈ 48 s (61.5 13.7).
- Chained-call cost is linear at ≈ 53 s per call. A max-depth chain (10) would take ≈ 600 s standalone on this CPU.
- `G_verify` is ≈ 12 ms and roughly constant per outer receipt (1000-iter stdev ≈ 2 ms). The succinct outer proof is fixed at 223,551 bytes (S_agg); verify is not on the latency critical path.
## Reproduce
```sh
cargo run --release -p cycle_bench
cargo run --release -p cycle_bench --features prove -- --prove
cargo run --release -p cycle_bench --features ppe -- --prove --ppe
cargo run --release -p cycle_bench --features ppe -- --verify --verify-iters 1000
```
JSON output: `target/cycle_bench.json`.
## Caveats
- CPU-only proving on a dev laptop. Production prover hardware (GPU, specialised CPU pipelines) will produce much smaller numbers; relative ordering should be preserved.
- Single-segment cases only; multi-segment programs would pay continuation overhead not measured here.

View File

@ -0,0 +1,120 @@
# integration_bench
End-to-end LEZ scenarios driven through the wallet against a docker-compose Bedrock node + in-process sequencer + indexer (via `test_fixtures::TestContext`). Times each step and records borsh sizes per block, split by tx variant.
Numbers below are from a single-host docker-compose run on an Apple M2 Pro (CPU only, no GPU acceleration). Absolute wall time and block sizes depend heavily on the bedrock config (block cadence and confirmation depth) and on dev-mode vs real proving; re-run the bench locally to characterise your own setup.
## Scenarios
| Scenario | Description |
|---|---|
| token | Sequential public token Send + one shielded recipient setup. |
| amm | Pool create, add liquidity, swap, remove liquidity. All public. |
| fanout | One sender → N recipients, sequential. All public. |
| private | Shielded, deshielded, private→private chained private flow. |
| parallel | N senders submit concurrently into one block. All public. |
## Dev-mode vs real-proving
`RISC0_DEV_MODE=1` makes the prover emit stub receipts instead of running the recursive STARK pipeline. The table compares each quantity in dev mode vs real proving for the two classes of scenarios:
| Quantity | Public-only scenarios (dev → real) | PPE-bearing scenarios (dev → real) |
|---|---|---|
| Wall time per step | same in both modes | real adds ~100 s per PPE step |
| `public_tx_bytes` | same in both modes | same in both modes |
| `ppe_tx_bytes` | n/a | dev ≈ 2 KB stub → real ≈ 225 KB (matches `S_agg` from cycle_bench) |
| `block_bytes` | same in both modes | real adds ~225 KB per PPE tx in the block |
| `bedrock_finality_s` | same in both modes | same in both modes (L1 cadence, not LEZ prover) |
| Blocks captured | similar in both modes | real captures more empty clock-only ticks that fill prove wall-time |
Tables below report dev-mode for all five scenarios. Real-proving numbers are included for `amm_swap_flow` (representative all-public) and `private_chained_flow` (representative chained-private flow); public-only scenarios converge between modes within run-to-run jitter, so a full real-proving sweep is not run here.
## Methodology
Per scenario, every produced block is fetched via `getBlock(BlockId)` and serialized with `borsh::to_vec(&Block)`. Each transaction is serialized individually and counted by variant. Empty clock-only ticks give the per-block fixed-cost baseline. Wall time is captured per step (submit + inclusion + wallet sync) and aggregated to the per-scenario `total_s`. The one-time stack-setup cost (`shared_setup_s` at the run level) and the closing bedrock finality wait (`bedrock_finality_s` per scenario) are reported separately, not folded into `total_s`.
## Step latencies — dev mode (`RISC0_DEV_MODE=1`)
Per-scenario wall time and Bedrock L1-finality latency for the closing tip.
| Scenario | total_s | bedrock_finality_s |
|---|---:|---:|
| token_onboarding | 61.36 | 5.88 |
| amm_swap_flow | 156.50 | 27.99 |
| multi_recipient_fanout | 214.40 | 31.71 |
| private_chained_flow | 109.31 | 8.73 |
| parallel_fanout | 234.42 | 20.29 |
Shared TestContext setup: 139.80 s (paid once per run). Total dev-mode wall time across all five scenarios: 1010.4 s.
## Step latencies — real proving (selected scenarios)
| Scenario | total_s | bedrock_finality_s | Δ vs dev |
|---|---:|---:|---:|
| amm_swap_flow | 156.20 | 26.95 | ~0 (all-public) |
| private_chained_flow | 391.74 | 9.40 | +282.4 s (≈ 94 s per PPE step × 3) |
Per-step breakdown for `private_chained_flow` in real proving:
| Step | submit_s | inclusion_s | total_s |
|---|---:|---:|---:|
| token_new_fungible (public) | 0.003 | 10.857 | 11.006 |
| shielded_transfer (PPE) | 125.416 | 0.001 | 125.469 |
| deshielded_transfer (PPE) | 126.261 | 0.001 | 126.311 |
| private_to_private (PPE) | 128.875 | 0.001 | 128.934 |
PPE steps move the cost from `inclusion_s` (waiting for the next sealed block) to `submit_s` (the wallet itself proving the PPE circuit before sending). Each PPE prove is ≈ 127 s on this CPU.
## Block + tx sizes (borsh) — dev mode
Per scenario, every produced block is fetched via `getBlock(BlockId)` and serialized with `borsh::to_vec(&Block)`. Each transaction is serialized individually and counted by variant. The empty clock-only ticks at `min` give the per-block fixed-cost baseline (≈ 334 bytes across all scenarios).
| Scenario | blocks | block_bytes (mean) | block_bytes (min..max) | public_tx (mean / n) | ppe_tx (mean / n) |
|---|---:|---:|---|---:|---:|
| token_onboarding | 6 | 881 | 334..2,890 | 206 / 8 | 2,556 / 1 |
| amm_swap_flow | 16 | 553 | 334..1,011 | 248 / 24 | n/a |
| multi_recipient_fanout | 22 | 513 | 334..707 | 221 / 33 | n/a |
| private_chained_flow | 10 | 1,186 | 334..3,565 | 173 / 11 | 2,715 / 3 |
| parallel_fanout | 24 | 646 | 334..3,904 | 248 / 45 | n/a |
## Block + tx sizes (borsh) — real proving
| Scenario | blocks | block_bytes (mean) | block_bytes (min..max) | public_tx (mean / n) | ppe_tx (mean / n) |
|---|---:|---:|---|---:|---:|
| amm_swap_flow | 16 | 553 | 334..1,011 | 248 / 24 | n/a |
| private_chained_flow | 39 | 17,707 | 334..226,578 | 158 / 40 | 225,728 / 3 |
`amm_swap_flow` is byte-identical between dev and real (no proof payload). `private_chained_flow`'s `ppe_tx_bytes` matches the cycle_bench `S_agg` measurement (≈ 225 KB borsh InnerReceipt). The `block_bytes` max (226,578) is the block containing the largest PPE transaction.
## Findings
- Public-only scenarios converge between dev mode and real proving in both latency and byte counts. Either mode is suitable to characterize them.
- PPE transactions are ≈ 225 KB on the wire in real proving, dominated by the outer succinct proof. Dev mode emits a ≈ 2.7 KB stub that does not represent the L1 payload; fee-model storage gas inputs must come from a real-proving run.
- Per-PPE-step prove cost on this CPU is ≈ 127 s, paid on the wallet side at submit time, not on the sequencer. For a single-program chained flow the cost stacks linearly.
- Empty clock-only ticks set the per-block fixed-cost baseline at ≈ 334 bytes across all scenarios and both modes.
- Bedrock L1 finality varies in the 6 to 32 s range across scenarios, driven by L1 cadence and which tick the closing wait happens to land on, not by the LEZ prover.
## Reproduce
Prerequisite: a running local Docker daemon (the `bedrock/docker-compose.yml` is brought up by the bench).
```sh
# Dev-mode sweep (fast)
RISC0_DEV_MODE=1 cargo run --release -p integration_bench -- --scenario all
# Real-proving for representative private flow
cargo run --release -p integration_bench -- --scenario private
# Real-proving for representative public flow
cargo run --release -p integration_bench -- --scenario amm
```
JSON output: `target/integration_bench_dev.json` / `target/integration_bench_prove.json` (suffix toggled by `RISC0_DEV_MODE`).
## Caveats
- Dev-mode `ppe_tx_bytes` and PPE-step latencies are not representative of production; use real-proving numbers for any fee-model input that touches the storage or prover-cost components.
- Single-host run, no GPU acceleration. Real-proving on production prover hardware will move per-step latencies by orders of magnitude; byte counts will not change.
- Bedrock running locally via docker-compose; no real network latency between sequencer and Bedrock.
- Bedrock L1 finality (`bedrock_finality_s`) is set by the bedrock config in `bedrock/docker-compose.yml` (block cadence × confirmation depth). Different configs will shift `bedrock_finality_s` materially.
- All scenarios share a single TestContext for the run (one bedrock + sequencer + indexer + wallet for the whole run, chain state accumulating across scenarios), which matches how the node runs in production.

View File

@ -8,15 +8,15 @@ license = { workspace = true }
workspace = true
[dependencies]
test_fixtures.workspace = true
nssa_core = { workspace = true, features = ["host"] }
nssa.workspace = true
authenticated_transfer_core.workspace = true
sequencer_core = { workspace = true, features = ["default", "testnet"] }
sequencer_service.workspace = true
wallet.workspace = true
common.workspace = true
key_protocol.workspace = true
indexer_service.workspace = true
serde_json.workspace = true
token_core.workspace = true
ata_core.workspace = true
@ -24,18 +24,13 @@ vault_core.workspace = true
faucet_core.workspace = true
indexer_service_rpc = { workspace = true, features = ["client"] }
sequencer_service_rpc = { workspace = true, features = ["client"] }
jsonrpsee = { workspace = true, features = ["ws-client"] }
wallet-ffi.workspace = true
indexer_ffi.workspace = true
indexer_service_protocol.workspace = true
url.workspace = true
anyhow.workspace = true
env_logger.workspace = true
log.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
hex.workspace = true
tempfile.workspace = true
bytesize.workspace = true
futures.workspace = true
testcontainers = { version = "0.27.3", features = ["docker-compose"] }

View File

@ -1,441 +1,6 @@
//! This library contains common code for integration tests.
//! Integration test helpers, re-exported from `test_fixtures` for backwards
//! compatibility. The actual fixtures live in the `test_fixtures` crate so that
//! non-test consumers (e.g. `integration_bench`) can depend on them without
//! pulling in the test files.
use std::{net::SocketAddr, sync::LazyLock};
use anyhow::{Context as _, Result};
use common::{HashType, transaction::NSSATransaction};
use futures::FutureExt as _;
use indexer_service::IndexerHandle;
use log::{debug, error};
use nssa::{AccountId, PrivacyPreservingTransaction};
use nssa_core::Commitment;
use sequencer_core::config::GenesisAction;
use sequencer_service::SequencerHandle;
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use tempfile::TempDir;
use testcontainers::compose::DockerCompose;
use wallet::{WalletCore, account::AccountIdWithPrivacy, cli::CliAccountMention};
use crate::{
indexer_client::IndexerClient,
setup::{
setup_bedrock_node, setup_indexer, setup_private_accounts_with_initial_supply,
setup_public_accounts_with_initial_supply, setup_sequencer, setup_wallet,
},
};
pub mod config;
pub mod indexer_client;
pub mod setup;
// TODO: Remove this and control time from tests
pub const TIME_TO_WAIT_FOR_BLOCK_SECONDS: u64 = 12;
pub const NSSA_PROGRAM_FOR_TEST_DATA_CHANGER: &str = "data_changer.bin";
pub const NSSA_PROGRAM_FOR_TEST_NOOP: &str = "noop.bin";
pub const NSSA_PROGRAM_FOR_TEST_PDA_FUND_SPEND_PROXY: &str = "pda_fund_spend_proxy.bin";
const BEDROCK_SERVICE_WITH_OPEN_PORT: &str = "logos-blockchain-node-0";
const BEDROCK_SERVICE_PORT: u16 = 18080;
static LOGGER: LazyLock<()> = LazyLock::new(env_logger::init);
struct IndexerComponents {
indexer_handle: IndexerHandle,
indexer_client: IndexerClient,
_temp_dir: TempDir,
}
impl Drop for IndexerComponents {
fn drop(&mut self) {
let Self {
indexer_handle,
indexer_client: _,
_temp_dir: _,
} = self;
if !indexer_handle.is_healthy() {
error!("Indexer handle has unexpectedly stopped before IndexerComponents drop");
}
}
}
/// Test context which sets up a sequencer and a wallet for integration tests.
///
/// It's memory and logically safe to create multiple instances of this struct in parallel tests,
/// as each instance uses its own temporary directories for sequencer and wallet data.
// NOTE: Order of fields is important for proper drop order.
pub struct TestContext {
sequencer_client: SequencerClient,
wallet: WalletCore,
wallet_password: String,
/// Optional to move out value in Drop.
sequencer_handle: Option<SequencerHandle>,
indexer_components: Option<IndexerComponents>,
bedrock_compose: DockerCompose,
bedrock_addr: SocketAddr,
_temp_sequencer_dir: TempDir,
_temp_wallet_dir: TempDir,
}
impl TestContext {
/// Create new test context.
pub async fn new() -> Result<Self> {
Self::builder().build().await
}
/// Get a builder for the test context to customize its configuration.
#[must_use]
pub const fn builder() -> TestContextBuilder {
TestContextBuilder::new()
}
/// Get reference to the wallet.
#[must_use]
pub const fn wallet(&self) -> &WalletCore {
&self.wallet
}
#[must_use]
pub fn wallet_password(&self) -> &str {
&self.wallet_password
}
/// Get mutable reference to the wallet.
pub const fn wallet_mut(&mut self) -> &mut WalletCore {
&mut self.wallet
}
/// Get reference to the sequencer client.
#[must_use]
pub const fn sequencer_client(&self) -> &SequencerClient {
&self.sequencer_client
}
/// Get the Bedrock Node address.
#[must_use]
pub const fn bedrock_addr(&self) -> SocketAddr {
self.bedrock_addr
}
/// Get reference to the indexer.
///
/// # Panics
///
/// Panics if the indexer is not enabled in the test context. See
/// [`TestContextBuilder::disable_indexer()`].
#[must_use]
pub fn indexer(&self) -> &IndexerHandle {
self.indexer_components
.as_ref()
.map(|components| &components.indexer_handle)
.expect("Called `TestContext::indexer()` on context with disabled indexer")
}
/// Get reference to the indexer client.
///
/// # Panics
///
/// Panics if the indexer is not enabled in the test context. See
/// [`TestContextBuilder::disable_indexer()`].
#[must_use]
pub fn indexer_client(&self) -> &IndexerClient {
self.indexer_components
.as_ref()
.map(|components| &components.indexer_client)
.expect("Called `TestContext::indexer_client()` on context with disabled indexer")
}
/// Get existing public account IDs in the wallet.
#[must_use]
pub fn existing_public_accounts(&self) -> Vec<AccountId> {
self.wallet
.storage()
.key_chain()
.public_account_ids()
.map(|(account_id, _idx)| account_id)
.collect()
}
/// Get existing private account IDs in the wallet.
#[must_use]
pub fn existing_private_accounts(&self) -> Vec<AccountId> {
self.wallet
.storage()
.key_chain()
.private_account_ids()
.map(|(account_id, _idx)| account_id)
.collect()
}
}
impl Drop for TestContext {
fn drop(&mut self) {
let Self {
sequencer_handle,
bedrock_compose,
bedrock_addr: _,
indexer_components: _,
sequencer_client: _,
wallet: _,
wallet_password: _,
_temp_sequencer_dir: _,
_temp_wallet_dir: _,
} = self;
let sequencer_handle = sequencer_handle
.take()
.expect("Sequencer handle should be present in TestContext drop");
if !sequencer_handle.is_healthy() {
let Err(err) = sequencer_handle
.failed()
.now_or_never()
.expect("Sequencer handle should not be running");
error!(
"Sequencer handle has unexpectedly stopped before TestContext drop with error: {err:#}"
);
}
let container = bedrock_compose
.service(BEDROCK_SERVICE_WITH_OPEN_PORT)
.unwrap_or_else(|| {
panic!("Failed to get Bedrock service container `{BEDROCK_SERVICE_WITH_OPEN_PORT}`")
});
let output = std::process::Command::new("docker")
.args(["inspect", "-f", "{{.State.Running}}", container.id()])
.output()
.expect("Failed to execute docker inspect command to check if Bedrock container is still running");
let stdout = String::from_utf8(output.stdout)
.expect("Failed to parse docker inspect output as String");
if stdout.trim() != "true" {
error!(
"Bedrock container `{}` is not running during TestContext drop, docker inspect output: {stdout}",
container.id()
);
}
}
}
pub struct TestContextBuilder {
genesis_transactions: Option<Vec<GenesisAction>>,
sequencer_partial_config: Option<config::SequencerPartialConfig>,
enable_indexer: bool,
}
impl TestContextBuilder {
const fn new() -> Self {
Self {
genesis_transactions: None,
sequencer_partial_config: None,
enable_indexer: true,
}
}
#[must_use]
pub fn with_genesis(mut self, genesis_transactions: Vec<GenesisAction>) -> Self {
self.genesis_transactions = Some(genesis_transactions);
self
}
#[must_use]
pub const fn with_sequencer_partial_config(
mut self,
sequencer_partial_config: config::SequencerPartialConfig,
) -> Self {
self.sequencer_partial_config = Some(sequencer_partial_config);
self
}
/// Exclude Indexer from test context.
/// Indexer is enabled by default.
///
/// Methods like [`TestContext::indexer()`] and [`TestContext::indexer_client()`] will panic if
/// called when indexer is disabled.
#[must_use]
pub const fn disable_indexer(mut self) -> Self {
self.enable_indexer = false;
self
}
pub async fn build(self) -> Result<TestContext> {
let Self {
genesis_transactions,
sequencer_partial_config,
enable_indexer,
} = self;
// Ensure logger is initialized only once
*LOGGER;
debug!("Test context setup");
let (bedrock_compose, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to setup Bedrock node")?;
let indexer_components = if enable_indexer {
let (indexer_handle, temp_indexer_dir) = setup_indexer(bedrock_addr)
.await
.context("Failed to setup Indexer")?;
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_handle.addr())
.context("Failed to convert indexer addr to URL")?;
let indexer_client = IndexerClient::new(&indexer_url)
.await
.context("Failed to create indexer client")?;
Some(IndexerComponents {
indexer_handle,
indexer_client,
_temp_dir: temp_indexer_dir,
})
} else {
None
};
let initial_public_accounts = config::default_public_accounts_for_wallet();
let initial_private_accounts = config::default_private_accounts_for_wallet();
let (sequencer_handle, temp_sequencer_dir) = setup_sequencer(
sequencer_partial_config.unwrap_or_default(),
bedrock_addr,
genesis_transactions.unwrap_or_else(|| {
config::genesis_from_accounts(&initial_public_accounts, &initial_private_accounts)
}),
)
.await
.context("Failed to setup Sequencer")?;
let (mut wallet, temp_wallet_dir, wallet_password) = setup_wallet(
sequencer_handle.addr(),
&initial_public_accounts,
&initial_private_accounts,
)
.context("Failed to setup wallet")?;
setup_public_accounts_with_initial_supply(&wallet, &initial_public_accounts)
.await
.context("Failed to initialize public accounts in wallet")?;
setup_private_accounts_with_initial_supply(&mut wallet, &initial_private_accounts)
.await
.context("Failed to initialize private accounts in wallet")?;
let sequencer_url = config::addr_to_url(config::UrlProtocol::Http, sequencer_handle.addr())
.context("Failed to convert sequencer addr to URL")?;
let sequencer_client = SequencerClientBuilder::default()
.build(sequencer_url)
.context("Failed to create sequencer client")?;
Ok(TestContext {
sequencer_client,
wallet,
wallet_password,
bedrock_compose,
bedrock_addr,
sequencer_handle: Some(sequencer_handle),
indexer_components,
_temp_sequencer_dir: temp_sequencer_dir,
_temp_wallet_dir: temp_wallet_dir,
})
}
pub fn build_blocking(self) -> Result<BlockingTestContext> {
let runtime = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?;
let ctx = runtime.block_on(self.build())?;
Ok(BlockingTestContext {
ctx: Some(ctx),
runtime,
})
}
}
/// A test context to be used in normal #[test] tests.
pub struct BlockingTestContext {
ctx: Option<TestContext>,
runtime: tokio::runtime::Runtime,
}
impl BlockingTestContext {
pub fn new() -> Result<Self> {
TestContext::builder().build_blocking()
}
pub const fn ctx(&self) -> &TestContext {
self.ctx.as_ref().expect("TestContext is set")
}
pub const fn runtime(&self) -> &tokio::runtime::Runtime {
&self.runtime
}
pub fn block_on<'ctx, F>(&'ctx self, f: impl FnOnce(&'ctx TestContext) -> F) -> F::Output
where
F: std::future::Future + 'ctx,
{
let future = f(self.ctx());
self.runtime.block_on(future)
}
pub fn block_on_mut<'ctx, F>(
&'ctx mut self,
f: impl FnOnce(&'ctx mut TestContext) -> F,
) -> F::Output
where
F: std::future::Future + 'ctx,
{
let ctx_mut = self.ctx.as_mut().expect("TestContext is set");
let future = f(ctx_mut);
self.runtime.block_on(future)
}
}
impl Drop for BlockingTestContext {
fn drop(&mut self) {
let Self { ctx, runtime } = self;
// Ensure async cleanup of TestContext by blocking on its drop in the runtime.
runtime.block_on(async {
if let Some(ctx) = ctx.take() {
drop(ctx);
}
});
}
}
#[must_use]
pub const fn public_mention(account_id: AccountId) -> CliAccountMention {
CliAccountMention::Id(AccountIdWithPrivacy::Public(account_id))
}
#[must_use]
pub const fn private_mention(account_id: AccountId) -> CliAccountMention {
CliAccountMention::Id(AccountIdWithPrivacy::Private(account_id))
}
#[expect(
clippy::wildcard_enum_match_arm,
reason = "We want the code to panic if the transaction type is not PrivacyPreserving"
)]
pub async fn fetch_privacy_preserving_tx(
seq_client: &SequencerClient,
tx_hash: HashType,
) -> PrivacyPreservingTransaction {
let tx = seq_client.get_transaction(tx_hash).await.unwrap().unwrap();
match tx {
NSSATransaction::PrivacyPreserving(privacy_preserving_transaction) => {
privacy_preserving_transaction
}
_ => panic!("Invalid tx type"),
}
}
pub async fn verify_commitment_is_in_state(
commitment: Commitment,
seq_client: &SequencerClient,
) -> bool {
seq_client
.get_proof_for_commitment(commitment)
.await
.ok()
.flatten()
.is_some()
}
pub use test_fixtures::*;

View File

@ -1,6 +1,7 @@
use std::time::Duration;
use anyhow::{Context as _, Result};
use common::transaction::NSSATransaction;
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, fetch_privacy_preserving_tx, private_mention,
public_mention, verify_commitment_is_in_state,
@ -623,3 +624,130 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> {
Ok(())
}
#[test]
async fn ppt_that_chain_calls_faucet_is_dropped() -> Result<()> {
use nssa::{
EphemeralPublicKey, SharedSecretKey, execute_and_prove,
privacy_preserving_transaction::{self, circuit::ProgramWithDependencies},
};
use nssa_core::{InputAccountIdentity, account::AccountWithMetadata};
let ctx = TestContext::new().await?;
let binary = std::fs::read(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../artifacts/test_program_methods/faucet_chain_caller.bin"),
)?;
let deploy_tx = NSSATransaction::ProgramDeployment(nssa::ProgramDeploymentTransaction::new(
nssa::program_deployment_transaction::Message::new(binary.clone()),
));
ctx.sequencer_client().send_transaction(deploy_tx).await?;
info!("Waiting for deploy block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let faucet_account_id = nssa::system_faucet_account_id();
let attacker_id = ctx.existing_public_accounts()[0];
let faucet_program_id = Program::faucet().id();
let vault_program_id = Program::vault().id();
let auth_transfer_program_id = Program::authenticated_transfer_program().id();
let nsk: nssa_core::NullifierSecretKey = [3; 32];
let npk = NullifierPublicKey::from(&nsk);
let vpk = Secp256k1Point::from_scalar([4; 32]);
let ssk = SharedSecretKey::new([55; 32], &vpk);
let epk = EphemeralPublicKey::from_scalar([55; 32]);
let attacker_vault_id = {
let seed = vault_core::compute_vault_seed(attacker_id);
AccountId::for_private_pda(&vault_program_id, &seed, &npk, 1337)
};
let amount: u128 = 1;
let faucet_pre = AccountWithMetadata::new(
ctx.sequencer_client()
.get_account(faucet_account_id)
.await?,
false,
faucet_account_id,
);
let vault_pda_pre = AccountWithMetadata::new(
ctx.sequencer_client()
.get_account(attacker_vault_id)
.await?,
false,
attacker_vault_id,
);
let faucet_chain_caller = Program::new(binary)?;
let program_with_deps = ProgramWithDependencies::new(
faucet_chain_caller,
[
(faucet_program_id, Program::faucet()),
(vault_program_id, Program::vault()),
(
auth_transfer_program_id,
Program::authenticated_transfer_program(),
),
]
.into(),
);
let instruction =
Program::serialize_instruction((faucet_program_id, vault_program_id, attacker_id, amount))?;
let (output, proof) = execute_and_prove(
vec![faucet_pre, vault_pda_pre],
instruction,
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivatePdaInit {
npk,
ssk,
identifier: 1337,
},
],
&program_with_deps,
)?;
let message = privacy_preserving_transaction::Message::try_from_circuit_output(
vec![faucet_account_id],
vec![],
vec![(npk, vpk, epk)],
output,
)?;
let witness_set = privacy_preserving_transaction::WitnessSet::for_message(&message, proof, &[]);
let attack_ppt = NSSATransaction::PrivacyPreserving(nssa::PrivacyPreservingTransaction::new(
message,
witness_set,
));
let faucet_balance_before = ctx
.sequencer_client()
.get_account_balance(faucet_account_id)
.await?;
let vault_balance_before = ctx
.sequencer_client()
.get_account_balance(attacker_vault_id)
.await?;
let tx_hash = ctx.sequencer_client().send_transaction(attack_ppt).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let faucet_balance_after = ctx
.sequencer_client()
.get_account_balance(faucet_account_id)
.await?;
let vault_balance_after = ctx
.sequencer_client()
.get_account_balance(attacker_vault_id)
.await?;
let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?;
assert_eq!(faucet_balance_after, faucet_balance_before);
assert_eq!(vault_balance_after, vault_balance_before);
assert!(tx_on_chain.is_none());
Ok(())
}

View File

@ -1,4 +1,4 @@
use std::time::Duration;
use std::{path::PathBuf, time::Duration};
use anyhow::Result;
use common::transaction::NSSATransaction;
@ -397,44 +397,6 @@ async fn cannot_transfer_funds_from_system_faucet_account() -> Result<()> {
Ok(())
}
#[test]
async fn can_transfer_funds_to_system_faucet_account() -> Result<()> {
let mut ctx = TestContext::new().await?;
let faucet_account_id = system_faucet_account_id();
let sender = ctx.existing_public_accounts()[0];
let sender_balance_before = ctx.sequencer_client().get_account_balance(sender).await?;
let faucet_balance_before = ctx
.sequencer_client()
.get_account_balance(faucet_account_id)
.await?;
let amount = 100_u128;
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: public_mention(sender),
to: Some(public_mention(faucet_account_id)),
to_npk: None,
to_vpk: None,
to_identifier: Some(0),
amount,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let sender_balance_after = ctx.sequencer_client().get_account_balance(sender).await?;
let faucet_balance_after = ctx
.sequencer_client()
.get_account_balance(faucet_account_id)
.await?;
assert_eq!(sender_balance_after, sender_balance_before - amount);
assert_eq!(faucet_balance_after, faucet_balance_before + amount);
Ok(())
}
#[test]
async fn cannot_execute_faucet_program() -> Result<()> {
let ctx = TestContext::new().await?;
@ -492,3 +454,69 @@ async fn cannot_execute_faucet_program() -> Result<()> {
Ok(())
}
#[test]
async fn user_tx_that_chain_calls_faucet_is_dropped() -> Result<()> {
let ctx = TestContext::new().await?;
let binary = std::fs::read(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../artifacts/test_program_methods/faucet_chain_caller.bin"),
)?;
let faucet_chain_caller_id = Program::new(binary.clone())?.id();
let deploy_tx = NSSATransaction::ProgramDeployment(nssa::ProgramDeploymentTransaction::new(
nssa::program_deployment_transaction::Message::new(binary),
));
ctx.sequencer_client().send_transaction(deploy_tx).await?;
info!("Waiting for deploy block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let faucet_account_id = system_faucet_account_id();
let attacker = ctx.existing_public_accounts()[0];
let faucet_program_id = Program::faucet().id();
let vault_program_id = Program::vault().id();
let attacker_vault_id = vault_core::compute_vault_account_id(vault_program_id, attacker);
let amount: u128 = 1;
let message = public_transaction::Message::try_new(
faucet_chain_caller_id,
vec![faucet_account_id, attacker_vault_id],
vec![],
(faucet_program_id, vault_program_id, attacker, amount),
)?;
let attack_tx = NSSATransaction::Public(nssa::PublicTransaction::new(
message,
nssa::public_transaction::WitnessSet::from_raw_parts(vec![]),
));
let faucet_balance_before = ctx
.sequencer_client()
.get_account_balance(faucet_account_id)
.await?;
let vault_balance_before = ctx
.sequencer_client()
.get_account_balance(attacker_vault_id)
.await?;
let tx_hash = ctx.sequencer_client().send_transaction(attack_tx).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let faucet_balance_after = ctx
.sequencer_client()
.get_account_balance(faucet_account_id)
.await?;
let vault_balance_after = ctx
.sequencer_client()
.get_account_balance(attacker_vault_id)
.await?;
let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?;
assert_eq!(faucet_balance_after, faucet_balance_before);
assert_eq!(vault_balance_after, vault_balance_before);
assert!(tx_on_chain.is_none());
Ok(())
}

View File

@ -14,7 +14,7 @@ faucet_core.workspace = true
anyhow.workspace = true
thiserror.workspace = true
risc0-zkvm.workspace = true
risc0-zkvm = { workspace = true, features = ["prove"] }
serde.workspace = true
serde_with.workspace = true
sha2.workspace = true

View File

@ -469,6 +469,24 @@ mod tests {
use test_program_methods::PINATA_COOLDOWN_ELF;
Self::new(PINATA_COOLDOWN_ELF.to_vec()).unwrap()
}
#[must_use]
pub fn malicious_injector() -> Self {
use test_program_methods::{MALICIOUS_INJECTOR_ELF, MALICIOUS_INJECTOR_ID};
Self {
id: MALICIOUS_INJECTOR_ID,
elf: MALICIOUS_INJECTOR_ELF.to_vec(),
}
}
#[must_use]
pub fn malicious_launderer() -> Self {
use test_program_methods::{MALICIOUS_LAUNDERER_ELF, MALICIOUS_LAUNDERER_ID};
Self {
id: MALICIOUS_LAUNDERER_ID,
elf: MALICIOUS_LAUNDERER_ELF.to_vec(),
}
}
}
#[test]

View File

@ -265,7 +265,11 @@ impl ValidatedStateDiff {
state_diff.insert(pre.account_id, post.account().clone());
}
let authorized_accounts: HashSet<_> = chained_call
// Source from `program_output.pre_states`, not `chained_call.pre_states`:
// the loop above already gates program_output's `is_authorized` via the
// `!pre.is_authorized || is_indeed_authorized` check, while `chained_call.
// pre_states` is caller-controlled and can be forged (audit-issue 91).
let authorized_accounts: HashSet<_> = program_output
.pre_states
.iter()
.filter(|pre| pre.is_authorized)
@ -488,3 +492,427 @@ fn n_unique<T: Eq + Hash>(data: &[T]) -> usize {
let set: HashSet<&T> = data.iter().collect();
set.len()
}
#[cfg(test)]
mod tests {
use nssa_core::account::{AccountId, Nonce};
use crate::{
PrivateKey, PublicKey, V03State,
error::{InvalidProgramBehaviorError, NssaError},
program::Program,
public_transaction::{Message, WitnessSet},
validated_state_diff::ValidatedStateDiff,
};
/// Privacy-path version of the authorization-injection attack. The test passes when the
/// attack is rejected and the victim's balance is left untouched.
///
/// `execute_and_prove` succeeds because each inner receipt is individually valid and the
/// outer circuit faithfully commits whatever the attacker's program output says, including
/// `victim(is_authorized=true)`. The circuit has no access to chain state and cannot know
/// the victim never signed.
///
/// The host-side validator is what catches the attack: it independently reconstructs
/// `public_pre_states` from chain state using `signer_account_ids.contains(victim_id) = false`,
/// so it expects `victim(is_authorized=false)`. The committed journal and the reconstructed
/// expected output diverge, `receipt.verify` fails, and `from_privacy_preserving_transaction`
/// returns an error before any state is applied.
#[test]
fn privacy_malicious_programs_cannot_drain_public_victim() {
use nssa_core::{
Commitment, InputAccountIdentity, SharedSecretKey,
account::{Account, AccountWithMetadata},
encryption::EphemeralPublicKey,
};
use crate::{
PrivacyPreservingTransaction,
privacy_preserving_transaction::{
circuit::{ProgramWithDependencies, execute_and_prove},
message::Message,
witness_set::WitnessSet,
},
state::{CommitmentSet, tests::test_private_account_keys_1},
};
type InjectorInstruction = (
nssa_core::program::ProgramId, // p2_id
nssa_core::program::ProgramId, // auth_transfer_id
[u8; 32], // victim_id_raw
u128, // victim_balance
u128, // victim_nonce
nssa_core::program::ProgramId, // victim_program_owner
[u8; 32], // recipient_id_raw
u128, // amount
);
// Attacker controls a private account.
let attacker_keys = test_private_account_keys_1();
let attacker_id = AccountId::for_regular_private_account(&attacker_keys.npk(), 0);
let attacker_esk = [12_u8; 32];
let attacker_ssk = SharedSecretKey::new(attacker_esk, &attacker_keys.vpk());
let attacker_epk = EphemeralPublicKey::from_scalar(attacker_esk);
let victim_id = AccountId::new([20_u8; 32]);
let recipient_id = AccountId::new([42_u8; 32]);
let victim_balance = 5_000_u128;
// genesis sets program_owner = authenticated_transfer_program.id() on all accounts.
let mut state = V03State::new_with_genesis_accounts(
&[(victim_id, victim_balance), (recipient_id, 0)],
vec![],
0,
);
state.insert_program(Program::malicious_injector());
state.insert_program(Program::malicious_launderer());
// Build attacker's private account and its local commitment tree.
let attacker_account = Account {
program_owner: Program::authenticated_transfer_program().id(),
balance: 100,
..Account::default()
};
let attacker_commitment = Commitment::new(&attacker_id, &attacker_account);
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&attacker_commitment));
let membership_proof = commitment_set
.get_proof_for(&attacker_commitment)
.expect("attacker commitment must be in the set");
let attacker_pre = AccountWithMetadata::new(attacker_account, true, attacker_id);
let victim_account = state.get_account_by_id(victim_id);
let instruction: InjectorInstruction = (
Program::malicious_launderer().id(),
Program::authenticated_transfer_program().id(),
*victim_id.value(),
victim_account.balance,
victim_account.nonce.0,
victim_account.program_owner,
*recipient_id.value(),
victim_balance,
);
let instruction_data = Program::serialize_instruction(instruction).unwrap();
let p2 = Program::malicious_launderer();
let at = Program::authenticated_transfer_program();
let program_with_deps = ProgramWithDependencies::new(
Program::malicious_injector(),
[(p2.id(), p2), (at.id(), at)].into(),
);
// account_identities order must match self.pre_states as built by the circuit:
// [0] attacker — first seen in P1's program_output.pre_states
// [1] victim — first seen in authenticated_transfer's program_output.pre_states
// [2] recipient — first seen in authenticated_transfer's program_output.pre_states
let account_identities = vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: attacker_ssk,
nsk: attacker_keys.nsk,
membership_proof,
identifier: 0,
},
InputAccountIdentity::Public, // victim
InputAccountIdentity::Public, // recipient
];
// execute_and_prove succeeds: all inner receipts are valid.
// The outer circuit commits victim(is_authorized=true) to its journal.
let (circuit_output, proof) = execute_and_prove(
vec![attacker_pre],
instruction_data,
account_identities,
&program_with_deps,
)
.expect("execute_and_prove should succeed \u{2014} the programs execute correctly");
// public_account_ids lists the Public entries from account_identities, in order.
// The single ciphertext belongs to attacker's private account update.
let message = Message::try_from_circuit_output(
vec![victim_id, recipient_id],
vec![], // no public signers, no nonces
vec![(attacker_keys.npk(), attacker_keys.vpk(), attacker_epk)],
circuit_output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]); // no signatures
let tx = PrivacyPreservingTransaction::new(message, witness_set);
let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0);
assert!(
matches!(result, Err(NssaError::InvalidPrivacyPreservingProof)),
"attack privacy transaction should be rejected with InvalidPrivacyPreservingProof"
);
assert_eq!(state.get_account_by_id(victim_id).balance, victim_balance);
assert_eq!(state.get_account_by_id(recipient_id).balance, 0);
}
/// Private-victim variant of the authorization-injection attack. The test passes when the
/// attack is rejected and the recipient's balance remains zero.
///
/// After the circuit's Vacant branch accepts the injected `victim(is_authorized=true)`
/// verbatim, the attacker must choose how to declare the victim in `account_identities`.
/// There are two routes, both closed:
///
/// - **mask=1 (`PrivateAuthorizedUpdate`)**: the circuit derives `account_id =
/// AccountId::for_regular_private_account(&npk_from(nsk), identifier)` and asserts it matches
/// `pre_state.account_id`. Passing this check requires the victim's `nsk`, which the attacker
/// does not have. `execute_and_prove` panics inside the ZKVM and no proof is produced.
///
/// - **mask=0 (`Public`)**: the circuit places the account in `public_pre_states` and
/// `execute_and_prove` succeeds. The host-side validator then reconstructs
/// `public_pre_states` from chain state; `state.get_account_by_id(victim_id)` returns the
/// default account (balance=0) because the victim has no public state entry. The committed
/// journal and the reconstructed expected output diverge, `receipt.verify` fails, and
/// `from_privacy_preserving_transaction` returns an error before any state is applied. This
/// test exercises this route.
#[test]
fn privacy_malicious_programs_cannot_drain_private_victim() {
use nssa_core::{
Commitment, InputAccountIdentity, SharedSecretKey,
account::{Account, AccountWithMetadata},
encryption::EphemeralPublicKey,
};
use crate::{
PrivacyPreservingTransaction,
privacy_preserving_transaction::{
circuit::{ProgramWithDependencies, execute_and_prove},
message::Message,
witness_set::WitnessSet,
},
state::{
CommitmentSet,
tests::{test_private_account_keys_1, test_private_account_keys_2},
},
};
type InjectorInstruction = (
nssa_core::program::ProgramId, // p2_id
nssa_core::program::ProgramId, // auth_transfer_id
[u8; 32], // victim_id_raw
u128, // victim_balance
u128, // victim_nonce
nssa_core::program::ProgramId, // victim_program_owner
[u8; 32], // recipient_id_raw
u128, // amount
);
// Attacker controls a private account.
let attacker_keys = test_private_account_keys_1();
let attacker_id = AccountId::for_regular_private_account(&attacker_keys.npk(), 0);
let attacker_esk = [12_u8; 32];
let attacker_ssk = SharedSecretKey::new(attacker_esk, &attacker_keys.vpk());
let attacker_epk = EphemeralPublicKey::from_scalar(attacker_esk);
// Victim is a private account — not registered in public chain state.
let victim_keys = test_private_account_keys_2();
let victim_id = AccountId::for_regular_private_account(&victim_keys.npk(), 0);
let victim_balance = 5_000_u128;
let recipient_id = AccountId::new([42_u8; 32]);
// Victim has no public state entry; only recipient is registered at genesis.
let mut state = V03State::new_with_genesis_accounts(&[(recipient_id, 0)], vec![], 0);
state.insert_program(Program::malicious_injector());
state.insert_program(Program::malicious_launderer());
// Build attacker's private account and its local commitment tree.
let attacker_account = Account {
program_owner: Program::authenticated_transfer_program().id(),
balance: 100,
..Account::default()
};
let attacker_commitment = Commitment::new(&attacker_id, &attacker_account);
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&attacker_commitment));
let membership_proof = commitment_set
.get_proof_for(&attacker_commitment)
.expect("attacker commitment must be in the set");
let attacker_pre = AccountWithMetadata::new(attacker_account, true, attacker_id);
// The attacker supplies the victim's account data directly — it cannot be read from
// public state. The injected balance and program_owner allow authenticated_transfer
// to succeed inside the circuit, which has no access to chain state and cannot detect
// that these values are fabricated.
let instruction: InjectorInstruction = (
Program::malicious_launderer().id(),
Program::authenticated_transfer_program().id(),
*victim_id.value(),
victim_balance,
0_u128, // nonce
Program::authenticated_transfer_program().id(), // program_owner
*recipient_id.value(),
victim_balance,
);
let instruction_data = Program::serialize_instruction(instruction).unwrap();
let p2 = Program::malicious_launderer();
let at = Program::authenticated_transfer_program();
let program_with_deps = ProgramWithDependencies::new(
Program::malicious_injector(),
[(p2.id(), p2), (at.id(), at)].into(),
);
// account_identities order must match self.pre_states as built by the circuit:
// [0] attacker — first seen in P1's program_output.pre_states
// [1] victim — first seen in authenticated_transfer's program_output.pre_states
// [2] recipient — first seen in authenticated_transfer's program_output.pre_states
//
// Victim is marked Public: the attacker has no nsk for the victim's private account,
// so PrivateAuthorizedUpdate is not an option.
let account_identities = vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: attacker_ssk,
nsk: attacker_keys.nsk,
membership_proof,
identifier: 0,
},
InputAccountIdentity::Public, // victim — attacker lacks victim's nsk
InputAccountIdentity::Public, // recipient
];
// execute_and_prove succeeds: authenticated_transfer runs against the injected
// victim(balance=5000, is_authorized=true) and produces valid inner receipts.
// The outer circuit commits victim(is_authorized=true) to public_pre_states.
let (circuit_output, proof) = execute_and_prove(
vec![attacker_pre],
instruction_data,
account_identities,
&program_with_deps,
)
.expect("execute_and_prove should succeed \u{2014} the programs execute correctly");
// public_account_ids lists the Public entries from account_identities, in order.
// The single ciphertext belongs to attacker's private account update.
let message = Message::try_from_circuit_output(
vec![victim_id, recipient_id],
vec![], // no public signers, no nonces
vec![(attacker_keys.npk(), attacker_keys.vpk(), attacker_epk)],
circuit_output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]); // no signatures
let tx = PrivacyPreservingTransaction::new(message, witness_set);
let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0);
assert!(
matches!(result, Err(NssaError::InvalidPrivacyPreservingProof)),
"attack on private victim should be rejected with InvalidPrivacyPreservingProof"
);
// Victim has no public balance to check; confirming the recipient received nothing
// is sufficient to show no funds moved.
assert_eq!(state.get_account_by_id(recipient_id).balance, 0);
}
/// Two malicious programs (injector + launderer) attempt to drain a victim's balance
/// without the victim signing anything. The test passes when the attack is rejected
/// and the victim's balance is left untouched.
///
/// Attack flow:
/// Transaction (attacker signs) → P1 (`malicious_injector`)
/// → injects `victim(is_authorized=true)` into chained-call `pre_states` for P2
/// P2 (`malicious_launderer`)
/// → outputs empty pre/post states, forwarding the forged flag to `authenticated_transfer`
/// → if `authorized_accounts` were built from the injected `pre_states`,
/// `{victim}.contains(victim)` would pass and the transfer would execute.
///
/// The validator must reject this: `authorized_accounts` must be derived from the
/// parent program's own validated `program_output.pre_states`, not from the chained-call
/// input, so a forged `is_authorized=true` flag is never trusted.
#[test]
fn malicious_programs_cannot_drain_victim_without_signature() {
// p2_id, auth_transfer_id, victim_id_raw, victim_balance, victim_nonce,
// victim_program_owner, recipient_id_raw, amount.
// Primitives only — AccountId/Account cannot round-trip through instruction_data
// via risc0_zkvm::serde (SerializeDisplay issue).
type InjectorInstruction = (
nssa_core::program::ProgramId, // p2_id
nssa_core::program::ProgramId, // auth_transfer_id
[u8; 32], // victim_id_raw
u128, // victim_balance
u128, // victim_nonce
nssa_core::program::ProgramId, // victim_program_owner
[u8; 32], // recipient_id_raw
u128, // amount
);
let attacker_key = PrivateKey::try_new([10; 32]).unwrap();
let attacker_id = AccountId::from(&PublicKey::new_from_private_key(&attacker_key));
let victim_key = PrivateKey::try_new([20; 32]).unwrap();
let victim_id = AccountId::from(&PublicKey::new_from_private_key(&victim_key));
let recipient_id = AccountId::new([42; 32]);
let victim_balance = 5_000_u128;
let mut state = V03State::new_with_genesis_accounts(
&[
(attacker_id, 100),
(victim_id, victim_balance),
(recipient_id, 0),
],
vec![],
0,
);
state.insert_program(Program::malicious_injector());
state.insert_program(Program::malicious_launderer());
// Read victim state from chain, exactly as the attacker would.
let victim_account = state.get_account_by_id(victim_id);
let instruction: InjectorInstruction = (
Program::malicious_launderer().id(),
Program::authenticated_transfer_program().id(),
*victim_id.value(),
victim_account.balance,
victim_account.nonce.0,
victim_account.program_owner,
*recipient_id.value(),
victim_balance,
);
let message = Message::try_new(
Program::malicious_injector().id(),
vec![attacker_id],
vec![Nonce(0)],
instruction,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, &[&attacker_key]);
let tx = crate::PublicTransaction::new(message, witness_set);
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0);
assert!(
matches!(
result,
Err(NssaError::InvalidProgramBehavior(
InvalidProgramBehaviorError::InvalidAccountAuthorization { account_id }
)) if account_id == victim_id
),
"attack transaction should be rejected with InvalidAccountAuthorization for the victim"
);
// Confirm the victim's balance is untouched.
let victim_balance_after = state.get_account_by_id(victim_id).balance;
let recipient_balance_after = state.get_account_by_id(recipient_id).balance;
assert_eq!(
victim_balance_after, victim_balance,
"victim balance should be unchanged"
);
assert_eq!(
recipient_balance_after, 0,
"recipient should receive nothing"
);
}
}

View File

@ -1,5 +1,5 @@
use std::{
collections::{HashMap, VecDeque, hash_map::Entry},
collections::{HashMap, HashSet, VecDeque, hash_map::Entry},
convert::Infallible,
};
@ -49,6 +49,7 @@ pub struct ExecutionState {
/// caller-seeds authorization paths to verify
/// `AccountId::for_private_pda(program_id, seed, npk, identifier) == pre_state.account_id`.
private_pda_npk_by_position: HashMap<usize, (NullifierPublicKey, Identifier)>,
authorized_accounts: HashSet<AccountId>,
}
impl ExecutionState {
@ -107,6 +108,7 @@ impl ExecutionState {
private_pda_bound_positions: HashMap::new(),
pda_family_binding: HashMap::new(),
private_pda_npk_by_position,
authorized_accounts: HashSet::new(),
};
let Some(first_output) = program_outputs.first() else {
@ -246,10 +248,10 @@ impl ExecutionState {
program_id: ProgramId,
caller_program_id: Option<ProgramId>,
caller_pda_seeds: &[PdaSeed],
pre_states: Vec<AccountWithMetadata>,
post_states: Vec<AccountPostState>,
output_pre_states: Vec<AccountWithMetadata>,
output_post_states: Vec<AccountPostState>,
) {
for (pre, mut post) in pre_states.into_iter().zip(post_states) {
for (pre, mut post) in output_pre_states.into_iter().zip(output_post_states) {
let pre_account_id = pre.account_id;
let pre_is_authorized = pre.is_authorized;
let post_states_entry = self.post_states.entry(pre.account_id);
@ -288,6 +290,7 @@ impl ExecutionState {
&mut self.pda_family_binding,
&mut self.private_pda_bound_positions,
&self.private_pda_npk_by_position,
&mut self.authorized_accounts,
pre_account_id,
pre_state_position,
caller_program_id,
@ -491,6 +494,7 @@ fn resolve_authorization_and_record_bindings(
pda_family_binding: &mut HashMap<(ProgramId, PdaSeed), AccountId>,
private_pda_bound_positions: &mut HashMap<usize, (ProgramId, PdaSeed)>,
private_pda_npk_by_position: &HashMap<usize, (NullifierPublicKey, Identifier)>,
authorized_accounts: &mut HashSet<AccountId>,
pre_account_id: AccountId,
pre_state_position: usize,
caller_program_id: Option<ProgramId>,
@ -525,5 +529,13 @@ fn resolve_authorization_and_record_bindings(
}
}
previous_is_authorized || matched_caller_seed.is_some()
if authorized_accounts.contains(&pre_account_id) {
return true;
}
let authorized = previous_is_authorized || matched_caller_seed.is_some();
if authorized {
authorized_accounts.insert(pre_account_id);
}
authorized
}

View File

@ -40,7 +40,7 @@ fn main() {
} => {
let [sender, recipient_vault] = pre_states
.try_into()
.expect("Transfer requires exactly 3 accounts");
.expect("Transfer requires exactly 2 accounts");
let seed = vault_core::compute_vault_seed(recipient_id);

34
test_fixtures/Cargo.toml Normal file
View File

@ -0,0 +1,34 @@
[package]
name = "test_fixtures"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
publish = false
[lints]
workspace = true
[dependencies]
common.workspace = true
indexer_service.workspace = true
key_protocol.workspace = true
nssa.workspace = true
nssa_core = { workspace = true, features = ["host"] }
sequencer_core = { workspace = true, features = ["default", "testnet"] }
sequencer_service.workspace = true
sequencer_service_rpc = { workspace = true, features = ["client"] }
vault_core.workspace = true
wallet.workspace = true
anyhow.workspace = true
bytesize.workspace = true
env_logger.workspace = true
futures.workspace = true
jsonrpsee = { workspace = true, features = ["ws-client"] }
log.workspace = true
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
testcontainers = { version = "0.27.3", features = ["docker-compose"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
url.workspace = true

496
test_fixtures/src/lib.rs Normal file
View File

@ -0,0 +1,496 @@
//! Shared test/bench fixtures: spins up bedrock + sequencer + indexer + wallet
//! end-to-end against docker-compose, exposes a `TestContext` callers can drive.
use std::{net::SocketAddr, path::Path, sync::LazyLock};
use anyhow::{Context as _, Result};
use common::{HashType, transaction::NSSATransaction};
use futures::FutureExt as _;
use indexer_service::IndexerHandle;
use log::{debug, error};
use nssa::{AccountId, PrivacyPreservingTransaction};
use nssa_core::Commitment;
use sequencer_core::config::GenesisAction;
use sequencer_service::SequencerHandle;
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use serde::Serialize;
use tempfile::TempDir;
use testcontainers::compose::DockerCompose;
use wallet::{WalletCore, account::AccountIdWithPrivacy, cli::CliAccountMention};
use crate::{
indexer_client::IndexerClient,
setup::{
setup_bedrock_node, setup_indexer, setup_private_accounts_with_initial_supply,
setup_public_accounts_with_initial_supply, setup_sequencer, setup_wallet,
},
};
pub mod config;
pub mod indexer_client;
pub mod setup;
// TODO: Remove this and control time from tests
pub const TIME_TO_WAIT_FOR_BLOCK_SECONDS: u64 = 12;
pub const NSSA_PROGRAM_FOR_TEST_DATA_CHANGER: &str = "data_changer.bin";
pub const NSSA_PROGRAM_FOR_TEST_NOOP: &str = "noop.bin";
pub const NSSA_PROGRAM_FOR_TEST_PDA_FUND_SPEND_PROXY: &str = "pda_fund_spend_proxy.bin";
pub(crate) const BEDROCK_SERVICE_WITH_OPEN_PORT: &str = "logos-blockchain-node-0";
pub(crate) const BEDROCK_SERVICE_PORT: u16 = 18080;
static LOGGER: LazyLock<()> = LazyLock::new(env_logger::init);
struct IndexerComponents {
indexer_handle: IndexerHandle,
indexer_client: IndexerClient,
temp_dir: TempDir,
}
impl Drop for IndexerComponents {
fn drop(&mut self) {
let Self {
indexer_handle,
indexer_client: _,
temp_dir: _,
} = self;
if !indexer_handle.is_healthy() {
error!("Indexer handle has unexpectedly stopped before IndexerComponents drop");
}
}
}
/// Recursively-sized bytes on disk for sequencer / indexer / wallet tempdirs.
#[derive(Debug, Clone, Copy, Default, Serialize)]
pub struct DiskSizes {
pub sequencer_bytes: u64,
pub indexer_bytes: u64,
pub wallet_bytes: u64,
}
/// Test context which sets up a sequencer and a wallet for integration tests.
///
/// It's memory and logically safe to create multiple instances of this struct in parallel tests,
/// as each instance uses its own temporary directories for sequencer and wallet data.
// NOTE: Order of fields is important for proper drop order.
pub struct TestContext {
sequencer_client: SequencerClient,
wallet: WalletCore,
wallet_password: String,
/// Optional to move out value in Drop.
sequencer_handle: Option<SequencerHandle>,
indexer_components: Option<IndexerComponents>,
bedrock_compose: DockerCompose,
bedrock_addr: SocketAddr,
temp_sequencer_dir: TempDir,
temp_wallet_dir: TempDir,
}
impl TestContext {
/// Create new test context.
pub async fn new() -> Result<Self> {
Self::builder().build().await
}
/// Get a builder for the test context to customize its configuration.
#[must_use]
pub const fn builder() -> TestContextBuilder {
TestContextBuilder::new()
}
/// Get reference to the wallet.
#[must_use]
pub const fn wallet(&self) -> &WalletCore {
&self.wallet
}
#[must_use]
pub fn wallet_password(&self) -> &str {
&self.wallet_password
}
/// Get mutable reference to the wallet.
pub const fn wallet_mut(&mut self) -> &mut WalletCore {
&mut self.wallet
}
/// Get reference to the sequencer client.
#[must_use]
pub const fn sequencer_client(&self) -> &SequencerClient {
&self.sequencer_client
}
/// Get the Bedrock Node address.
#[must_use]
pub const fn bedrock_addr(&self) -> SocketAddr {
self.bedrock_addr
}
/// Get reference to the indexer.
///
/// # Panics
///
/// Panics if the indexer is not enabled in the test context. See
/// [`TestContextBuilder::disable_indexer()`].
#[must_use]
pub fn indexer(&self) -> &IndexerHandle {
self.indexer_components
.as_ref()
.map(|components| &components.indexer_handle)
.expect("Called `TestContext::indexer()` on context with disabled indexer")
}
/// Get the indexer's bound socket address.
///
/// # Panics
///
/// Panics if the indexer is not enabled in the test context.
#[must_use]
pub fn indexer_addr(&self) -> SocketAddr {
self.indexer().addr()
}
/// Get reference to the indexer client.
///
/// # Panics
///
/// Panics if the indexer is not enabled in the test context. See
/// [`TestContextBuilder::disable_indexer()`].
#[must_use]
pub fn indexer_client(&self) -> &IndexerClient {
self.indexer_components
.as_ref()
.map(|components| &components.indexer_client)
.expect("Called `TestContext::indexer_client()` on context with disabled indexer")
}
/// Recursively-sized bytes on disk for sequencer + indexer + wallet tempdirs.
/// Indexer bytes are zero if the indexer is disabled.
#[must_use]
pub fn disk_sizes(&self) -> DiskSizes {
DiskSizes {
sequencer_bytes: dir_size_bytes(self.temp_sequencer_dir.path()),
indexer_bytes: self
.indexer_components
.as_ref()
.map_or(0, |c| dir_size_bytes(c.temp_dir.path())),
wallet_bytes: dir_size_bytes(self.temp_wallet_dir.path()),
}
}
/// Get existing public account IDs in the wallet.
#[must_use]
pub fn existing_public_accounts(&self) -> Vec<AccountId> {
self.wallet
.storage()
.key_chain()
.public_account_ids()
.map(|(account_id, _idx)| account_id)
.collect()
}
/// Get existing private account IDs in the wallet.
#[must_use]
pub fn existing_private_accounts(&self) -> Vec<AccountId> {
self.wallet
.storage()
.key_chain()
.private_account_ids()
.map(|(account_id, _idx)| account_id)
.collect()
}
}
impl Drop for TestContext {
fn drop(&mut self) {
let Self {
sequencer_handle,
bedrock_compose,
bedrock_addr: _,
indexer_components: _,
sequencer_client: _,
wallet: _,
wallet_password: _,
temp_sequencer_dir: _,
temp_wallet_dir: _,
} = self;
let sequencer_handle = sequencer_handle
.take()
.expect("Sequencer handle should be present in TestContext drop");
if !sequencer_handle.is_healthy() {
let Err(err) = sequencer_handle
.failed()
.now_or_never()
.expect("Sequencer handle should not be running");
error!(
"Sequencer handle has unexpectedly stopped before TestContext drop with error: {err:#}"
);
}
let container = bedrock_compose
.service(BEDROCK_SERVICE_WITH_OPEN_PORT)
.unwrap_or_else(|| {
panic!("Failed to get Bedrock service container `{BEDROCK_SERVICE_WITH_OPEN_PORT}`")
});
let output = std::process::Command::new("docker")
.args(["inspect", "-f", "{{.State.Running}}", container.id()])
.output()
.expect("Failed to execute docker inspect command to check if Bedrock container is still running");
let stdout = String::from_utf8(output.stdout)
.expect("Failed to parse docker inspect output as String");
if stdout.trim() != "true" {
error!(
"Bedrock container `{}` is not running during TestContext drop, docker inspect output: {stdout}",
container.id()
);
}
}
}
pub struct TestContextBuilder {
genesis_transactions: Option<Vec<GenesisAction>>,
sequencer_partial_config: Option<config::SequencerPartialConfig>,
enable_indexer: bool,
}
impl TestContextBuilder {
const fn new() -> Self {
Self {
genesis_transactions: None,
sequencer_partial_config: None,
enable_indexer: true,
}
}
#[must_use]
pub fn with_genesis(mut self, genesis_transactions: Vec<GenesisAction>) -> Self {
self.genesis_transactions = Some(genesis_transactions);
self
}
#[must_use]
pub const fn with_sequencer_partial_config(
mut self,
sequencer_partial_config: config::SequencerPartialConfig,
) -> Self {
self.sequencer_partial_config = Some(sequencer_partial_config);
self
}
/// Exclude Indexer from test context.
/// Indexer is enabled by default.
///
/// Methods like [`TestContext::indexer()`] and [`TestContext::indexer_client()`] will panic if
/// called when indexer is disabled.
#[must_use]
pub const fn disable_indexer(mut self) -> Self {
self.enable_indexer = false;
self
}
pub async fn build(self) -> Result<TestContext> {
let Self {
genesis_transactions,
sequencer_partial_config,
enable_indexer,
} = self;
// Ensure logger is initialized only once
*LOGGER;
debug!("Test context setup");
let (bedrock_compose, bedrock_addr) = setup_bedrock_node()
.await
.context("Failed to setup Bedrock node")?;
let indexer_components = if enable_indexer {
let (indexer_handle, temp_indexer_dir) = setup_indexer(bedrock_addr)
.await
.context("Failed to setup Indexer")?;
let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_handle.addr())
.context("Failed to convert indexer addr to URL")?;
let indexer_client = IndexerClient::new(&indexer_url)
.await
.context("Failed to create indexer client")?;
Some(IndexerComponents {
indexer_handle,
indexer_client,
temp_dir: temp_indexer_dir,
})
} else {
None
};
let initial_public_accounts = config::default_public_accounts_for_wallet();
let initial_private_accounts = config::default_private_accounts_for_wallet();
let (sequencer_handle, temp_sequencer_dir) = setup_sequencer(
sequencer_partial_config.unwrap_or_default(),
bedrock_addr,
genesis_transactions.unwrap_or_else(|| {
config::genesis_from_accounts(&initial_public_accounts, &initial_private_accounts)
}),
)
.await
.context("Failed to setup Sequencer")?;
let (mut wallet, temp_wallet_dir, wallet_password) = setup_wallet(
sequencer_handle.addr(),
&initial_public_accounts,
&initial_private_accounts,
)
.context("Failed to setup wallet")?;
setup_public_accounts_with_initial_supply(&wallet, &initial_public_accounts)
.await
.context("Failed to initialize public accounts in wallet")?;
setup_private_accounts_with_initial_supply(&mut wallet, &initial_private_accounts)
.await
.context("Failed to initialize private accounts in wallet")?;
let sequencer_url = config::addr_to_url(config::UrlProtocol::Http, sequencer_handle.addr())
.context("Failed to convert sequencer addr to URL")?;
let sequencer_client = SequencerClientBuilder::default()
.build(sequencer_url)
.context("Failed to create sequencer client")?;
Ok(TestContext {
sequencer_client,
wallet,
wallet_password,
bedrock_compose,
bedrock_addr,
sequencer_handle: Some(sequencer_handle),
indexer_components,
temp_sequencer_dir,
temp_wallet_dir,
})
}
pub fn build_blocking(self) -> Result<BlockingTestContext> {
let runtime = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?;
let ctx = runtime.block_on(self.build())?;
Ok(BlockingTestContext {
ctx: Some(ctx),
runtime,
})
}
}
/// A test context to be used in normal #[test] tests.
pub struct BlockingTestContext {
ctx: Option<TestContext>,
runtime: tokio::runtime::Runtime,
}
impl BlockingTestContext {
pub fn new() -> Result<Self> {
TestContext::builder().build_blocking()
}
pub const fn ctx(&self) -> &TestContext {
self.ctx.as_ref().expect("TestContext is set")
}
pub const fn runtime(&self) -> &tokio::runtime::Runtime {
&self.runtime
}
pub fn block_on<'ctx, F>(&'ctx self, f: impl FnOnce(&'ctx TestContext) -> F) -> F::Output
where
F: std::future::Future + 'ctx,
{
let future = f(self.ctx());
self.runtime.block_on(future)
}
pub fn block_on_mut<'ctx, F>(
&'ctx mut self,
f: impl FnOnce(&'ctx mut TestContext) -> F,
) -> F::Output
where
F: std::future::Future + 'ctx,
{
let ctx_mut = self.ctx.as_mut().expect("TestContext is set");
let future = f(ctx_mut);
self.runtime.block_on(future)
}
}
impl Drop for BlockingTestContext {
fn drop(&mut self) {
let Self { ctx, runtime } = self;
// Ensure async cleanup of TestContext by blocking on its drop in the runtime.
runtime.block_on(async {
if let Some(ctx) = ctx.take() {
drop(ctx);
}
});
}
}
#[must_use]
pub const fn public_mention(account_id: AccountId) -> CliAccountMention {
CliAccountMention::Id(AccountIdWithPrivacy::Public(account_id))
}
#[must_use]
pub const fn private_mention(account_id: AccountId) -> CliAccountMention {
CliAccountMention::Id(AccountIdWithPrivacy::Private(account_id))
}
#[expect(
clippy::wildcard_enum_match_arm,
reason = "We want the code to panic if the transaction type is not PrivacyPreserving"
)]
pub async fn fetch_privacy_preserving_tx(
seq_client: &SequencerClient,
tx_hash: HashType,
) -> PrivacyPreservingTransaction {
let tx = seq_client.get_transaction(tx_hash).await.unwrap().unwrap();
match tx {
NSSATransaction::PrivacyPreserving(privacy_preserving_transaction) => {
privacy_preserving_transaction
}
_ => panic!("Invalid tx type"),
}
}
pub async fn verify_commitment_is_in_state(
commitment: Commitment,
seq_client: &SequencerClient,
) -> bool {
seq_client
.get_proof_for_commitment(commitment)
.await
.ok()
.flatten()
.is_some()
}
fn dir_size_bytes(path: &Path) -> u64 {
let mut total = 0_u64;
let Ok(entries) = std::fs::read_dir(path) else {
return 0;
};
for entry in entries.flatten() {
let Ok(metadata) = entry.metadata() else {
continue;
};
if metadata.is_file() {
total = total.saturating_add(metadata.len());
} else if metadata.is_dir() {
total = total.saturating_add(dir_size_bytes(&entry.path()));
} else {
// Sockets, FIFOs, block/char devices: ignore. Symlinks are
// already followed by `is_file()` / `is_dir()`.
}
}
total
}

View File

@ -11,6 +11,7 @@ workspace = true
nssa_core.workspace = true
authenticated_transfer_core.workspace = true
clock_core.workspace = true
faucet_core.workspace = true
risc0-zkvm.workspace = true
serde = { workspace = true, default-features = false }

View File

@ -0,0 +1,52 @@
use nssa_core::{
account::AccountId,
program::{
AccountPostState, ChainedCall, ProgramId, ProgramInput, ProgramOutput, read_nssa_inputs,
},
};
use risc0_zkvm::serde::to_vec;
type Instruction = (ProgramId, ProgramId, AccountId, u128);
// (faucet_program_id, vault_program_id, recipient_id, amount)
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
instruction: (faucet_program_id, vault_program_id, recipient_id, amount),
},
instruction_words,
) = read_nssa_inputs::<Instruction>();
let post_states: Vec<_> = pre_states
.iter()
.map(|pre| AccountPostState::new(pre.account.clone()))
.collect();
assert_eq!(pre_states.len(), 2);
let [faucet_pre, vault_pda_pre] = [pre_states[0].clone(), pre_states[1].clone()];
let chained_calls = vec![ChainedCall {
program_id: faucet_program_id,
instruction_data: to_vec(&faucet_core::Instruction::Transfer {
vault_program_id,
recipient_id,
amount,
})
.unwrap(),
pre_states: vec![faucet_pre, vault_pda_pre],
pda_seeds: vec![],
}];
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
pre_states,
post_states,
)
.with_chained_calls(chained_calls)
.write();
}

View File

@ -0,0 +1,105 @@
use nssa_core::{
account::{Account, AccountId, AccountWithMetadata, Data, Nonce},
program::{
AccountPostState, ChainedCall, ProgramId, ProgramInput, ProgramOutput, read_nssa_inputs,
},
};
/// Instruction uses only risc0-serde-compatible primitives — no `AccountId`/`Account` structs,
/// which use `SerializeDisplay`/`DeserializeFromStr` and cannot round-trip through
/// `instruction_data`.
///
/// Fields:
/// `p2_id`: program ID of the launderer (P2)
/// `auth_transfer_id`: program ID of `authenticated_transfer`, forwarded to P2
/// `victim_id_raw`: raw `[u8; 32]` of the victim `AccountId`
/// `victim_balance`: victim's current balance
/// `victim_nonce`: victim's current nonce (inner `u128`)
/// `victim_program_owner`: victim account's `program_owner` field
/// `recipient_id_raw`: raw `[u8; 32]` of the recipient `AccountId`
/// `amount`: balance to transfer out of the victim.
type Instruction = (
ProgramId,
ProgramId,
[u8; 32],
u128,
u128,
ProgramId,
[u8; 32],
u128,
);
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
instruction:
(
p2_id,
auth_transfer_id,
victim_id_raw,
victim_balance,
victim_nonce,
victim_program_owner,
recipient_id_raw,
amount,
),
},
instruction_words,
) = read_nssa_inputs::<Instruction>();
// Echo own pre_states (attacker's account) unchanged.
let post_states = pre_states
.iter()
.map(|p| AccountPostState::new(p.account.clone()))
.collect();
// Construct victim AccountWithMetadata from primitives, stamping is_authorized=true.
// Victim has not signed anything — this flag is forged entirely by P1's logic.
let victim = AccountWithMetadata {
account: Account {
program_owner: victim_program_owner,
balance: victim_balance,
data: Data::default(),
nonce: Nonce(victim_nonce),
},
is_authorized: true,
account_id: AccountId::new(victim_id_raw),
};
// Recipient is already initialized under authenticated_transfer (program_owner =
// auth_transfer_id, balance = 0). Using the default account would trigger
// Claim::Authorized inside authenticated_transfer, which requires is_authorized=true
// on the recipient — a check that would block the transfer.
let recipient = AccountWithMetadata {
account: Account {
program_owner: auth_transfer_id,
balance: 0,
data: Data::default(),
nonce: Nonce(0),
},
is_authorized: false,
account_id: AccountId::new(recipient_id_raw),
};
// Forward auth_transfer_id and amount to P2 so it can call authenticated_transfer.
let p2_instruction = risc0_zkvm::serde::to_vec(&(auth_transfer_id, amount))
.expect("serialization is infallible");
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
pre_states,
post_states,
)
.with_chained_calls(vec![ChainedCall {
program_id: p2_id,
pre_states: vec![victim, recipient],
instruction_data: p2_instruction,
pda_seeds: vec![],
}])
.write();
}

View File

@ -0,0 +1,43 @@
use nssa_core::program::{ChainedCall, ProgramId, ProgramInput, ProgramOutput, read_nssa_inputs};
/// Instruction: (`auth_transfer_id`, `amount`) — both primitive, safe for `risc0_zkvm::serde`.
type Instruction = (ProgramId, u128);
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
instruction: (auth_transfer_id, amount),
},
instruction_words,
) = read_nssa_inputs::<Instruction>();
// Output empty pre/post states. P2 processes no accounts itself, so the
// authorization check at validated_state_diff.rs:158-182 runs over nothing.
// Victim is never compared against caller_data.authorized_accounts = {attacker}.
//
// The bug: authorized_accounts for authenticated_transfer is built from
// chained_call.pre_states (this call's inputs, set by P1), which contains
// victim(is_authorized=true). So authorized_accounts = {victim}, and the
// subsequent check passes.
let auth_transfer_instruction =
risc0_zkvm::serde::to_vec(&authenticated_transfer_core::Instruction::Transfer { amount })
.expect("serialization is infallible");
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
vec![],
vec![],
)
.with_chained_calls(vec![ChainedCall {
program_id: auth_transfer_id,
pre_states,
instruction_data: auth_transfer_instruction,
pda_seeds: vec![],
}])
.write();
}

View File

@ -0,0 +1,18 @@
[package]
name = "crypto_primitives_bench"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
publish = false
[lints]
workspace = true
[dependencies]
key_protocol.workspace = true
nssa_core = { workspace = true, features = ["host"] }
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
rand = { workspace = true }

View File

@ -0,0 +1,20 @@
# crypto_primitives_bench
Cryptographic primitive microbenchmarks used by client/wallet code. Single host binary, no live sequencer or Bedrock needed.
## Run
```sh
cargo run --release -p crypto_primitives_bench
```
## What you'll see
Per-operation `best_us`, `mean_us`, and `stdev_us` over 100 iterations (plus 2 warmup):
- `KeyChain::new_os_random` — full mnemonic → SSK → NSK/VSK + public-key derivation (HMAC-SHA512 PBKDF dominates).
- `KeyChain::new_mnemonic` — same pipeline, mnemonic exposed.
- `SharedSecretKey::new (sender DH)` — secp256k1 ECDH per recipient.
- `EncryptionScheme::encrypt` / `decrypt` — ChaCha20 over an Account note.
JSON output is written to `target/crypto_primitives_bench.json`.

View File

@ -0,0 +1,175 @@
//! Cryptographic primitive microbenchmarks used by client/wallet code.
//!
//! Measures:
//! - `KeyChain::new_os_random` (mnemonic → SSK → NSK/VSK + public keys)
//! - `KeyChain::new_mnemonic` (same, but mnemonic exposed)
//! - `SharedSecretKey::new` (Diffie-Hellman shared key derivation, the per-recipient cost)
//! - `EncryptionScheme::encrypt` / `decrypt` (Account note encryption)
//!
//! Reports best-of-N wall time per operation. No live stack required.
#![expect(
clippy::arithmetic_side_effects,
clippy::as_conversions,
clippy::cast_precision_loss,
clippy::float_arithmetic,
clippy::print_stdout,
reason = "Bench tool"
)]
use std::{path::PathBuf, time::Instant};
use anyhow::Result;
use key_protocol::key_management::KeyChain;
use nssa_core::{
Commitment, EncryptionScheme, SharedSecretKey,
account::{Account, AccountId},
encryption::{EphemeralPublicKey, EphemeralSecretKey},
program::PrivateAccountKind,
};
use rand::{RngCore as _, rngs::OsRng};
use serde::Serialize;
const ITERS: usize = 100;
#[derive(Debug, Serialize)]
struct OpResult {
op: &'static str,
iters: usize,
best_us: f64,
mean_us: f64,
stdev_us: f64,
}
fn time<F: FnMut()>(op: &'static str, iters: usize, mut f: F) -> OpResult {
// Warmup
for _ in 0..2 {
f();
}
let mut samples_ns: Vec<f64> = Vec::with_capacity(iters);
for _ in 0..iters {
let t = Instant::now();
f();
samples_ns.push(t.elapsed().as_nanos() as f64);
}
let best_ns = samples_ns.iter().copied().fold(f64::INFINITY, f64::min);
let mean_ns: f64 = samples_ns.iter().sum::<f64>() / iters as f64;
let stdev_ns = if iters > 1 {
let var: f64 = samples_ns
.iter()
.map(|s| (s - mean_ns).powi(2))
.sum::<f64>()
/ (iters - 1) as f64;
var.sqrt()
} else {
0.0
};
OpResult {
op,
iters,
best_us: best_ns / 1_000.0,
mean_us: mean_ns / 1_000.0,
stdev_us: stdev_ns / 1_000.0,
}
}
fn main() -> Result<()> {
let mut results: Vec<OpResult> = Vec::new();
results.push(time("KeyChain::new_os_random", ITERS, || {
let _kc = KeyChain::new_os_random();
}));
results.push(time("KeyChain::new_mnemonic", ITERS, || {
let (_kc, _mnemonic) = KeyChain::new_mnemonic("");
}));
// SharedSecretKey: caller has ephemeral secret, recipient has VSK→VPK.
// We bench the SENDER side: derive ephemeral pubkey, then SharedSecretKey::new(scalar, point).
let recipient_kc = KeyChain::new_os_random();
let vpk = recipient_kc.viewing_public_key;
results.push(time("SharedSecretKey::new (sender DH)", ITERS, || {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let esk: EphemeralSecretKey = bytes;
let _epk = EphemeralPublicKey::from(&esk);
let _ssk = SharedSecretKey::new(esk, &vpk);
}));
// EncryptionScheme::encrypt / decrypt over a small Account note.
let account = Account::default();
let account_id = AccountId::new([7; 32]);
let commitment = Commitment::new(&account_id, &account);
let shared = {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let esk: EphemeralSecretKey = bytes;
SharedSecretKey::new(esk, &vpk)
};
let kind = PrivateAccountKind::Regular(0_u128);
let output_index: u32 = 0;
let mut produced_ct = None;
results.push(time("EncryptionScheme::encrypt", ITERS, || {
let ct = EncryptionScheme::encrypt(&account, &kind, &shared, &commitment, output_index);
produced_ct = Some(ct);
}));
let ct = produced_ct.expect("encrypt produced ciphertext");
results.push(time("EncryptionScheme::decrypt", ITERS, || {
let _decoded = EncryptionScheme::decrypt(&ct, &shared, &commitment, output_index);
}));
print_table(&results);
write_json(&results)?;
Ok(())
}
fn print_table(results: &[OpResult]) {
let ow = results
.iter()
.map(|r| r.op.len())
.max()
.unwrap_or(0)
.max("op".len());
let cw = 22_usize;
println!(
"{:<ow$} {:>6} {:>cw$} {:>cw$} {:>cw$}",
"op", "iters", "best_us (ms)", "mean_us (ms)", "stdev_us (ms)",
);
println!("{}", "-".repeat(ow + 6 + cw * 3 + 8));
for r in results {
println!(
"{:<ow$} {:>6} {:>cw$} {:>cw$} {:>cw$}",
r.op,
r.iters,
fmt_us_ms(r.best_us),
fmt_us_ms(r.mean_us),
fmt_us_ms(r.stdev_us),
);
}
}
fn fmt_us_ms(us: f64) -> String {
let ms = us / 1_000.0;
if ms < 0.01 {
format!("{us:.2} (<0.01 ms)")
} else {
format!("{us:.2} ({ms:.2} ms)")
}
}
fn write_json(results: &[OpResult]) -> Result<()> {
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()?;
let out_path = workspace_root
.join("target")
.join("crypto_primitives_bench.json");
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&out_path, serde_json::to_string_pretty(&results)?)?;
println!("\nJSON written to {}", out_path.display());
Ok(())
}

View File

@ -0,0 +1,29 @@
[package]
name = "cycle_bench"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
publish = false
[lints]
workspace = true
[features]
default = []
prove = ["nssa/prove", "risc0-zkvm/prove"]
ppe = ["prove"]
[dependencies]
nssa = { workspace = true }
nssa_core = { workspace = true, features = ["host"] }
clock_core.workspace = true
token_core.workspace = true
amm_core.workspace = true
ata_core.workspace = true
risc0-zkvm.workspace = true
borsh.workspace = true
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
clap = { workspace = true }

View File

@ -0,0 +1,28 @@
# cycle_bench
Per-program Risc0 cycle counts, prover wall time, PPE composition cost, and verifier wall time for the built-in LEZ programs. Feeds the fee model (`G_executor`, `G_prove`, `G_verify`, `S_agg`).
## Run
```sh
# Executor cycles only (fast, ~seconds)
cargo run --release -p cycle_bench
# + real proving per program (slow, ~minutes)
cargo run --release -p cycle_bench --features prove -- --prove
# + PPE composition cases (very slow, ~hour)
cargo run --release -p cycle_bench --features ppe -- --prove --ppe
# + verifier microbench (G_verify): generates one PPE receipt, times verify x1000
cargo run --release -p cycle_bench --features ppe -- --verify --verify-iters 1000
```
`RISC0_DEV_MODE=1` skips proving entirely and is only useful for the executor path. Combine flags freely; output is printed to stdout and written to `target/cycle_bench.json` for regression diffs.
## What you'll see
- Per-program executor cycles and segments, plus exec wall time as `best / mean ± stdev (n=N)`.
- With `--prove`: prover total cycles, paging cycles, segments, and wall time.
- With `--ppe`: end-to-end `execute_and_prove` wall time and S_agg (the borsh-serialized InnerReceipt length) for one auth-transfer-in-PPE case and a chain-caller depth sweep.
- With `--verify`: verify wall time `best / mean ± stdev`, plus `proof_bytes` and `journal_bytes`.

View File

@ -0,0 +1,632 @@
//! Measures Risc0 user cycles per built-in program instruction.
//!
//! Runs each guest ELF through the Risc0 executor (no proving) with realistic inputs
//! drawn from the existing per-program unit tests, then prints a table and writes a
//! JSON dump for regression comparison.
//!
//! Run with `cargo run --release -p cycle_bench`. `RISC0_DEV_MODE` has no effect on
//! executor cycle counts.
#![expect(
clippy::arithmetic_side_effects,
clippy::as_conversions,
clippy::cast_precision_loss,
clippy::float_arithmetic,
clippy::missing_const_for_fn,
clippy::non_ascii_literal,
clippy::print_literal,
clippy::print_stderr,
clippy::print_stdout,
clippy::ref_patterns,
reason = "Bench tool: matches test-style fixture code"
)]
use std::{path::PathBuf, time::Instant};
use amm_core::{PoolDefinition, compute_liquidity_token_pda, compute_pool_pda, compute_vault_pda};
use anyhow::Result;
use ata_core::{compute_ata_seed, get_associated_token_account_id};
use clap::Parser;
use clock_core::{
CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID,
ClockAccountData,
};
use nssa::program_methods::{
AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID,
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, CLOCK_ELF, CLOCK_ID, TOKEN_ELF,
TOKEN_ID,
};
use nssa_core::{
Timestamp,
account::{Account, AccountId, AccountWithMetadata, Data},
program::{InstructionData, ProgramId},
};
use risc0_zkvm::{ExecutorEnv, default_executor, default_prover};
use serde::Serialize;
use stats::Stats;
use token_core::{TokenDefinition, TokenHolding};
mod ppe;
mod stats;
#[derive(Parser, Debug)]
#[command(about = "Per-program executor and (optionally) prover cycle measurements")]
struct Cli {
/// Also run prover.prove for each case and report wall time + cycles. Slow.
#[arg(long)]
prove: bool,
/// Also run privacy-preserving execution circuit (PPE) composition cases:
/// (a) single `auth_transfer` Transfer through `execute_and_prove`, (b) `chain_caller`
/// with depth N=1,3,5,9. Requires --features ppe at build time. Very slow.
#[arg(long)]
ppe: bool,
/// After running --ppe-style proving once for auth_transfer-in-PPE, time
/// `receipt.verify(PRIVACY_PRESERVING_CIRCUIT_ID)` over many iterations.
/// Produces `G_verify` for the fee model. Requires --features ppe.
#[arg(long)]
verify: bool,
/// Iterations for --verify. Default matches the fee-model handoff target.
#[arg(long, default_value_t = 1000)]
verify_iters: usize,
/// Iterations for executor wall-time sampling per case. First iter is
/// discarded as warmup, remaining N feed the stats.
#[arg(long, default_value_t = 5)]
exec_iters: usize,
}
#[derive(Debug, Serialize)]
struct BenchResult {
program: &'static str,
instruction: &'static str,
user_cycles: u64,
segments: usize,
exec_stats: Stats,
/// Stats over prover.prove(env, elf) wall-clock samples. Only populated when --prove is set.
/// Single-sample (n=1) when --prove is on without explicit repetition, since proving is slow.
prove_stats: Option<Stats>,
/// Total cycles (with continuation overhead, paging, po2 padding) from ProveInfo.stats.
prove_total_cycles: Option<u64>,
/// User cycles from ProveInfo.stats (should match executor cycles).
prove_user_cycles: Option<u64>,
/// Paging cycles from ProveInfo.stats.
prove_paging_cycles: Option<u64>,
/// Segments from ProveInfo.stats.
prove_segments: Option<usize>,
}
struct Case {
program: &'static str,
instruction_label: &'static str,
elf: &'static [u8],
self_program_id: ProgramId,
pre_states: Vec<AccountWithMetadata>,
instruction_words: InstructionData,
}
impl Case {
fn new<I: Serialize>(
program: &'static str,
instruction_label: &'static str,
elf: &'static [u8],
self_program_id: ProgramId,
pre_states: Vec<AccountWithMetadata>,
instruction: &I,
) -> Result<Self> {
Ok(Self {
program,
instruction_label,
elf,
self_program_id,
pre_states,
instruction_words: risc0_zkvm::serde::to_vec(instruction)?,
})
}
fn run(self, prove: bool, exec_iters: usize) -> Result<BenchResult> {
let Self {
program,
instruction_label,
elf,
self_program_id,
pre_states,
instruction_words,
} = self;
let caller_program_id: Option<ProgramId> = None;
// One warmup pass discarded, then `exec_iters` samples. The executor has
// large per-call setup overhead (ELF parsing, env init); reporting both
// best-of-N and mean ± stdev shows whether jitter is significant.
let mut samples: Vec<f64> = Vec::with_capacity(exec_iters);
let mut last_info = None;
let total = exec_iters.saturating_add(1).max(2);
for iter in 0..total {
let mut env_builder = ExecutorEnv::builder();
env_builder
.write(&self_program_id)?
.write(&caller_program_id)?
.write(&pre_states)?
.write(&instruction_words)?;
let env = env_builder.build()?;
let started = Instant::now();
let info = default_executor().execute(env, elf)?;
let elapsed_ms = started.elapsed().as_secs_f64() * 1_000.0;
if iter > 0 {
samples.push(elapsed_ms);
}
last_info = Some(info);
}
let info = last_info.expect("at least one iteration");
let exec_stats = Stats::from_samples(&samples);
let mut prove_stats = None;
let mut prove_total_cycles = None;
let mut prove_user_cycles = None;
let mut prove_paging_cycles = None;
let mut prove_segments = None;
if prove {
let mut env_builder = ExecutorEnv::builder();
env_builder
.write(&self_program_id)?
.write(&caller_program_id)?
.write(&pre_states)?
.write(&instruction_words)?;
let env = env_builder.build()?;
let started = Instant::now();
let prove_info = default_prover()
.prove(env, elf)
.map_err(|e| anyhow::anyhow!("prove failed: {e}"))?;
let prove_ms = started.elapsed().as_secs_f64() * 1_000.0;
prove_stats = Some(Stats::from_samples(&[prove_ms]));
prove_total_cycles = Some(prove_info.stats.total_cycles);
prove_user_cycles = Some(prove_info.stats.user_cycles);
prove_paging_cycles = Some(prove_info.stats.paging_cycles);
prove_segments = Some(prove_info.stats.segments);
eprintln!(
" prove({program}/{instruction_label}): {prove_ms:.1} ms ({:.1}s), total_cycles={}, segments={}",
prove_ms / 1_000.0,
prove_info.stats.total_cycles,
prove_info.stats.segments,
);
}
Ok(BenchResult {
program,
instruction: instruction_label,
user_cycles: info.cycles(),
segments: info.segments.len(),
exec_stats,
prove_stats,
prove_total_cycles,
prove_user_cycles,
prove_paging_cycles,
prove_segments,
})
}
}
fn authenticated_transfer_init() -> Vec<AccountWithMetadata> {
vec![AccountWithMetadata {
account: Account::default(),
is_authorized: true,
account_id: AccountId::new([1; 32]),
}]
}
fn authenticated_transfer_transfer() -> Vec<AccountWithMetadata> {
let sender = AccountWithMetadata {
account: Account {
balance: 1_000_000,
..Account::default()
},
is_authorized: true,
account_id: AccountId::new([1; 32]),
};
let recipient = AccountWithMetadata {
account: Account::default(),
is_authorized: false,
account_id: AccountId::new([2; 32]),
};
vec![sender, recipient]
}
fn token_holding(
definition_id: AccountId,
account_id: AccountId,
balance: u128,
is_authorized: bool,
) -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: TOKEN_ID,
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id,
balance,
}),
nonce: 0_u128.into(),
},
is_authorized,
account_id,
}
}
fn token_definition(
account_id: AccountId,
total_supply: u128,
is_authorized: bool,
) -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: TOKEN_ID,
balance: 0,
data: Data::from(&TokenDefinition::Fungible {
name: String::from("test"),
total_supply,
metadata_id: None,
}),
nonce: 0_u128.into(),
},
is_authorized,
account_id,
}
}
fn token_transfer_pre_states() -> Vec<AccountWithMetadata> {
let def = AccountId::new([15; 32]);
let sender = token_holding(def, AccountId::new([17; 32]), 100_000, true);
let recipient = token_holding(def, AccountId::new([42; 32]), 50_000, true);
vec![sender, recipient]
}
fn token_mint_pre_states() -> Vec<AccountWithMetadata> {
let def_id = AccountId::new([15; 32]);
let def = token_definition(def_id, 100_000, true);
let holding = token_holding(def_id, AccountId::new([17; 32]), 1_000, true);
vec![def, holding]
}
fn token_burn_pre_states() -> Vec<AccountWithMetadata> {
let def_id = AccountId::new([15; 32]);
let def = token_definition(def_id, 100_000, true);
let holding = token_holding(def_id, AccountId::new([17; 32]), 1_000, true);
vec![def, holding]
}
fn clock_account(account_id: AccountId, block_id: u64) -> AccountWithMetadata {
AccountWithMetadata {
account: Account {
program_owner: CLOCK_ID,
balance: 0,
data: ClockAccountData {
block_id,
timestamp: Timestamp::from(0_u64),
}
.to_bytes()
.try_into()
.expect("ClockAccountData should fit in account data"),
nonce: 0_u128.into(),
},
is_authorized: false,
account_id,
}
}
fn clock_pre_states_tick_at(block_id: u64) -> Vec<AccountWithMetadata> {
vec![
clock_account(CLOCK_01_PROGRAM_ACCOUNT_ID, block_id),
clock_account(CLOCK_10_PROGRAM_ACCOUNT_ID, block_id),
clock_account(CLOCK_50_PROGRAM_ACCOUNT_ID, block_id),
]
}
fn amm_token_a_def_id() -> AccountId {
AccountId::new([42; 32])
}
fn amm_token_b_def_id() -> AccountId {
AccountId::new([43; 32])
}
fn amm_pool_id() -> AccountId {
compute_pool_pda(AMM_ID, amm_token_a_def_id(), amm_token_b_def_id())
}
fn amm_vault_a_id() -> AccountId {
compute_vault_pda(AMM_ID, amm_pool_id(), amm_token_a_def_id())
}
fn amm_vault_b_id() -> AccountId {
compute_vault_pda(AMM_ID, amm_pool_id(), amm_token_b_def_id())
}
fn amm_lp_def_id() -> AccountId {
compute_liquidity_token_pda(AMM_ID, amm_pool_id())
}
/// Pool seeded with reserves `1_000` / `500`, lp supply `sqrt(1000*500) = 707`.
fn amm_pool_account() -> AccountWithMetadata {
let reserve_a: u128 = 1_000;
let reserve_b: u128 = 500;
let lp_supply = (reserve_a * reserve_b).isqrt();
AccountWithMetadata {
account: Account {
program_owner: AMM_ID,
balance: 0,
data: Data::from(&PoolDefinition {
definition_token_a_id: amm_token_a_def_id(),
definition_token_b_id: amm_token_b_def_id(),
vault_a_id: amm_vault_a_id(),
vault_b_id: amm_vault_b_id(),
liquidity_pool_id: amm_lp_def_id(),
liquidity_pool_supply: lp_supply,
reserve_a,
reserve_b,
fees: 0,
active: true,
}),
nonce: 0_u128.into(),
},
is_authorized: true,
account_id: amm_pool_id(),
}
}
fn amm_swap_pre_states() -> Vec<AccountWithMetadata> {
let pool = amm_pool_account();
let vault_a = token_holding(amm_token_a_def_id(), amm_vault_a_id(), 1_000, true);
let vault_b = token_holding(amm_token_b_def_id(), amm_vault_b_id(), 500, true);
let user_a = token_holding(amm_token_a_def_id(), AccountId::new([45; 32]), 1_000, true);
let user_b = token_holding(amm_token_b_def_id(), AccountId::new([46; 32]), 500, false);
vec![pool, vault_a, vault_b, user_a, user_b]
}
fn amm_add_liquidity_pre_states() -> Vec<AccountWithMetadata> {
let pool = amm_pool_account();
let vault_a = token_holding(amm_token_a_def_id(), amm_vault_a_id(), 1_000, true);
let vault_b = token_holding(amm_token_b_def_id(), amm_vault_b_id(), 500, true);
let lp_supply = (1_000_u128 * 500_u128).isqrt();
let lp_def = token_definition(amm_lp_def_id(), lp_supply, true);
let user_a = token_holding(amm_token_a_def_id(), AccountId::new([45; 32]), 1_000, true);
let user_b = token_holding(amm_token_b_def_id(), AccountId::new([46; 32]), 500, true);
let user_lp = token_holding(amm_lp_def_id(), AccountId::new([47; 32]), 0, true);
vec![pool, vault_a, vault_b, lp_def, user_a, user_b, user_lp]
}
fn ata_create_pre_states() -> Vec<AccountWithMetadata> {
let owner_id = AccountId::new([91; 32]);
let definition_id = AccountId::new([15; 32]);
let owner = AccountWithMetadata {
account: Account::default(),
is_authorized: true,
account_id: owner_id,
};
let token_def = token_definition(definition_id, 100_000, false);
let seed = compute_ata_seed(owner_id, definition_id);
let ata_id = get_associated_token_account_id(&ASSOCIATED_TOKEN_ACCOUNT_ID, &seed);
let ata_account = AccountWithMetadata {
account: Account::default(),
is_authorized: false,
account_id: ata_id,
};
vec![owner, token_def, ata_account]
}
fn main() -> Result<()> {
let cli = Cli::parse();
let prove = cli.prove;
let exec_iters = cli.exec_iters.max(1);
if prove {
eprintln!("cycle_bench: prove mode ON, this will be slow (~minutes per program)");
}
let cases = [
Case::new(
"authenticated_transfer",
"Transfer",
AUTHENTICATED_TRANSFER_ELF,
AUTHENTICATED_TRANSFER_ID,
authenticated_transfer_transfer(),
&5_000_u128,
)?,
Case::new(
"authenticated_transfer",
"Initialize",
AUTHENTICATED_TRANSFER_ELF,
AUTHENTICATED_TRANSFER_ID,
authenticated_transfer_init(),
&0_u128,
)?,
Case::new(
"token",
"Transfer",
TOKEN_ELF,
TOKEN_ID,
token_transfer_pre_states(),
&token_core::Instruction::Transfer {
amount_to_transfer: 5_000,
},
)?,
Case::new(
"token",
"Mint",
TOKEN_ELF,
TOKEN_ID,
token_mint_pre_states(),
&token_core::Instruction::Mint {
amount_to_mint: 5_000,
},
)?,
Case::new(
"token",
"Burn",
TOKEN_ELF,
TOKEN_ID,
token_burn_pre_states(),
&token_core::Instruction::Burn {
amount_to_burn: 500,
},
)?,
Case::new(
"clock",
"Tick (block_id+1, no multiples)",
CLOCK_ELF,
CLOCK_ID,
clock_pre_states_tick_at(0),
&Timestamp::from(1_700_000_000_u64),
)?,
Case::new(
"amm",
"SwapExactInput",
AMM_ELF,
AMM_ID,
amm_swap_pre_states(),
&amm_core::Instruction::SwapExactInput {
swap_amount_in: 200,
min_amount_out: 1,
token_definition_id_in: amm_token_a_def_id(),
},
)?,
Case::new(
"amm",
"AddLiquidity",
AMM_ELF,
AMM_ID,
amm_add_liquidity_pre_states(),
&amm_core::Instruction::AddLiquidity {
min_amount_liquidity: 1,
max_amount_to_add_token_a: 400,
max_amount_to_add_token_b: 200,
},
)?,
Case::new(
"ata",
"Create",
ASSOCIATED_TOKEN_ACCOUNT_ELF,
ASSOCIATED_TOKEN_ACCOUNT_ID,
ata_create_pre_states(),
&ata_core::Instruction::Create {
ata_program_id: ASSOCIATED_TOKEN_ACCOUNT_ID,
},
)?,
];
let results: Vec<BenchResult> = cases
.into_iter()
.map(|c| c.run(prove, exec_iters))
.collect::<Result<Vec<_>>>()?;
print_table(&results, prove);
#[cfg(feature = "ppe")]
let ppe_results = if cli.ppe { ppe::run_all() } else { Vec::new() };
#[cfg(not(feature = "ppe"))]
let ppe_results: Vec<ppe::PpeBenchResult> = {
if cli.ppe {
eprintln!("cycle_bench: --ppe requires --features ppe at build time. Ignoring.");
}
Vec::new()
};
if !ppe_results.is_empty() {
ppe::print_table(&ppe_results);
}
#[cfg(feature = "ppe")]
let verify_result = if cli.verify {
Some(ppe::run_verify(cli.verify_iters)?)
} else {
None
};
#[cfg(not(feature = "ppe"))]
let verify_result: Option<ppe::VerifyBenchResult> = {
if cli.verify {
eprintln!("cycle_bench: --verify requires --features ppe at build time. Ignoring.");
}
None
};
if let Some(ref vr) = verify_result {
ppe::print_verify(vr);
}
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()?;
let out_path = workspace_root.join("target").join("cycle_bench.json");
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let combined = serde_json::json!({
"standalone": results,
"ppe": ppe_results,
"verify": verify_result,
});
std::fs::write(&out_path, serde_json::to_string_pretty(&combined)?)?;
println!("\nJSON written to {}", out_path.display());
Ok(())
}
fn print_table(results: &[BenchResult], prove: bool) {
let pw = results
.iter()
.map(|r| r.program.len())
.max()
.unwrap_or(0)
.max("program".len());
let iw = results
.iter()
.map(|r| r.instruction.len())
.max()
.unwrap_or(0)
.max("instruction".len());
let cw = 12_usize;
let sw = 8_usize;
let exec_w = results
.iter()
.map(|r| r.exec_stats.to_string().len())
.max()
.unwrap_or(0)
.max("exec_ms (best / mean ± stdev)".len());
println!(
"{:<pw$} {:<iw$} {:>cw$} {:>sw$} {:<exec_w$}",
"program", "instruction", "user_cycles", "segments", "exec_ms (best / mean ± stdev)",
);
println!("{}", "-".repeat(pw + iw + cw + sw + exec_w + 8));
for r in results {
println!(
"{:<pw$} {:<iw$} {:>cw$} {:>sw$} {:<exec_w$}",
r.program, r.instruction, r.user_cycles, r.segments, r.exec_stats,
);
}
if prove {
println!("\nprove():");
let pcw = 14_usize;
let pwallw = 24_usize;
let psw = 10_usize;
println!(
"{:<pw$} {:<iw$} {:>pcw$} {:>pwallw$} {:>psw$}",
"program", "instruction", "prove_total_c", "prove_ms (s)", "prove_segs",
);
println!("{}", "-".repeat(pw + iw + pcw + pwallw + psw + 8));
for r in results {
let total = r
.prove_total_cycles
.map_or_else(|| "-".to_owned(), |c| c.to_string());
let pms = r.prove_stats.map_or_else(
|| "-".to_owned(),
|s| format!("{:.1} ({:.1}s)", s.best_ms, s.best_ms / 1_000.0),
);
let psegs = r
.prove_segments
.map_or_else(|| "-".to_owned(), |s| s.to_string());
println!(
"{:<pw$} {:<iw$} {:>pcw$} {:>pwallw$} {:>psw$}",
r.program, r.instruction, total, pms, psegs,
);
}
}
}

View File

@ -0,0 +1,122 @@
//! Privacy-preserving execution (PPE) cases for `cycle_bench`.
//!
//! Composition cost is the delta between standalone `prover.prove(env, elf)` for
//! a single program (measured in the main bench) and a full `execute_and_prove`
//! that wraps the same program in the privacy circuit. Chained-call depth sweep
//! uses the `chain_caller` test program (loaded from artifacts/) with N=1, 3, 5, 9.
//!
//! `run_verify` produces `G_verify` for the fee model: it generates one PPE
//! receipt (`auth_transfer` Transfer in PPE) and times `Receipt::verify` over
//! `iters` iterations. The proof bytes captured here are also the on-wire
//! "outer proof" payload (`S_agg` in the fee model).
#![allow(
dead_code,
reason = "Stubs are used when the `ppe` feature is disabled."
)]
use anyhow::Result;
use serde::Serialize;
use crate::stats::Stats;
#[cfg(feature = "ppe")]
mod ppe_impl;
#[derive(Debug, Serialize, Clone)]
pub struct PpeBenchResult {
pub label: String,
pub chain_depth: usize,
pub prove_wall_ms: Option<f64>,
/// borsh-serialized `InnerReceipt` length (`S_agg` in the fee model).
pub proof_bytes: Option<usize>,
pub error: Option<String>,
}
#[derive(Debug, Serialize, Clone)]
pub struct VerifyBenchResult {
pub label: String,
pub stats: Stats,
pub proof_bytes: usize,
pub journal_bytes: usize,
}
#[cfg(not(feature = "ppe"))]
pub fn run_all() -> Vec<PpeBenchResult> {
Vec::new()
}
#[cfg(feature = "ppe")]
pub fn run_all() -> Vec<PpeBenchResult> {
let mut results = Vec::new();
eprintln!("PPE: running composition cost (auth_transfer Transfer in PPE)");
results.push(ppe_impl::run_auth_transfer_in_ppe());
for depth in [1_u32, 3, 5, 9] {
eprintln!("PPE: running chain_caller depth={depth}");
results.push(ppe_impl::run_chain_caller(depth));
}
results
}
#[cfg(not(feature = "ppe"))]
pub fn run_verify(_iters: usize) -> Result<VerifyBenchResult> {
anyhow::bail!("--verify requires --features ppe at build time")
}
#[cfg(feature = "ppe")]
pub fn run_verify(iters: usize) -> Result<VerifyBenchResult> {
ppe_impl::run_verify(iters)
}
pub fn print_table(results: &[PpeBenchResult]) {
let lw = results
.iter()
.map(|r| r.label.len())
.max()
.unwrap_or(0)
.max("label".len());
println!(
"\n{:<lw$} {:>5} {:>20} {:>12} {}",
"label",
"depth",
"prove_ms (s)",
"proof_bytes",
"error",
lw = lw,
);
println!("{}", "-".repeat(lw + 60));
for r in results {
let p = r.prove_wall_ms.map_or_else(
|| "-".to_owned(),
|v| format!("{v:.1} ({:.1}s)", v / 1_000.0),
);
let b = r
.proof_bytes
.map_or_else(|| "-".to_owned(), |n| n.to_string());
let e = r.error.as_deref().unwrap_or("");
println!(
"{:<lw$} {:>5} {:>20} {:>12} {}",
r.label,
r.chain_depth,
p,
b,
e,
lw = lw,
);
}
}
pub fn print_verify(r: &VerifyBenchResult) {
println!("\nVerify (G_verify):");
println!(" case : {}", r.label);
println!(
" proof_bytes : {} (borsh InnerReceipt, S_agg)",
r.proof_bytes
);
println!(" journal_bytes : {}", r.journal_bytes);
println!(" verify_ms : {}", r.stats);
}

View File

@ -0,0 +1,194 @@
//! Feature-gated implementation of PPE composition and verify benches.
use std::{collections::HashMap, time::Instant};
use nssa::{
execute_and_prove,
privacy_preserving_transaction::circuit::{ProgramWithDependencies, Proof},
program::Program,
program_methods::PRIVACY_PRESERVING_CIRCUIT_ID,
};
use nssa_core::{
InputAccountIdentity, PrivacyPreservingCircuitOutput,
account::{Account, AccountId, AccountWithMetadata},
program::ProgramId,
};
use risc0_zkvm::{InnerReceipt, Receipt, serde::to_vec};
use super::{PpeBenchResult, VerifyBenchResult};
use crate::stats::Stats;
const AUTH_TRANSFER_ID: ProgramId = nssa::program_methods::AUTHENTICATED_TRANSFER_ID;
const AUTH_TRANSFER_ELF: &[u8] = nssa::program_methods::AUTHENTICATED_TRANSFER_ELF;
/// `chain_caller` bytecode shipped at `artifacts/test_program_methods/chain_caller.bin`.
/// Loaded at compile time so we don't need a dev-dependency on `test_program_methods`.
const CHAIN_CALLER_ELF: &[u8] =
include_bytes!("../../../../artifacts/test_program_methods/chain_caller.bin");
pub fn run_auth_transfer_in_ppe() -> PpeBenchResult {
let label = "auth_transfer Transfer in PPE".to_owned();
let started = Instant::now();
match prove_auth_transfer_in_ppe() {
Ok((_out, proof)) => {
let prove_ms = started.elapsed().as_secs_f64() * 1_000.0;
PpeBenchResult {
label,
chain_depth: 0,
prove_wall_ms: Some(prove_ms),
proof_bytes: Some(proof.into_inner().len()),
error: None,
}
}
Err(err) => PpeBenchResult {
label,
chain_depth: 0,
prove_wall_ms: None,
proof_bytes: None,
error: Some(err.to_string()),
},
}
}
fn prove_auth_transfer_in_ppe() -> anyhow::Result<(PrivacyPreservingCircuitOutput, Proof)> {
let program = Program::new(AUTH_TRANSFER_ELF.to_vec())?;
let pwd = ProgramWithDependencies::from(program);
// For PPE to allow the sender's balance to be decremented by this
// program, the sender must already be claimed by auth_transfer.
// Recipient stays default-owned so the first call can claim it.
let sender = AccountWithMetadata {
account: Account {
program_owner: AUTH_TRANSFER_ID,
balance: 1_000_000,
..Account::default()
},
is_authorized: true,
account_id: AccountId::new([1; 32]),
};
let recipient = AccountWithMetadata {
account: Account::default(),
is_authorized: true,
account_id: AccountId::new([2; 32]),
};
let pre_states = vec![sender, recipient];
let balance_to_move: u128 = 5_000;
let instruction_data = to_vec(&balance_to_move)?;
let account_identities = vec![InputAccountIdentity::Public; pre_states.len()];
Ok(execute_and_prove(
pre_states,
instruction_data,
account_identities,
&pwd,
)?)
}
pub fn run_chain_caller(depth: u32) -> PpeBenchResult {
let label = format!("chain_caller depth={depth}");
let started = Instant::now();
match prove_chain_caller(depth) {
Ok((_out, proof)) => {
let prove_ms = started.elapsed().as_secs_f64() * 1_000.0;
PpeBenchResult {
label,
chain_depth: depth as usize,
prove_wall_ms: Some(prove_ms),
proof_bytes: Some(proof.into_inner().len()),
error: None,
}
}
Err(err) => PpeBenchResult {
label,
chain_depth: depth as usize,
prove_wall_ms: None,
proof_bytes: None,
error: Some(err.to_string()),
},
}
}
fn prove_chain_caller(
num_chain_calls: u32,
) -> anyhow::Result<(PrivacyPreservingCircuitOutput, Proof)> {
let chain_caller = Program::new(CHAIN_CALLER_ELF.to_vec())?;
let auth_transfer = Program::new(AUTH_TRANSFER_ELF.to_vec())?;
let mut deps = HashMap::new();
deps.insert(AUTH_TRANSFER_ID, auth_transfer);
let pwd = ProgramWithDependencies::new(chain_caller, deps);
// Both accounts pre-claimed by auth_transfer. chain_caller doesn't
// track recipient's post-claim program_owner, so a default recipient
// would cause a state mismatch on subsequent chained calls.
let recipient_pre = AccountWithMetadata {
account: Account {
program_owner: AUTH_TRANSFER_ID,
..Account::default()
},
is_authorized: true,
account_id: AccountId::new([2; 32]),
};
let sender_pre = AccountWithMetadata {
account: Account {
program_owner: AUTH_TRANSFER_ID,
balance: 1_000_000,
..Account::default()
},
is_authorized: true,
account_id: AccountId::new([1; 32]),
};
// chain_caller expects pre_states = [recipient, sender].
let pre_states = vec![recipient_pre, sender_pre];
let balance: u128 = 1;
let pda_seed: Option<nssa_core::program::PdaSeed> = None;
let instruction = (balance, AUTH_TRANSFER_ID, num_chain_calls, pda_seed);
let instruction_data = to_vec(&instruction)?;
let account_identities = vec![InputAccountIdentity::Public; pre_states.len()];
Ok(execute_and_prove(
pre_states,
instruction_data,
account_identities,
&pwd,
)?)
}
pub fn run_verify(iters: usize) -> anyhow::Result<VerifyBenchResult> {
eprintln!("verify: generating PPE receipt for auth_transfer Transfer (~1 prove)");
let (output, proof) = prove_auth_transfer_in_ppe()?;
let journal = output.to_bytes();
let journal_bytes = journal.len();
let proof_bytes_vec = proof.into_inner();
let proof_bytes = proof_bytes_vec.len();
let inner: InnerReceipt = borsh::from_slice(&proof_bytes_vec)
.map_err(|e| anyhow::anyhow!("InnerReceipt deserialize: {e}"))?;
let receipt = Receipt::new(inner, journal);
// Sanity-check before the timing loop so we don't measure 1000 failures.
receipt
.verify(PRIVACY_PRESERVING_CIRCUIT_ID)
.map_err(|e| anyhow::anyhow!("verify sanity check failed: {e}"))?;
eprintln!("verify: timing {iters} iters of receipt.verify(...)");
let mut samples = Vec::with_capacity(iters);
for _ in 0..iters {
let started = Instant::now();
receipt
.verify(PRIVACY_PRESERVING_CIRCUIT_ID)
.map_err(|e| anyhow::anyhow!("verify failed mid-loop: {e}"))?;
samples.push(started.elapsed().as_secs_f64() * 1_000.0);
}
let stats = Stats::from_samples(&samples);
Ok(VerifyBenchResult {
label: "auth_transfer Transfer in PPE".to_owned(),
stats,
proof_bytes,
journal_bytes,
})
}

View File

@ -0,0 +1,64 @@
//! Small helper for best / mean / stdev over wall-time samples.
//!
//! We report both best-of-N (the figure that strips OS noise and matches what most
//! bench READMEs print) and mean +/- stdev (the figure the fee model wants, since
//! it cares about the steady-state cost not a single fastest sample).
use std::fmt;
use serde::Serialize;
#[derive(Debug, Serialize, Clone, Copy, Default)]
pub struct Stats {
/// Number of samples in the aggregate (excluding warmup).
pub n: usize,
/// Lowest sample (ms). Strips OS jitter; matches the bench README "best of N" figure.
pub best_ms: f64,
/// Arithmetic mean of samples (ms).
pub mean_ms: f64,
/// Sample standard deviation of samples (ms), computed with Bessel's correction (n-1).
/// 0.0 when n < 2.
pub stdev_ms: f64,
}
impl Stats {
pub fn from_samples(samples: &[f64]) -> Self {
let n = samples.len();
if n == 0 {
return Self::default();
}
let best_ms = samples.iter().copied().fold(f64::INFINITY, f64::min);
let sum: f64 = samples.iter().sum();
let mean_ms = sum / n as f64;
let stdev_ms = if n > 1 {
let var: f64 = samples
.iter()
.map(|s| {
let d = s - mean_ms;
d * d
})
.sum::<f64>()
/ (n - 1) as f64;
var.sqrt()
} else {
0.0
};
Self {
n,
best_ms,
mean_ms,
stdev_ms,
}
}
}
/// `best / mean ± stdev (n=N)` for table display.
impl fmt::Display for Stats {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{:.2} / {:.2} ± {:.2} (n={})",
self.best_ms, self.mean_ms, self.stdev_ms, self.n,
)
}
}

View File

@ -0,0 +1,25 @@
[package]
name = "integration_bench"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
publish = false
[lints]
workspace = true
[dependencies]
common.workspace = true
indexer_service_rpc = { workspace = true, features = ["client"] }
nssa.workspace = true
sequencer_service_rpc = { workspace = true, features = ["client"] }
test_fixtures.workspace = true
wallet.workspace = true
anyhow.workspace = true
borsh.workspace = true
clap.workspace = true
jsonrpsee = { workspace = true, features = ["ws-client"] }
serde.workspace = true
serde_json.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }

View File

@ -0,0 +1,27 @@
# integration_bench
End-to-end LEZ scenarios driven through the wallet against a docker-compose Bedrock node + in-process sequencer + indexer (via `test_fixtures::TestContext`). Times each step (submit, inclusion, wallet sync) and records borsh sizes for every block produced, split into per-tx-variant counts.
## Run
Prerequisite: a running local Docker daemon. The Bedrock service comes up via the same `bedrock/docker-compose.yml` that integration tests use, so no host-side binary or env vars are required.
```sh
# All scenarios, dev-mode proving (fast)
RISC0_DEV_MODE=1 cargo run --release -p integration_bench -- --scenario all
# One scenario, real proving (slow)
cargo run --release -p integration_bench -- --scenario amm
```
Scenarios: `token`, `amm`, `fanout`, `private`, `parallel`, `all`.
All scenarios share a single TestContext for the run (one Bedrock + sequencer + indexer + wallet across the whole run, chain state accumulating), which matches how the node runs in production.
## What you'll see
Per scenario: a step table (`submit_s`, `inclusion_s`, `sync_s`, `total_s`) and a size summary covering every block captured during the scenario (block_bytes total/mean/min/max; per-tx-variant sizes for public, PPE, and program-deployment transactions).
The fanout, parallel, and private scenarios are the most representative for L1-payload-size measurements since they put multiple txs per block.
JSON output is written to `target/integration_bench_dev.json` (dev mode) or `target/integration_bench_prove.json` (real proving).

View File

@ -0,0 +1,331 @@
//! Step / scenario timing primitives shared across scenarios.
#![allow(
clippy::ref_option,
reason = "serde::serialize_with requires fn(&Option<T>, S) -> Result<...>"
)]
use std::time::{Duration, Instant};
use anyhow::{Result, bail};
use common::transaction::NSSATransaction;
use sequencer_service_rpc::RpcClient as _;
use serde::{Serialize, Serializer};
use test_fixtures::{DiskSizes, TestContext};
use wallet::cli::SubcommandReturnValue;
const TX_INCLUSION_POLL_INTERVAL: Duration = Duration::from_millis(250);
const TX_INCLUSION_TIMEOUT: Duration = Duration::from_secs(120);
/// Borsh-serialized sizes for one zone block fetched after a step. `block_bytes`
/// is the full Block (header + body + bedrock metadata) and is the closest
/// proxy we have to the L1 payload posted per block. `tx_bytes` is each contained
/// transaction split by variant, which is what the fee model's `S_tx` slot covers.
#[derive(Debug, Serialize, Clone, Default)]
pub struct BlockSize {
pub block_id: u64,
pub block_bytes: usize,
pub public_tx_bytes: Vec<usize>,
pub ppe_tx_bytes: Vec<usize>,
pub deploy_tx_bytes: Vec<usize>,
}
#[derive(Debug, Serialize, Clone)]
pub struct StepResult {
pub label: String,
#[serde(serialize_with = "ser_duration_secs", rename = "submit_s")]
pub submit: Duration,
#[serde(serialize_with = "ser_opt_duration_secs", rename = "inclusion_s")]
pub inclusion: Option<Duration>,
#[serde(serialize_with = "ser_opt_duration_secs", rename = "wallet_sync_s")]
pub wallet_sync: Option<Duration>,
#[serde(serialize_with = "ser_duration_secs", rename = "total_s")]
pub total: Duration,
pub tx_hash: Option<String>,
/// Borsh sizes for every zone block produced during this step.
/// Empty for steps that don't advance the chain (e.g. `RegisterAccount`).
pub blocks: Vec<BlockSize>,
}
#[derive(Debug, Serialize, Default)]
pub struct ScenarioOutput {
pub name: String,
pub steps: Vec<StepResult>,
#[serde(serialize_with = "ser_duration_secs", rename = "total_s")]
pub total: Duration,
/// Disk sizes (sequencer / indexer / wallet tempdirs) sampled at scenario start.
pub disk_before: Option<DiskSizes>,
/// Disk sizes sampled at scenario end.
pub disk_after: Option<DiskSizes>,
/// Bedrock-finality latency: time from final-step inclusion to the indexer
/// reporting the sequencer tip as L1-finalised. Effectively measures the
/// sequencer→Bedrock posting + Bedrock finalisation + indexer L1 ingest path.
/// A value at the timeout (60s) means finalisation did not happen within the bench window.
#[serde(
serialize_with = "ser_opt_duration_secs",
rename = "bedrock_finality_s"
)]
pub bedrock_finality: Option<Duration>,
}
impl ScenarioOutput {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
..Default::default()
}
}
pub fn push(&mut self, step: StepResult) {
self.total = self.total.saturating_add(step.total);
self.steps.push(step);
}
/// Run a single timed step against `ctx`: capture pre-block, run `submit`,
/// finalize timings, push a `StepResult` onto `self.steps`. Returns the
/// `SubcommandReturnValue` from `submit` so the caller can match on it.
pub async fn step(
&mut self,
ctx: &mut TestContext,
label: impl Into<String>,
submit: impl AsyncFnOnce(&mut TestContext) -> Result<SubcommandReturnValue>,
) -> Result<SubcommandReturnValue> {
let pre_block = begin_step(ctx).await?;
let started = Instant::now();
let ret = submit(ctx).await?;
let step = finalize_step(label, started, pre_block, &ret, ctx).await?;
self.push(step);
Ok(ret)
}
}
/// Begin a timed step. Capture this *before* submitting the wallet operation
/// so we can later subtract it from the post-submit block height to detect
/// when the chain has advanced past the tx's block.
async fn begin_step(ctx: &TestContext) -> Result<u64> {
Ok(ctx.sequencer_client().get_last_block_id().await?)
}
/// Finish a timed wallet step. Records submit (the time between `started`
/// being captured and `ret` being received) and, if `ret` is a
/// [`SubcommandReturnValue::PrivacyPreservingTransfer`], polls the sequencer
/// for inclusion and records the inclusion latency. Returns a [`StepResult`].
async fn finalize_step(
label: impl Into<String>,
started: Instant,
pre_block_id: u64,
ret: &SubcommandReturnValue,
ctx: &mut TestContext,
) -> Result<StepResult> {
let label = label.into();
let submit = started.elapsed();
let mut tx_hash_str = None;
let mut inclusion = None;
let mut wallet_sync = None;
let mut blocks: Vec<BlockSize> = Vec::new();
// For non-account-create steps (anything that produces a tx_hash, or even
// `Empty` for public Token Send), wait for the chain to advance past the
// submission block so state is applied before the next step. We use
// get_last_block_id as the canonical "block has been produced and
// recorded" signal.
let should_wait_for_chain = !matches!(ret, SubcommandReturnValue::RegisterAccount { .. });
if should_wait_for_chain {
if let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = ret {
tx_hash_str = Some(format!("{tx_hash}"));
}
let started_inclusion = Instant::now();
wait_for_chain_advance(ctx, pre_block_id, 2).await?;
inclusion = Some(started_inclusion.elapsed());
let started_sync = Instant::now();
sync_wallet_to_tip(ctx).await?;
wallet_sync = Some(started_sync.elapsed());
// Capture block-byte and per-tx-byte sizes for every block produced
// during this step. We intentionally capture all blocks, including
// empty clock-only ticks: the empty-block baseline lets the fee model
// back out the per-tx contribution.
let tip = ctx.sequencer_client().get_last_block_id().await?;
for block_id in (pre_block_id.saturating_add(1))..=tip {
if let Some(block) = ctx.sequencer_client().get_block(block_id).await? {
let block_bytes = borsh::to_vec(&block).map_or(0, |v| v.len());
let mut sz = BlockSize {
block_id,
block_bytes,
public_tx_bytes: Vec::new(),
ppe_tx_bytes: Vec::new(),
deploy_tx_bytes: Vec::new(),
};
for tx in &block.body.transactions {
let n = borsh::to_vec(tx).map_or(0, |v| v.len());
match tx {
NSSATransaction::Public(_) => sz.public_tx_bytes.push(n),
NSSATransaction::PrivacyPreserving(_) => sz.ppe_tx_bytes.push(n),
NSSATransaction::ProgramDeployment(_) => sz.deploy_tx_bytes.push(n),
}
}
blocks.push(sz);
}
}
}
Ok(StepResult {
label,
submit,
inclusion,
wallet_sync,
total: started.elapsed(),
tx_hash: tx_hash_str,
blocks,
})
}
/// Wait for `get_last_block_id` to advance by at least `min_blocks` from `from_block_id`.
pub async fn wait_for_chain_advance(
ctx: &TestContext,
from_block_id: u64,
min_blocks: u64,
) -> Result<()> {
let target = from_block_id.saturating_add(min_blocks);
let poll = async {
loop {
match ctx.sequencer_client().get_last_block_id().await {
Ok(current) if current >= target => return,
Ok(_) => {}
Err(err) => eprintln!("get_last_block_id error (continuing poll): {err:#}"),
}
tokio::time::sleep(TX_INCLUSION_POLL_INTERVAL).await;
}
};
match tokio::time::timeout(TX_INCLUSION_TIMEOUT, poll).await {
Ok(()) => Ok(()),
Err(_) => bail!(
"chain did not advance from {from_block_id} to at least {target} within {TX_INCLUSION_TIMEOUT:?}"
),
}
}
async fn sync_wallet_to_tip(ctx: &mut TestContext) -> Result<()> {
let last_block = ctx.sequencer_client().get_last_block_id().await?;
ctx.wallet_mut().sync_to_block(last_block).await?;
Ok(())
}
pub fn print_table(output: &ScenarioOutput) {
let label_width = output
.steps
.iter()
.map(|s| s.label.len())
.max()
.unwrap_or(0)
.max("step".len());
println!(
"\nScenario: {} (total {:.2}s)",
output.name,
output.total.as_secs_f64(),
);
println!(
"{:<lw$} {:>10} {:>12} {:>10} {:>10}",
"step",
"submit_s",
"inclusion_s",
"sync_s",
"total_s",
lw = label_width,
);
println!("{}", "-".repeat(label_width.saturating_add(50)));
for s in &output.steps {
let inclusion = s
.inclusion
.map_or_else(|| "-".to_owned(), |v| format!("{:.3}", v.as_secs_f64()));
let sync = s
.wallet_sync
.map_or_else(|| "-".to_owned(), |v| format!("{:.3}", v.as_secs_f64()));
println!(
"{:<lw$} {:>10.3} {:>12} {:>10} {:>10.3}",
s.label,
s.submit.as_secs_f64(),
inclusion,
sync,
s.total.as_secs_f64(),
lw = label_width,
);
}
print_size_summary(output);
}
/// Aggregate borsh sizes per scenario: total/mean/min/max block bytes, and
/// per-tx bytes split by variant. Empty if no blocks were captured.
fn print_size_summary(output: &ScenarioOutput) {
let blocks: Vec<&BlockSize> = output.steps.iter().flat_map(|s| s.blocks.iter()).collect();
if blocks.is_empty() {
return;
}
let block_bytes: Vec<usize> = blocks.iter().map(|b| b.block_bytes).collect();
let total_block_bytes: usize = block_bytes.iter().sum();
let mean_block = mean_usize(&block_bytes);
let min_block = block_bytes.iter().copied().min().unwrap_or(0);
let max_block = block_bytes.iter().copied().max().unwrap_or(0);
let public: Vec<usize> = blocks
.iter()
.flat_map(|b| b.public_tx_bytes.iter().copied())
.collect();
let ppe: Vec<usize> = blocks
.iter()
.flat_map(|b| b.ppe_tx_bytes.iter().copied())
.collect();
let deploy: Vec<usize> = blocks
.iter()
.flat_map(|b| b.deploy_tx_bytes.iter().copied())
.collect();
println!(
"\nBlock + tx size summary ({} blocks captured):",
blocks.len()
);
println!(
" block_bytes: total={total_block_bytes}, mean={mean_block}, min={min_block}, max={max_block}",
);
print_tx_line("public_tx_bytes ", &public);
print_tx_line("ppe_tx_bytes ", &ppe);
print_tx_line("deploy_tx_bytes ", &deploy);
}
fn print_tx_line(label: &str, samples: &[usize]) {
if samples.is_empty() {
println!(" {label}: (none)");
return;
}
let total: usize = samples.iter().sum();
let mean = mean_usize(samples);
let min = samples.iter().copied().min().unwrap_or(0);
let max = samples.iter().copied().max().unwrap_or(0);
println!(
" {label}: n={}, total={total}, mean={mean}, min={min}, max={max}",
samples.len()
);
}
fn mean_usize(xs: &[usize]) -> usize {
xs.iter().sum::<usize>().checked_div(xs.len()).unwrap_or(0)
}
fn ser_duration_secs<S: Serializer>(d: &Duration, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_f64(d.as_secs_f64())
}
fn ser_opt_duration_secs<S: Serializer>(
d: &Option<Duration>,
s: S,
) -> std::result::Result<S::Ok, S::Error> {
match d {
Some(d) => s.serialize_f64(d.as_secs_f64()),
None => s.serialize_none(),
}
}

View File

@ -0,0 +1,200 @@
//! End-to-end LEZ scenario bench.
//!
//! Spins up the full stack via `test_fixtures::TestContext` (docker-compose
//! Bedrock + in-process sequencer + indexer + wallet) once for the whole run,
//! then drives the wallet through each requested scenario against that single
//! shared stack. Times each step and records borsh-serialized block + tx sizes
//! per scenario.
//!
//! Prerequisite: a working local Docker daemon. The Bedrock service is brought
//! up via the same `bedrock/docker-compose.yml` the integration tests use, so
//! no host-side binary or env vars are required.
//!
//! Run examples:
//! `RISC0_DEV_MODE=1 cargo run --release -p integration_bench -- --scenario all`.
//! `cargo run --release -p integration_bench -- --scenario amm`.
//!
//! `RISC0_DEV_MODE=1` skips proving and produces latency-only numbers in
//! ~minutes; omitting it produces realistic proving-inclusive numbers but
//! the run takes much longer.
#![allow(
clippy::arithmetic_side_effects,
clippy::print_stderr,
clippy::print_stdout,
clippy::shadow_unrelated,
clippy::wildcard_enum_match_arm,
reason = "Bench tool: stderr/stdout output is the deliverable; small Duration / iterator-sum \
arithmetic is safe at bench scale; bench scenarios bail loudly on any unexpected \
return variant, which is preferable to maintaining an exhaustive list in five files; \
the step() closure helper canonically rebinds `ctx` inside the closure body."
)]
use std::{path::PathBuf, time::Duration};
use anyhow::{Context as _, Result};
use clap::{Parser, ValueEnum};
use harness::ScenarioOutput;
use serde::Serialize;
use test_fixtures::TestContext;
mod harness;
mod scenarios;
#[derive(Copy, Clone, Debug, ValueEnum)]
enum ScenarioName {
Token,
Amm,
Fanout,
Private,
Parallel,
All,
}
#[derive(Parser, Debug)]
#[command(about = "End-to-end LEZ scenario bench")]
struct Cli {
/// Which scenario(s) to run.
#[arg(long, value_enum, default_value_t = ScenarioName::All)]
scenario: ScenarioName,
/// Optional JSON output path. Defaults to `<workspace>/target/integration_bench.json`.
#[arg(long)]
json_out: Option<PathBuf>,
}
#[derive(Debug, Serialize)]
struct BenchRunReport {
risc0_dev_mode: bool,
/// Time to bring up the shared `TestContext` (docker-compose Bedrock +
/// sequencer + indexer + wallet). Paid once per run regardless of how many
/// scenarios are exercised.
shared_setup_s: f64,
scenarios: Vec<ScenarioOutput>,
total_wall_s: f64,
}
#[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<()> {
// test_fixtures initializes env_logger via a LazyLock, so we leave logger
// setup to it. Set RUST_LOG=info before running to see logs.
let cli = Cli::parse();
let risc0_dev_mode = std::env::var("RISC0_DEV_MODE").is_ok_and(|v| !v.is_empty() && v != "0");
eprintln!(
"integration_bench: scenario={:?}, RISC0_DEV_MODE={}",
cli.scenario,
if risc0_dev_mode { "1" } else { "unset/0" }
);
let to_run: Vec<ScenarioName> = match cli.scenario {
ScenarioName::All => vec![
ScenarioName::Token,
ScenarioName::Amm,
ScenarioName::Fanout,
ScenarioName::Private,
ScenarioName::Parallel,
],
other => vec![other],
};
let overall_started = std::time::Instant::now();
// One shared stack for the entire run: docker-compose Bedrock + sequencer +
// indexer + wallet. Scenarios share chain state, which matches how the node
// runs in production (long-lived, accumulating).
let setup_started = std::time::Instant::now();
let mut ctx = TestContext::new()
.await
.context("failed to setup TestContext")?;
let shared_setup = setup_started.elapsed();
eprintln!("setup: {:.2}s", shared_setup.as_secs_f64());
let mut all_outputs = Vec::with_capacity(to_run.len());
for name in to_run {
eprintln!("\n=== running scenario: {name:?} ===");
let disk_before = ctx.disk_sizes();
let mut output = run_scenario(name, &mut ctx).await?;
output.disk_before = Some(disk_before);
output.disk_after = Some(ctx.disk_sizes());
output.bedrock_finality = Some(measure_bedrock_finality(&ctx).await?);
harness::print_table(&output);
all_outputs.push(output);
}
let total_wall_s = overall_started.elapsed().as_secs_f64();
eprintln!("\nTotal wall time: {total_wall_s:.1}s");
let report = BenchRunReport {
risc0_dev_mode,
shared_setup_s: shared_setup.as_secs_f64(),
scenarios: all_outputs,
total_wall_s,
};
let out_path = if let Some(p) = cli.json_out {
p
} else {
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()?;
let suffix = if risc0_dev_mode { "dev" } else { "prove" };
workspace_root
.join("target")
.join(format!("integration_bench_{suffix}.json"))
};
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&out_path, serde_json::to_string_pretty(&report)?)?;
eprintln!("\nJSON written to {}", out_path.display());
Ok(())
}
async fn run_scenario(name: ScenarioName, ctx: &mut TestContext) -> Result<ScenarioOutput> {
match name {
ScenarioName::Token => scenarios::token::run(ctx).await,
ScenarioName::Amm => scenarios::amm::run(ctx).await,
ScenarioName::Fanout => scenarios::fanout::run(ctx).await,
ScenarioName::Private => scenarios::private::run(ctx).await,
ScenarioName::Parallel => scenarios::parallel::run(ctx).await,
ScenarioName::All => unreachable!("dispatched above"),
}
}
/// Poll the indexer's L1-finalised block id until it catches up with the
/// sequencer's last block id. This is effectively the sequencer→Bedrock posting
/// plus Bedrock finalisation plus indexer ingest latency.
async fn measure_bedrock_finality(ctx: &TestContext) -> Result<Duration> {
use indexer_service_rpc::RpcClient as _;
use jsonrpsee::ws_client::WsClientBuilder;
use sequencer_service_rpc::RpcClient as _;
let indexer_url = format!("ws://{}", ctx.indexer_addr());
let indexer_ws = WsClientBuilder::default()
.build(&indexer_url)
.await
.context("connect indexer WS")?;
let sequencer_tip = ctx.sequencer_client().get_last_block_id().await?;
let timeout = Duration::from_secs(60);
let started = std::time::Instant::now();
let poll = async {
loop {
match indexer_ws.get_last_finalized_block_id().await {
Ok(Some(b)) if b >= sequencer_tip => return,
Ok(_) => {}
Err(err) => eprintln!("indexer last_synced poll error: {err:#}"),
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
};
if tokio::time::timeout(timeout, poll).await.is_err() {
eprintln!("indexer did not catch up to {sequencer_tip} within {timeout:?}");
}
Ok(started.elapsed())
}

View File

@ -0,0 +1,191 @@
//! AMM swap flow: setup two tokens, create pool, swap, add liquidity, remove liquidity.
use anyhow::{Result, bail};
use test_fixtures::{TestContext, public_mention};
use wallet::cli::{
Command, SubcommandReturnValue,
account::{AccountSubcommand, NewSubcommand},
programs::{amm::AmmProgramAgnosticSubcommand, token::TokenProgramAgnosticSubcommand},
};
use crate::harness::ScenarioOutput;
pub async fn run(ctx: &mut TestContext) -> Result<ScenarioOutput> {
let mut output = ScenarioOutput::new("amm_swap_flow");
let def_a = new_public_account(ctx, &mut output, "create_acc_def_a").await?;
let supply_a = new_public_account(ctx, &mut output, "create_acc_supply_a").await?;
let user_a = new_public_account(ctx, &mut output, "create_acc_user_a").await?;
let def_b = new_public_account(ctx, &mut output, "create_acc_def_b").await?;
let supply_b = new_public_account(ctx, &mut output, "create_acc_supply_b").await?;
let user_b = new_public_account(ctx, &mut output, "create_acc_user_b").await?;
let user_lp = new_public_account(ctx, &mut output, "create_acc_user_lp").await?;
timed_token_new(ctx, &mut output, "token_a_new", def_a, supply_a, "TokA").await?;
timed_token_send(
ctx,
&mut output,
"token_a_fund_user",
supply_a,
user_a,
1_000,
)
.await?;
timed_token_new(ctx, &mut output, "token_b_new", def_b, supply_b, "TokB").await?;
timed_token_send(
ctx,
&mut output,
"token_b_fund_user",
supply_b,
user_b,
1_000,
)
.await?;
output
.step(ctx, "amm_new_pool", async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::AMM(AmmProgramAgnosticSubcommand::New {
user_holding_a: public_mention(user_a),
user_holding_b: public_mention(user_b),
user_holding_lp: public_mention(user_lp),
balance_a: 300,
balance_b: 300,
}),
)
.await
})
.await?;
output
.step(ctx, "amm_swap_exact_input", async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::AMM(AmmProgramAgnosticSubcommand::SwapExactInput {
user_holding_a: public_mention(user_a),
user_holding_b: public_mention(user_b),
amount_in: 50,
min_amount_out: 1,
token_definition: def_a,
}),
)
.await
})
.await?;
output
.step(ctx, "amm_add_liquidity", async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::AMM(AmmProgramAgnosticSubcommand::AddLiquidity {
user_holding_a: public_mention(user_a),
user_holding_b: public_mention(user_b),
user_holding_lp: public_mention(user_lp),
min_amount_lp: 1,
max_amount_a: 100,
max_amount_b: 100,
}),
)
.await
})
.await?;
output
.step(ctx, "amm_remove_liquidity", async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::AMM(AmmProgramAgnosticSubcommand::RemoveLiquidity {
user_holding_a: public_mention(user_a),
user_holding_b: public_mention(user_b),
user_holding_lp: public_mention(user_lp),
balance_lp: 50,
min_amount_a: 1,
min_amount_b: 1,
}),
)
.await
})
.await?;
Ok(output)
}
async fn new_public_account(
ctx: &mut TestContext,
output: &mut ScenarioOutput,
label: &str,
) -> Result<nssa::AccountId> {
let ret = output
.step(ctx, label, async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await
})
.await?;
match ret {
SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id),
other => bail!("expected RegisterAccount, got {other:?}"),
}
}
async fn timed_token_new(
ctx: &mut TestContext,
output: &mut ScenarioOutput,
label: &str,
def_id: nssa::AccountId,
supply_id: nssa::AccountId,
name: &str,
) -> Result<()> {
let name = name.to_owned();
output
.step(ctx, label, async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::New {
definition_account_id: public_mention(def_id),
supply_account_id: public_mention(supply_id),
name,
total_supply: 10_000,
}),
)
.await
})
.await?;
Ok(())
}
async fn timed_token_send(
ctx: &mut TestContext,
output: &mut ScenarioOutput,
label: &str,
from_id: nssa::AccountId,
to_id: nssa::AccountId,
amount: u128,
) -> Result<()> {
output
.step(ctx, label, async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::Send {
from: public_mention(from_id),
to: Some(public_mention(to_id)),
to_npk: None,
to_vpk: None,
to_identifier: Some(0),
amount,
}),
)
.await
})
.await?;
Ok(())
}

View File

@ -0,0 +1,86 @@
//! Multi-recipient fanout: one funded supply pays 10 distinct recipients.
use anyhow::{Result, bail};
use test_fixtures::{TestContext, public_mention};
use wallet::cli::{
Command, SubcommandReturnValue,
account::{AccountSubcommand, NewSubcommand},
programs::token::TokenProgramAgnosticSubcommand,
};
use crate::harness::ScenarioOutput;
const FANOUT_COUNT: usize = 10;
const AMOUNT_PER_TRANSFER: u128 = 100;
pub async fn run(ctx: &mut TestContext) -> Result<ScenarioOutput> {
let mut output = ScenarioOutput::new("multi_recipient_fanout");
let def_id = new_public_account(ctx, &mut output, "create_acc_def").await?;
let supply_id = new_public_account(ctx, &mut output, "create_acc_supply").await?;
output
.step(ctx, "token_new_fungible", async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::New {
definition_account_id: public_mention(def_id),
supply_account_id: public_mention(supply_id),
name: "FanoutToken".to_owned(),
total_supply: 10_000_000,
}),
)
.await
})
.await?;
let mut recipients = Vec::with_capacity(FANOUT_COUNT);
for i in 0..FANOUT_COUNT {
let id = new_public_account(ctx, &mut output, &format!("create_recipient_{i:02}")).await?;
recipients.push(id);
}
for (i, recipient_id) in recipients.iter().copied().enumerate() {
output
.step(ctx, format!("transfer_{i:02}"), async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::Send {
from: public_mention(supply_id),
to: Some(public_mention(recipient_id)),
to_npk: None,
to_vpk: None,
to_identifier: Some(0),
amount: AMOUNT_PER_TRANSFER,
}),
)
.await
})
.await?;
}
Ok(output)
}
async fn new_public_account(
ctx: &mut TestContext,
output: &mut ScenarioOutput,
label: &str,
) -> Result<nssa::AccountId> {
let ret = output
.step(ctx, label, async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await
})
.await?;
match ret {
SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id),
other => bail!("expected RegisterAccount, got {other:?}"),
}
}

View File

@ -0,0 +1,7 @@
//! Scenarios driven by the e2e bench.
pub mod amm;
pub mod fanout;
pub mod parallel;
pub mod private;
pub mod token;

View File

@ -0,0 +1,188 @@
//! Parallel-fanout throughput scenario. N distinct senders each transfer one token
//! to one recipient. Submission is serialised through the single wallet but does
//! not wait for chain advance between submits, so all N txs land in the same
//! block (up to `max_num_tx_in_block`). Measures observed throughput.
use std::time::Instant;
use anyhow::{Result, bail};
use common::transaction::NSSATransaction;
use sequencer_service_rpc::RpcClient as _;
use test_fixtures::{TestContext, public_mention};
use wallet::cli::{
Command, SubcommandReturnValue,
account::{AccountSubcommand, NewSubcommand},
programs::token::TokenProgramAgnosticSubcommand,
};
use crate::harness::{BlockSize, ScenarioOutput, StepResult};
const PARALLEL_FANOUT_N: usize = 10;
const AMOUNT_PER_TRANSFER: u128 = 100;
pub async fn run(ctx: &mut TestContext) -> Result<ScenarioOutput> {
let mut output = ScenarioOutput::new("parallel_fanout");
// Setup: definition, master supply, N parallel supplies, N recipients.
let def_id = new_public_account(ctx, &mut output, "create_acc_def").await?;
let master_id = new_public_account(ctx, &mut output, "create_acc_master").await?;
let mut senders = Vec::with_capacity(PARALLEL_FANOUT_N);
for i in 0..PARALLEL_FANOUT_N {
let id = new_public_account(ctx, &mut output, &format!("create_sender_{i:02}")).await?;
senders.push(id);
}
let mut recipients = Vec::with_capacity(PARALLEL_FANOUT_N);
for i in 0..PARALLEL_FANOUT_N {
let id = new_public_account(ctx, &mut output, &format!("create_recipient_{i:02}")).await?;
recipients.push(id);
}
// Mint full supply into master.
let total_mint = u128::try_from(PARALLEL_FANOUT_N)
.expect("usize fits u128")
.saturating_mul(AMOUNT_PER_TRANSFER)
.saturating_mul(10);
output
.step(ctx, "token_new_fungible", async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::New {
definition_account_id: public_mention(def_id),
supply_account_id: public_mention(master_id),
name: "ParToken".to_owned(),
total_supply: total_mint,
}),
)
.await
})
.await?;
// Fund each sender from master. Serial; this is setup, not measured throughput.
for (i, sender_id) in senders.iter().copied().enumerate() {
output
.step(ctx, format!("fund_sender_{i:02}"), async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::Send {
from: public_mention(master_id),
to: Some(public_mention(sender_id)),
to_npk: None,
to_vpk: None,
to_identifier: Some(0),
amount: AMOUNT_PER_TRANSFER * 5,
}),
)
.await
})
.await?;
}
// The measured phase: submit N transfers as fast as possible, do not wait
// for chain advance between submits. The sequencer batches whatever lands in
// its mempool before block_create_timeout. The burst step is captured
// manually rather than via the `step()` helper because we need to time
// submit-and-inclusion as two separate intervals over a synthesised batch
// rather than per-tx.
let pre_block_burst = ctx.sequencer_client().get_last_block_id().await?;
let burst_started = Instant::now();
// Submit all N back-to-back. Wallet serialises through `wallet_mut()`, but
// each sender has its own nonce so there are no collisions.
for (sender_id, recipient_id) in senders.iter().zip(recipients.iter()) {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::Send {
from: public_mention(*sender_id),
to: Some(public_mention(*recipient_id)),
to_npk: None,
to_vpk: None,
to_identifier: Some(0),
amount: AMOUNT_PER_TRANSFER,
}),
)
.await?;
}
let all_submitted_at = Instant::now();
let submit_duration = all_submitted_at.saturating_duration_since(burst_started);
// Wait for the chain to advance by at least 2 blocks past pre_block_burst.
// That guarantees the block holding our burst is sealed and applied.
crate::harness::wait_for_chain_advance(ctx, pre_block_burst, 2).await?;
let inclusion_done_at = Instant::now();
let inclusion_after_submit = inclusion_done_at.saturating_duration_since(all_submitted_at);
let burst_total = inclusion_done_at.saturating_duration_since(burst_started);
eprintln!(
"parallel_fanout: submitted {} txs in {:.3}s, inclusion in {:.3}s, total {:.3}s",
senders.len(),
submit_duration.as_secs_f64(),
inclusion_after_submit.as_secs_f64(),
burst_total.as_secs_f64(),
);
// Capture every block produced during the burst window. This is the
// scenario where one block holds many txs, so block_bytes here is the
// most representative L1-payload-equivalent measurement we have.
let tip = ctx.sequencer_client().get_last_block_id().await?;
let mut blocks: Vec<BlockSize> = Vec::new();
for block_id in (pre_block_burst.saturating_add(1))..=tip {
if let Some(block) = ctx.sequencer_client().get_block(block_id).await? {
let block_bytes = borsh::to_vec(&block).map_or(0, |v| v.len());
let mut sz = BlockSize {
block_id,
block_bytes,
public_tx_bytes: Vec::new(),
ppe_tx_bytes: Vec::new(),
deploy_tx_bytes: Vec::new(),
};
for tx in &block.body.transactions {
let n = borsh::to_vec(tx).map_or(0, |v| v.len());
match tx {
NSSATransaction::Public(_) => sz.public_tx_bytes.push(n),
NSSATransaction::PrivacyPreserving(_) => sz.ppe_tx_bytes.push(n),
NSSATransaction::ProgramDeployment(_) => sz.deploy_tx_bytes.push(n),
}
}
blocks.push(sz);
}
}
// Synthesise a single summary "step" for the burst. Use the submit time
// for `submit` and the inclusion-wait time for `inclusion`.
let burst_step = StepResult {
label: format!("burst_{}_transfers", senders.len()),
submit: submit_duration,
inclusion: Some(inclusion_after_submit),
wallet_sync: None,
total: burst_total,
tx_hash: None,
blocks,
};
output.push(burst_step);
Ok(output)
}
async fn new_public_account(
ctx: &mut TestContext,
output: &mut ScenarioOutput,
label: &str,
) -> Result<nssa::AccountId> {
let ret = output
.step(ctx, label, async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await
})
.await?;
match ret {
SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id),
other => bail!("expected RegisterAccount, got {other:?}"),
}
}

View File

@ -0,0 +1,140 @@
//! Private chained flow: shielded, deshielded, and private-to-private transfers.
use anyhow::{Result, bail};
use test_fixtures::{TestContext, private_mention, public_mention};
use wallet::cli::{
Command, SubcommandReturnValue,
account::{AccountSubcommand, NewSubcommand},
programs::token::TokenProgramAgnosticSubcommand,
};
use crate::harness::ScenarioOutput;
pub async fn run(ctx: &mut TestContext) -> Result<ScenarioOutput> {
let mut output = ScenarioOutput::new("private_chained_flow");
let def_id = new_public_account(ctx, &mut output, "create_acc_def").await?;
let supply_id = new_public_account(ctx, &mut output, "create_acc_supply").await?;
let public_recipient_id =
new_public_account(ctx, &mut output, "create_acc_pub_recipient").await?;
let private_a = new_private_account(ctx, &mut output, "create_acc_priv_a").await?;
let private_b = new_private_account(ctx, &mut output, "create_acc_priv_b").await?;
// Mint into public supply.
output
.step(ctx, "token_new_fungible", async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::New {
definition_account_id: public_mention(def_id),
supply_account_id: public_mention(supply_id),
name: "PrivToken".to_owned(),
total_supply: 1_000_000,
}),
)
.await
})
.await?;
// Shielded transfer: public supply -> private_a.
output
.step(ctx, "shielded_transfer", async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::Send {
from: public_mention(supply_id),
to: Some(private_mention(private_a)),
to_npk: None,
to_vpk: None,
to_identifier: Some(0),
amount: 1_000,
}),
)
.await
})
.await?;
// Deshielded transfer: private_a -> public_recipient.
output
.step(ctx, "deshielded_transfer", async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::Send {
from: private_mention(private_a),
to: Some(public_mention(public_recipient_id)),
to_npk: None,
to_vpk: None,
to_identifier: Some(0),
amount: 100,
}),
)
.await
})
.await?;
// Private-to-private transfer: private_a -> private_b.
output
.step(ctx, "private_to_private", async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::Send {
from: private_mention(private_a),
to: Some(private_mention(private_b)),
to_npk: None,
to_vpk: None,
to_identifier: Some(0),
amount: 200,
}),
)
.await
})
.await?;
Ok(output)
}
async fn new_public_account(
ctx: &mut TestContext,
output: &mut ScenarioOutput,
label: &str,
) -> Result<nssa::AccountId> {
let ret = output
.step(ctx, label, async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await
})
.await?;
match ret {
SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id),
other => bail!("expected RegisterAccount, got {other:?}"),
}
}
async fn new_private_account(
ctx: &mut TestContext,
output: &mut ScenarioOutput,
label: &str,
) -> Result<nssa::AccountId> {
let ret = output
.step(ctx, label, async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
})),
)
.await
})
.await?;
match ret {
SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id),
other => bail!("expected RegisterAccount, got {other:?}"),
}
}

View File

@ -0,0 +1,119 @@
//! Token onboarding scenario: create accounts, mint, public transfer, private transfer.
use anyhow::{Result, bail};
use test_fixtures::{TestContext, private_mention, public_mention};
use wallet::cli::{
Command, SubcommandReturnValue,
account::{AccountSubcommand, NewSubcommand},
programs::token::TokenProgramAgnosticSubcommand,
};
use crate::harness::ScenarioOutput;
pub async fn run(ctx: &mut TestContext) -> Result<ScenarioOutput> {
let mut output = ScenarioOutput::new("token_onboarding");
let definition_id = new_public_account(ctx, &mut output, "create_pub_definition").await?;
let supply_id = new_public_account(ctx, &mut output, "create_pub_supply").await?;
let recipient_id = new_public_account(ctx, &mut output, "create_pub_recipient").await?;
output
.step(ctx, "token_new_fungible", async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::New {
definition_account_id: public_mention(definition_id),
supply_account_id: public_mention(supply_id),
name: "BenchToken".to_owned(),
total_supply: 1_000_000,
}),
)
.await
})
.await?;
output
.step(ctx, "token_public_transfer", async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::Send {
from: public_mention(supply_id),
to: Some(public_mention(recipient_id)),
to_npk: None,
to_vpk: None,
to_identifier: Some(0),
amount: 1_000,
}),
)
.await
})
.await?;
let private_recipient_id =
new_private_account(ctx, &mut output, "create_priv_recipient").await?;
output
.step(ctx, "token_shielded_transfer", async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::Send {
from: public_mention(supply_id),
to: Some(private_mention(private_recipient_id)),
to_npk: None,
to_vpk: None,
to_identifier: Some(0),
amount: 500,
}),
)
.await
})
.await?;
Ok(output)
}
async fn new_public_account(
ctx: &mut TestContext,
output: &mut ScenarioOutput,
label: &str,
) -> Result<nssa::AccountId> {
let ret = output
.step(ctx, label, async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await
})
.await?;
match ret {
SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id),
other => bail!("expected RegisterAccount, got {other:?}"),
}
}
async fn new_private_account(
ctx: &mut TestContext,
output: &mut ScenarioOutput,
label: &str,
) -> Result<nssa::AccountId> {
let ret = output
.step(ctx, label, async |ctx| {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
})),
)
.await
})
.await?;
match ret {
SubcommandReturnValue::RegisterAccount { account_id } => Ok(account_id),
other => bail!("expected RegisterAccount, got {other:?}"),
}
}