Merge origin/main into marvin/keycard-privacy-commands

Brings in keycard-commands (merged as PR #451) plus all subsequent main
commits (bench tools, test_fixtures, faucet/audit fixes, CI updates).

Conflict resolution:
- keycard_wallet/: kept HEAD throughout (S-padding fix, zeroize, private
  key methods, get_public_account_id_for_path_with_connect naming)
- wallet/src/signing.rs: kept HEAD (add_required/add_optional names,
  KeycardSessionContext)
- wallet/src/lib.rs: kept HEAD (ExecutionFailureKind::from_anyhow helper)
- wallet/src/cli/mod.rs: kept HEAD (key_path() method)
- wallet/src/program_facades/native_token_transfer/public.rs: kept HEAD
  (main's register_account references undefined nonces)
- Cargo.toml: HEAD + added test_fixtures/tools members and criterion dep
  from main; kept zeroize workspace dep
- docs/keycard.md: merged both (HEAD content + main's Testing/SigningGroups
  sections; added wallet_with_keycard.sh mention)
This commit is contained in:
Marvin Jones 2026-05-22 10:57:16 -04:00
commit ada4bf3e0a
86 changed files with 5342 additions and 1043 deletions

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.

1379
Cargo.lock generated

File diff suppressed because it is too large Load Diff

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",
@ -156,6 +161,7 @@ clap = { version = "4.5.42", features = ["derive", "env"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
pyo3 = { version = "0.24", features = ["auto-initialize"] }
zeroize = "1"
criterion = { version = "0.8", features = ["html_reports"] }
# Profile for leptos WASM release builds
[profile.wasm-release]

View File

@ -23,6 +23,12 @@ test:
@echo "🧪 Running tests"
RISC0_DEV_MODE=1 cargo nextest run --no-fail-fast
# Run criterion benches: fast crypto primitives, then the slow PPE verify (real proving setup).
bench:
@echo "📊 Running criterion benches"
cargo bench -p crypto_primitives_bench --bench primitives
cargo bench -p cycle_bench --features ppe --bench verify
# Run Bedrock node in docker
[working-directory: 'bedrock']
run-bedrock:

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

View File

@ -27,7 +27,7 @@ Installation:
- **Important:** keycard can only connect with one application at a time; if Keycard-Desktop is using keycard then Wallet CLI cannot access the same keycard, and vice-versa.
## Wallet with Keycard
Keycard functionality is available to Wallet CLI by setting up the following Python virtual environment:
Keycard functionality is available to Wallet CLI by setting up the following Python virtual environment. The steps below can also be run via `keycard_wallet/wallet_with_keycard.sh`.
```bash
# Install appropriate version of `keycard-py`.
@ -469,4 +469,28 @@ wallet ata burn \
Keycard PIN:
Transaction hash is l4o6n9j1038m7i56kn2i175m4njj36o1ip9345930mn61loo95j3o8jm5k0o8o22
Transaction data is ...
```
## Testing
Tests for Keycard commands are in `keycard_wallet/tests/keycard_tests.sh`. Run from the repo root with a Keycard connected:
```bash
bash keycard_wallet/tests/keycard_tests.sh
```
## SigningGroups
`SigningGroups` (`wallet/src/signing.rs`) partitions a transaction's signers into two buckets — local accounts and Keycard accounts. This ensures that Python GIL is only used at most once per transaction, regardless of how many Keycard accounts are involved.
Local signers are resolved and signed in pure Rust. Keycard signers store only their BIP32 key path; all of them are signed inside a single Python session (`connect` / `close_session`) when `sign_all` is called. The command calls `needs_pin` to decide whether to prompt for a PIN before signing.
Foreign recipient accounts — those with no local key and no Keycard path — are silently skipped and require neither a signature nor a nonce.
```
SigningGroups {
local: [(AccountId, PrivateKey)], // signed in pure Rust
keycard: [(AccountId, BIP32Path)], // signed via a single Python/Keycard session
}
```
```

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,56 @@
# crypto_primitives_bench
Cryptographic primitives used by client/wallet code. Measures the per-call cost of key derivation, sender-side DH for note encryption, and Account note symmetric encrypt/decrypt. Standalone host binary, no live stack required.
## Machine
| Field | Value |
|---|---|
| Chip | Apple M2 Pro (8P+4E) |
| RAM | 16 GB |
| OS | macOS 15.5 |
| Rust | 1.94.0 |
| Profile | release |
## Results
Criterion sample_size = 50, warm_up_time = 2 s, measurement_time = 10 s. Slope-regression point estimate in the middle column; 95% confidence interval bounds in the outer columns.
| Operation | low | point | high | outliers (mild + severe) |
|---|---:|---:|---:|---:|
| keychain/new_os_random | 3.11 ms | 3.21 ms | 3.34 ms | 3 + 5 |
| keychain/new_mnemonic | 3.05 ms | 3.11 ms | 3.23 ms | 0 + 2 |
| shared_secret_key/sender_dh | 76.7 µs | 78.4 µs | 80.6 µs | 3 + 4 |
| encryption/encrypt | 1.11 µs | 1.17 µs | 1.25 µs | 1 + 5 |
| encryption/decrypt | 907 ns | 928 ns | 954 ns | 0 + 3 |
Numbers from a single M2 Pro dev box. For full estimates (slope, mean, median, MAD, std-dev) and the noise model, see `target/criterion/<group>/<bench>/estimates.json` after running locally.
## Findings
- Keychain creation is dominated by the 2048-round HMAC-SHA512 PBKDF in the mnemonic-to-SSK path. ≈ 3 ms.
- Per-recipient DH (secp256k1) is ≈ 80 µs. Outbound shielded transfers to N recipients cost ≈ 80·N µs of crypto on top of proving.
- Symmetric encrypt/decrypt over a 49-byte Account note is sub-µs. Bulk encryption is not the bottleneck.
## Reproduce
```sh
cargo bench -p crypto_primitives_bench --bench primitives
```
JSON estimates: `target/criterion/<group>/<bench>/estimates.json`. HTML report: `target/criterion/report/index.html`.
## Baseline comparison
```sh
# On main:
cargo bench -p crypto_primitives_bench --bench primitives -- --save-baseline main
# On your branch:
cargo bench -p crypto_primitives_bench --bench primitives -- --baseline main
```
Criterion reports per-bench change as a percentage with a 95% confidence interval; deltas within the CI are reported as "no significant change" rather than red.
## Caveats
- Single-thread, no SIMD acceleration. Bench dev box uses the pure-Rust secp256k1 backend.

View File

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

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

@ -11,7 +11,7 @@
use std::time::{Duration, Instant};
use anyhow::Result;
use anyhow::{Context as _, Result};
use bytesize::ByteSize;
use common::transaction::NSSATransaction;
use integration_tests::{TestContext, config::SequencerPartialConfig};
@ -66,7 +66,64 @@ impl TpsTestManager {
Duration::from_secs_f64(number_transactions as f64 / self.target_tps as f64)
}
/// Claim funds from each account's vault PDA into the account itself.
///
/// `GenesisAction::SupplyAccount` funds vault PDAs (not accounts directly), so this step is
/// required before sending `authenticated_transfer` transactions from these accounts.
/// All claim transactions are submitted at once and then confirmed sequentially.
/// After this call every account has nonce 1, so `build_public_txs` must be called after it.
pub async fn claim_vault_funds(
&self,
sequencer_client: &sequencer_service_rpc::SequencerClient,
) -> Result<()> {
let vault_program_id = Program::vault().id();
let mut tx_hashes = Vec::with_capacity(self.public_keypairs.len());
for (private_key, account_id) in &self.public_keypairs {
let owner_vault_id =
vault_core::compute_vault_account_id(vault_program_id, *account_id);
let message = putx::Message::try_new(
vault_program_id,
vec![*account_id, owner_vault_id],
vec![Nonce(0_u128)],
vault_core::Instruction::Claim { amount: 10 },
)
.context("Failed to build vault claim message")?;
let witness_set =
nssa::public_transaction::WitnessSet::for_message(&message, &[private_key]);
let tx = PublicTransaction::new(message, witness_set);
let hash = sequencer_client
.send_transaction(NSSATransaction::Public(tx))
.await
.context("Failed to submit vault claim")?;
tx_hashes.push(hash);
}
let deadline = Instant::now() + Duration::from_secs(300);
for (i, tx_hash) in tx_hashes.iter().enumerate() {
loop {
anyhow::ensure!(
Instant::now() < deadline,
"Vault claims timed out after 5 minutes ({i}/{} confirmed)",
tx_hashes.len()
);
let found = sequencer_client
.get_transaction(*tx_hash)
.await
.ok()
.flatten()
.is_some();
if found {
break;
}
}
}
Ok(())
}
/// Build a batch of public transactions to submit to the node.
///
/// Must be called after `claim_vault_funds`, which sets each account's nonce to 1.
pub fn build_public_txs(&self) -> Vec<PublicTransaction> {
// Create valid public transactions
let program = Program::authenticated_transfer_program();
@ -78,7 +135,7 @@ impl TpsTestManager {
let message = putx::Message::try_new(
program.id(),
[pair[0].1, pair[1].1].to_vec(),
[Nonce(0_u128)].to_vec(),
[Nonce(1_u128)].to_vec(),
authenticated_transfer_core::Instruction::Transfer { amount },
)
.unwrap();
@ -127,6 +184,12 @@ pub async fn tps_test() -> Result<()> {
.build()
.await?;
// Genesis funds vault PDAs, not accounts directly. Claim into accounts before measuring.
tps_test
.claim_vault_funds(ctx.sequencer_client())
.await
.context("Failed to claim vault funds for TPS accounts")?;
let target_time = tps_test.target_time();
info!(
"TPS test begin. Target time is {target_time:?} for {num_transactions} transactions ({target_tps} TPS)"

View File

@ -0,0 +1,81 @@
#!/bin/bash
# Run wallet_with_keycard.sh first
source venv/bin/activate # Load the appropriate virtual environment
export KEYCARD_PIN=111111
# Tests wallet keycard available
# - Checks whether smart reader and keycard are both available.
echo "Test: wallet keycard available"
wallet keycard available
# Install a new mnemonic phrase to keycard
echo "Test: wallet keycard load"
export KEYCARD_MNEMONIC="fashion degree mountain wool question damp current pond grow dolphin chronic then"
wallet keycard load
unset KEYCARD_MNEMONIC
echo "Test: wallet auth-transfer init --account-id \"m/44'/60'/0'/0/0\""
wallet auth-transfer init --account-id "m/44'/60'/0'/0/0"
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\""
wallet account get --account-id "m/44'/60'/0'/0/0"
echo "Test: wallet pinata claim --to \"m/44'/60'/0'/0/0\""
wallet pinata claim --to "m/44'/60'/0'/0/0"
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\""
wallet account get --account-id "m/44'/60'/0'/0/0"
echo "Test: wallet auth-transfer init and send between two keycard accounts"
wallet auth-transfer init --account-id "m/44'/60'/0'/0/1"
wallet auth-transfer send --amount 40 --from "m/44'/60'/0'/0/0" --to "m/44'/60'/0'/0/1"
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\""
wallet account get --account-id "m/44'/60'/0'/0/0"
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/1\""
wallet account get --account-id "m/44'/60'/0'/0/1"
# Send from keycard account to a local wallet account
echo "Test: create local wallet account"
LOCAL_ACCOUNT_ID=$(wallet account new public 2>&1 | grep -oP '(?<=Public/)\S+')
echo "Created local account: Public/${LOCAL_ACCOUNT_ID}"
echo "Test: wallet auth-transfer init local account"
wallet auth-transfer init --account-id "Public/${LOCAL_ACCOUNT_ID}"
echo "Test: wallet auth-transfer send from keycard to local account"
wallet auth-transfer send --amount 10 --from "m/44'/60'/0'/0/0" --to "Public/${LOCAL_ACCOUNT_ID}"
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\""
wallet account get --account-id "m/44'/60'/0'/0/0"
echo "Test: wallet account get --account-id \"Public/${LOCAL_ACCOUNT_ID}\""
wallet account get --account-id "Public/${LOCAL_ACCOUNT_ID}"
# Create a local wallet account, fund it, and send to keycard account (co-signed: local key + keycard)
echo "Test: wallet auth-transfer send from local account to keycard account"
wallet auth-transfer send --amount 10 --from "Public/${LOCAL_ACCOUNT_ID}" --to "m/44'/60'/0'/0/1"
echo "Test: wallet account get --account-id \"Public/${LOCAL_ACCOUNT_ID}\""
wallet account get --account-id "Public/${LOCAL_ACCOUNT_ID}"
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/1\""
wallet account get --account-id "m/44'/60'/0'/0/1"
# Send from keycard account to a local wallet account (foreign recipient — no signature needed)
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\""
wallet account get --account-id "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo"
echo "Test: wallet auth-transfer send from keycard to local account"
wallet auth-transfer send --amount 10 --from "m/44'/60'/0'/0/0" --to "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo"
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\""
wallet account get --account-id "m/44'/60'/0'/0/0"
echo "Test: wallet account get --account-id \"m/44'/60'/0'/0/0\""
wallet account get --account-id "Public/7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo"

View File

@ -0,0 +1,12 @@
#!/bin/bash
cargo install --path wallet --force
# Install appropriate version of `keycard-py`.
git clone --branch lee-schnorr --single-branch https://github.com/bitgamma/keycard-py.git keycard_wallet/python/keycard-py
# Set up virtual environment.
python3 -m venv venv
source venv/bin/activate
pip install pyscard mnemonic ecdsa pyaes
pip install -e keycard_wallet/python/keycard-py

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

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

@ -0,0 +1,506 @@
//! Shared test/bench fixtures: spins up bedrock + sequencer + indexer + wallet
//! end-to-end against docker-compose, exposes a `TestContext` callers can drive.
use std::{net::SocketAddr, path::Path, sync::LazyLock};
use anyhow::{Context as _, Result};
use common::{HashType, transaction::NSSATransaction};
use futures::FutureExt as _;
use indexer_service::IndexerHandle;
use log::{debug, error};
use nssa::{AccountId, PrivacyPreservingTransaction};
use nssa_core::Commitment;
use sequencer_core::config::GenesisAction;
use sequencer_service::SequencerHandle;
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use serde::Serialize;
use tempfile::TempDir;
use testcontainers::compose::DockerCompose;
use wallet::{WalletCore, account::AccountIdWithPrivacy, cli::CliAccountMention};
use crate::{
indexer_client::IndexerClient,
setup::{
setup_bedrock_node, setup_indexer, setup_private_accounts_with_initial_supply,
setup_public_accounts_with_initial_supply, setup_sequencer, setup_wallet,
},
};
pub mod config;
pub mod indexer_client;
pub mod setup;
// TODO: Remove this and control time from tests
pub const TIME_TO_WAIT_FOR_BLOCK_SECONDS: u64 = 12;
pub const NSSA_PROGRAM_FOR_TEST_DATA_CHANGER: &str = "data_changer.bin";
pub const NSSA_PROGRAM_FOR_TEST_NOOP: &str = "noop.bin";
pub const NSSA_PROGRAM_FOR_TEST_PDA_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();
// Wallet genesis must always be present so that
// setup_public/private_accounts_with_initial_supply can claim from the vault PDAs.
// When a test supplies custom genesis, merge rather than replace.
let wallet_genesis =
config::genesis_from_accounts(&initial_public_accounts, &initial_private_accounts);
let genesis = match genesis_transactions {
Some(mut custom) => {
custom.extend(wallet_genesis);
custom
}
None => wallet_genesis,
};
let (sequencer_handle, temp_sequencer_dir) = setup_sequencer(
sequencer_partial_config.unwrap_or_default(),
bedrock_addr,
genesis,
)
.await
.context("Failed to setup Sequencer")?;
let (mut wallet, temp_wallet_dir, wallet_password) = setup_wallet(
sequencer_handle.addr(),
&initial_public_accounts,
&initial_private_accounts,
)
.context("Failed to setup wallet")?;
setup_public_accounts_with_initial_supply(&wallet, &initial_public_accounts)
.await
.context("Failed to initialize public accounts in wallet")?;
setup_private_accounts_with_initial_supply(&mut wallet, &initial_private_accounts)
.await
.context("Failed to initialize private accounts in wallet")?;
let sequencer_url = config::addr_to_url(config::UrlProtocol::Http, sequencer_handle.addr())
.context("Failed to convert sequencer addr to URL")?;
let sequencer_client = SequencerClientBuilder::default()
.build(sequencer_url)
.context("Failed to create sequencer client")?;
Ok(TestContext {
sequencer_client,
wallet,
wallet_password,
bedrock_compose,
bedrock_addr,
sequencer_handle: Some(sequencer_handle),
indexer_components,
temp_sequencer_dir,
temp_wallet_dir,
})
}
pub fn build_blocking(self) -> Result<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,19 @@
[package]
name = "crypto_primitives_bench"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
publish = false
[lints]
workspace = true
[dev-dependencies]
key_protocol.workspace = true
nssa_core = { workspace = true, features = ["host"] }
rand = { workspace = true }
criterion.workspace = true
[[bench]]
name = "primitives"
harness = false

View File

@ -0,0 +1,29 @@
# crypto_primitives_bench
Criterion-driven microbenchmarks for the cryptographic primitives client/wallet code uses on every transaction. No live sequencer or Bedrock needed.
## Run
```sh
cargo bench -p crypto_primitives_bench --bench primitives
```
## What you'll see
Criterion's per-operation report (point estimate, 95% CI, outlier counts) for:
- `keychain/new_os_random`: full mnemonic → SSK → NSK/VSK + public-key derivation (HMAC-SHA512 PBKDF dominates).
- `keychain/new_mnemonic`: same pipeline, mnemonic exposed.
- `shared_secret_key/sender_dh`: secp256k1 ECDH per recipient (includes ephemeral key gen).
- `encryption/encrypt` / `decrypt`: ChaCha20 over an Account note.
Per-bench JSON estimates are written under `target/criterion/<group>/<bench>/`. HTML reports at `target/criterion/report/index.html`.
## Baseline comparison
```sh
# On main:
cargo bench -p crypto_primitives_bench --bench primitives -- --save-baseline main
# On your branch:
cargo bench -p crypto_primitives_bench --bench primitives -- --baseline main
```

View File

@ -0,0 +1,91 @@
//! Criterion microbenchmarks for client/wallet cryptographic primitives.
//!
//! Measures:
//! - `KeyChain::new_os_random` (mnemonic → SSK → NSK/VSK + public keys)
//! - `KeyChain::new_mnemonic` (same, but mnemonic exposed)
//! - `SharedSecretKey::new` (Diffie-Hellman shared key derivation, the per-recipient cost)
//! - `EncryptionScheme::encrypt` / `decrypt` (Account note encryption)
use std::time::Duration;
use criterion::{Criterion, criterion_group, criterion_main};
use key_protocol::key_management::KeyChain;
use nssa_core::{
Commitment, EncryptionScheme, SharedSecretKey,
account::{Account, AccountId},
encryption::{EphemeralPublicKey, EphemeralSecretKey},
program::PrivateAccountKind,
};
use rand::{RngCore as _, rngs::OsRng};
fn bench_keychain(c: &mut Criterion) {
let mut g = c.benchmark_group("keychain");
g.sample_size(50).noise_threshold(0.05);
g.bench_function("new_os_random", |b| b.iter(KeyChain::new_os_random));
g.bench_function("new_mnemonic", |b| {
b.iter(|| {
let (_kc, _mnemonic) = KeyChain::new_mnemonic("");
});
});
g.finish();
}
fn bench_shared_secret_key(c: &mut Criterion) {
// One-time setup: recipient's viewing public key (sender side bench).
let recipient_kc = KeyChain::new_os_random();
let vpk = recipient_kc.viewing_public_key;
let mut g = c.benchmark_group("shared_secret_key");
g.sample_size(50).noise_threshold(0.05);
g.bench_function("sender_dh", |b| {
b.iter(|| {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let esk: EphemeralSecretKey = bytes;
let _epk = EphemeralPublicKey::from(&esk);
SharedSecretKey::new(esk, &vpk)
});
});
g.finish();
}
fn bench_encryption(c: &mut Criterion) {
// One-time setup: a fixed Account/Commitment and a SharedSecretKey to bench
// encrypt/decrypt over a representative note. ESK gen is excluded from the
// measured loop (covered by the SharedSecretKey bench above).
let recipient_kc = KeyChain::new_os_random();
let vpk = recipient_kc.viewing_public_key;
let npk = recipient_kc.nullifier_public_key;
let account = Account::default();
let account_id = AccountId::for_regular_private_account(&npk, 0);
let commitment = Commitment::new(&account_id, &account);
let shared = {
let mut bytes = [0_u8; 32];
OsRng.fill_bytes(&mut bytes);
let esk: EphemeralSecretKey = bytes;
SharedSecretKey::new(esk, &vpk)
};
let kind = PrivateAccountKind::Regular(0_u128);
let output_index: u32 = 0;
let mut g = c.benchmark_group("encryption");
g.sample_size(50).noise_threshold(0.05);
g.bench_function("encrypt", |b| {
b.iter(|| EncryptionScheme::encrypt(&account, &kind, &shared, &commitment, output_index));
});
// One ciphertext for the decrypt bench (encrypt is deterministic given inputs).
let ct = EncryptionScheme::encrypt(&account, &kind, &shared, &commitment, output_index);
g.bench_function("decrypt", |b| {
b.iter(|| EncryptionScheme::decrypt(&ct, &shared, &commitment, output_index));
});
g.finish();
}
criterion_group! {
name = benches;
config = Criterion::default()
.warm_up_time(Duration::from_secs(2))
.measurement_time(Duration::from_secs(10));
targets = bench_keychain, bench_shared_secret_key, bench_encryption
}
criterion_main!(benches);

View File

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

View File

@ -0,0 +1,44 @@
# cycle_bench
Per-program Risc0 cycle counts, prover wall time, PPE composition cost, and verifier wall time for the built-in LEZ programs. Feeds the fee model (`G_executor`, `G_prove`, `G_verify`, `S_agg`).
## Run
The binary handles executor cycles, prover wall time, and PPE composition cost:
```sh
# Executor cycles only (fast, ~seconds)
cargo run --release -p cycle_bench
# + real proving per program (slow, ~minutes)
cargo run --release -p cycle_bench --features prove -- --prove
# + PPE composition cases (very slow, ~hour)
cargo run --release -p cycle_bench --features ppe -- --prove --ppe
```
The verifier microbenchmark (`G_verify`) lives in a criterion bench under `benches/verify.rs`:
```sh
# Generates one PPE receipt for auth_transfer Transfer (~minutes of setup),
# then times Receipt::verify under criterion's statistical sampler.
cargo bench -p cycle_bench --features ppe --bench verify
```
`RISC0_DEV_MODE=1` skips proving entirely and is only useful for the executor path. The bin writes to `target/cycle_bench.json`; criterion writes per-bench estimates under `target/criterion/`.
## What you'll see
- Per-program executor cycles and segments, plus exec wall time as `best / mean ± stdev (n=N)`.
- With `--prove`: prover total cycles, paging cycles, segments, and wall time.
- With `--ppe`: end-to-end `execute_and_prove` wall time and `S_agg` (the borsh-serialized InnerReceipt length) for one auth-transfer-in-PPE case and a chain-caller depth sweep.
- From the `verify` criterion bench: `ppe/verify_auth_transfer` slope-regression point estimate with 95% CI bounds.
## Baseline comparison (verify bench)
```sh
# On main:
cargo bench -p cycle_bench --features ppe --bench verify -- --save-baseline main
# On your branch:
cargo bench -p cycle_bench --features ppe --bench verify -- --baseline main
```

View File

@ -0,0 +1,47 @@
//! Criterion bench for `Receipt::verify(PRIVACY_PRESERVING_CIRCUIT_ID)`.
//!
//! Produces the `G_verify` fee-model parameter. Setup: one full PPE prove of an
//! `auth_transfer` Transfer (minutes, runs once outside the timed loop). Measured
//! op: `Receipt::verify` over a real PPE receipt.
//!
//! Run with: `cargo bench -p cycle_bench --features ppe --bench verify`.
use std::{hint::black_box, time::Duration};
use anyhow::Context as _;
use criterion::{Criterion, criterion_group, criterion_main};
use cycle_bench::ppe::prove_auth_transfer_in_ppe;
use nssa::program_methods::PRIVACY_PRESERVING_CIRCUIT_ID;
use risc0_zkvm::{InnerReceipt, Receipt};
fn bench_verify(c: &mut Criterion) {
let (output, proof) = prove_auth_transfer_in_ppe().expect("prove auth_transfer in PPE");
let journal = output.to_bytes();
let proof_bytes = proof.into_inner();
let inner: InnerReceipt = borsh::from_slice(&proof_bytes)
.context("decode InnerReceipt")
.expect("InnerReceipt deserialize");
let receipt = Receipt::new(inner, journal);
// Sanity check before the timed loop.
receipt
.verify(PRIVACY_PRESERVING_CIRCUIT_ID)
.expect("verify sanity check");
let mut g = c.benchmark_group("ppe");
g.sample_size(100)
.warm_up_time(Duration::from_secs(2))
.measurement_time(Duration::from_secs(15))
.noise_threshold(0.05);
g.bench_function("verify_auth_transfer", |b| {
b.iter(|| {
receipt
.verify(black_box(PRIVACY_PRESERVING_CIRCUIT_ID))
.expect("verify failed mid-loop");
});
});
g.finish();
}
criterion_group!(benches, bench_verify);
criterion_main!(benches);

View File

@ -0,0 +1,23 @@
//! `cycle_bench` library: per-program executor/prover cycle measurement helpers
//! shared between the `cycle_bench` binary and the `verify` criterion bench.
#![expect(
clippy::arithmetic_side_effects,
clippy::as_conversions,
clippy::cast_precision_loss,
clippy::float_arithmetic,
clippy::print_literal,
clippy::print_stdout,
reason = "Bench library: stats arithmetic and table printing are bench-style"
)]
#![cfg_attr(
feature = "ppe",
expect(
clippy::arbitrary_source_item_ordering,
clippy::print_stderr,
reason = "PPE module: re-export ordering and eprintln progress trip strict lints"
)
)]
pub mod ppe;
pub mod stats;

View File

@ -0,0 +1,597 @@
//! Measures Risc0 user cycles per built-in program instruction.
//!
//! Runs each guest ELF through the Risc0 executor (no proving) with realistic inputs
//! drawn from the existing per-program unit tests, then prints a table and writes a
//! JSON dump for regression comparison.
//!
//! Run with `cargo run --release -p cycle_bench`. `RISC0_DEV_MODE` has no effect on
//! executor cycle counts.
#![expect(
clippy::arithmetic_side_effects,
clippy::float_arithmetic,
clippy::missing_const_for_fn,
clippy::non_ascii_literal,
clippy::print_stderr,
clippy::print_stdout,
reason = "Bench tool: matches test-style fixture code"
)]
use std::{path::PathBuf, time::Instant};
use amm_core::{PoolDefinition, compute_liquidity_token_pda, compute_pool_pda, compute_vault_pda};
use anyhow::Result;
use ata_core::{compute_ata_seed, get_associated_token_account_id};
use clap::Parser;
use clock_core::{
CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID,
ClockAccountData,
};
use cycle_bench::{ppe, stats::Stats};
use nssa::program_methods::{
AMM_ELF, AMM_ID, ASSOCIATED_TOKEN_ACCOUNT_ELF, ASSOCIATED_TOKEN_ACCOUNT_ID,
AUTHENTICATED_TRANSFER_ELF, AUTHENTICATED_TRANSFER_ID, CLOCK_ELF, CLOCK_ID, TOKEN_ELF,
TOKEN_ID,
};
use nssa_core::{
Timestamp,
account::{Account, AccountId, AccountWithMetadata, Data},
program::{InstructionData, ProgramId},
};
use risc0_zkvm::{ExecutorEnv, default_executor, default_prover};
use serde::Serialize;
use token_core::{TokenDefinition, TokenHolding};
#[derive(Parser, Debug)]
#[command(about = "Per-program executor and (optionally) prover cycle measurements")]
struct Cli {
/// Also run prover.prove for each case and report wall time + cycles. Slow.
#[arg(long)]
prove: bool,
/// Also run privacy-preserving execution circuit (PPE) composition cases:
/// (a) single `auth_transfer` Transfer through `execute_and_prove`, (b) `chain_caller`
/// with depth N=1,3,5,9. Requires --features ppe at build time. Very slow.
#[arg(long)]
ppe: bool,
/// Iterations for executor wall-time sampling per case. First iter is
/// discarded as warmup, remaining N feed the stats.
#[arg(long, default_value_t = 5)]
exec_iters: usize,
}
#[derive(Debug, Serialize)]
struct BenchResult {
program: &'static str,
instruction: &'static str,
user_cycles: u64,
segments: usize,
exec_stats: Stats,
/// Stats over prover.prove(env, elf) wall-clock samples. Only populated when --prove is set.
/// Single-sample (n=1) when --prove is on without explicit repetition, since proving is slow.
prove_stats: Option<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(),
&authenticated_transfer_core::Instruction::Transfer { amount: 5_000 },
)?,
Case::new(
"authenticated_transfer",
"Initialize",
AUTHENTICATED_TRANSFER_ELF,
AUTHENTICATED_TRANSFER_ID,
authenticated_transfer_init(),
&authenticated_transfer_core::Instruction::Initialize,
)?,
Case::new(
"token",
"Transfer",
TOKEN_ELF,
TOKEN_ID,
token_transfer_pre_states(),
&token_core::Instruction::Transfer {
amount_to_transfer: 5_000,
},
)?,
Case::new(
"token",
"Mint",
TOKEN_ELF,
TOKEN_ID,
token_mint_pre_states(),
&token_core::Instruction::Mint {
amount_to_mint: 5_000,
},
)?,
Case::new(
"token",
"Burn",
TOKEN_ELF,
TOKEN_ID,
token_burn_pre_states(),
&token_core::Instruction::Burn {
amount_to_burn: 500,
},
)?,
Case::new(
"clock",
"Tick (block_id+1, no multiples)",
CLOCK_ELF,
CLOCK_ID,
clock_pre_states_tick_at(0),
&Timestamp::from(1_700_000_000_u64),
)?,
Case::new(
"amm",
"SwapExactInput",
AMM_ELF,
AMM_ID,
amm_swap_pre_states(),
&amm_core::Instruction::SwapExactInput {
swap_amount_in: 200,
min_amount_out: 1,
token_definition_id_in: amm_token_a_def_id(),
},
)?,
Case::new(
"amm",
"AddLiquidity",
AMM_ELF,
AMM_ID,
amm_add_liquidity_pre_states(),
&amm_core::Instruction::AddLiquidity {
min_amount_liquidity: 1,
max_amount_to_add_token_a: 400,
max_amount_to_add_token_b: 200,
},
)?,
Case::new(
"ata",
"Create",
ASSOCIATED_TOKEN_ACCOUNT_ELF,
ASSOCIATED_TOKEN_ACCOUNT_ID,
ata_create_pre_states(),
&ata_core::Instruction::Create {
ata_program_id: ASSOCIATED_TOKEN_ACCOUNT_ID,
},
)?,
];
let results: Vec<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);
}
let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()?;
let out_path = workspace_root.join("target").join("cycle_bench.json");
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let combined = serde_json::json!({
"standalone": results,
"ppe": ppe_results,
});
std::fs::write(&out_path, serde_json::to_string_pretty(&combined)?)?;
println!("\nJSON written to {}", out_path.display());
Ok(())
}
fn print_table(results: &[BenchResult], prove: bool) {
let pw = results
.iter()
.map(|r| r.program.len())
.max()
.unwrap_or(0)
.max("program".len());
let iw = results
.iter()
.map(|r| r.instruction.len())
.max()
.unwrap_or(0)
.max("instruction".len());
let cw = 12_usize;
let sw = 8_usize;
let exec_w = results
.iter()
.map(|r| r.exec_stats.to_string().len())
.max()
.unwrap_or(0)
.max("exec_ms (best / mean ± stdev)".len());
println!(
"{:<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,94 @@
//! Privacy-preserving execution (PPE) cases for `cycle_bench`.
//!
//! Composition cost is the delta between standalone `prover.prove(env, elf)` for
//! a single program (measured in the main bench) and a full `execute_and_prove`
//! that wraps the same program in the privacy circuit. Chained-call depth sweep
//! uses the `chain_caller` test program (loaded from artifacts/) with N=1, 3, 5, 9.
//!
//! `Receipt::verify(PRIVACY_PRESERVING_CIRCUIT_ID)` timings (the `G_verify` fee-model
//! parameter) are measured by the `verify` criterion bench under `benches/verify.rs`,
//! which reuses the `prove_auth_transfer_in_ppe` setup helper re-exported below.
#![allow(
dead_code,
reason = "Stubs are used when the `ppe` feature is disabled."
)]
use serde::Serialize;
#[cfg(feature = "ppe")]
mod ppe_impl;
#[cfg(feature = "ppe")]
pub use ppe_impl::prove_auth_transfer_in_ppe;
#[derive(Debug, Serialize, Clone)]
pub struct PpeBenchResult {
pub label: String,
pub chain_depth: usize,
pub prove_wall_ms: Option<f64>,
/// borsh-serialized `InnerReceipt` length (`S_agg` in the fee model).
pub proof_bytes: Option<usize>,
pub error: Option<String>,
}
#[cfg(not(feature = "ppe"))]
#[must_use]
pub const fn run_all() -> Vec<PpeBenchResult> {
Vec::new()
}
#[cfg(feature = "ppe")]
#[must_use]
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
}
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,
);
}
}

View File

@ -0,0 +1,159 @@
//! Feature-gated implementation of PPE composition benches.
//!
//! `prove_auth_transfer_in_ppe` is reused by the `verify` criterion bench under
//! `benches/verify.rs` (re-exported via `super::prove_auth_transfer_in_ppe`).
use std::{collections::HashMap, time::Instant};
use nssa::{
execute_and_prove,
privacy_preserving_transaction::circuit::{ProgramWithDependencies, Proof},
program::Program,
};
use nssa_core::{
InputAccountIdentity, PrivacyPreservingCircuitOutput,
account::{Account, AccountId, AccountWithMetadata},
program::ProgramId,
};
use risc0_zkvm::serde::to_vec;
use super::PpeBenchResult;
const AUTH_TRANSFER_ID: ProgramId = nssa::program_methods::AUTHENTICATED_TRANSFER_ID;
const AUTH_TRANSFER_ELF: &[u8] = nssa::program_methods::AUTHENTICATED_TRANSFER_ELF;
/// `chain_caller` bytecode shipped at `artifacts/test_program_methods/chain_caller.bin`.
/// Loaded at compile time so we don't need a dev-dependency on `test_program_methods`.
const CHAIN_CALLER_ELF: &[u8] =
include_bytes!("../../../../artifacts/test_program_methods/chain_caller.bin");
pub fn run_auth_transfer_in_ppe() -> PpeBenchResult {
let label = "auth_transfer Transfer in PPE".to_owned();
let started = Instant::now();
match prove_auth_transfer_in_ppe() {
Ok((_out, proof)) => {
let prove_ms = started.elapsed().as_secs_f64() * 1_000.0;
PpeBenchResult {
label,
chain_depth: 0,
prove_wall_ms: Some(prove_ms),
proof_bytes: Some(proof.into_inner().len()),
error: None,
}
}
Err(err) => PpeBenchResult {
label,
chain_depth: 0,
prove_wall_ms: None,
proof_bytes: None,
error: Some(err.to_string()),
},
}
}
pub fn prove_auth_transfer_in_ppe() -> anyhow::Result<(PrivacyPreservingCircuitOutput, Proof)> {
let program = Program::new(AUTH_TRANSFER_ELF.to_vec())?;
let pwd = ProgramWithDependencies::from(program);
// For PPE to allow the sender's balance to be decremented by this
// program, the sender must already be claimed by auth_transfer.
// Recipient stays default-owned so the first call can claim it.
let sender = AccountWithMetadata {
account: Account {
program_owner: AUTH_TRANSFER_ID,
balance: 1_000_000,
..Account::default()
},
is_authorized: true,
account_id: AccountId::new([1; 32]),
};
let recipient = AccountWithMetadata {
account: Account::default(),
is_authorized: true,
account_id: AccountId::new([2; 32]),
};
let pre_states = vec![sender, recipient];
let instruction = authenticated_transfer_core::Instruction::Transfer { amount: 5_000 };
let instruction_data = to_vec(&instruction)?;
let account_identities = vec![InputAccountIdentity::Public; pre_states.len()];
Ok(execute_and_prove(
pre_states,
instruction_data,
account_identities,
&pwd,
)?)
}
pub fn run_chain_caller(depth: u32) -> PpeBenchResult {
let label = format!("chain_caller depth={depth}");
let started = Instant::now();
match prove_chain_caller(depth) {
Ok((_out, proof)) => {
let prove_ms = started.elapsed().as_secs_f64() * 1_000.0;
PpeBenchResult {
label,
chain_depth: depth as usize,
prove_wall_ms: Some(prove_ms),
proof_bytes: Some(proof.into_inner().len()),
error: None,
}
}
Err(err) => PpeBenchResult {
label,
chain_depth: depth as usize,
prove_wall_ms: None,
proof_bytes: None,
error: Some(err.to_string()),
},
}
}
fn prove_chain_caller(
num_chain_calls: u32,
) -> anyhow::Result<(PrivacyPreservingCircuitOutput, Proof)> {
let chain_caller = Program::new(CHAIN_CALLER_ELF.to_vec())?;
let auth_transfer = Program::new(AUTH_TRANSFER_ELF.to_vec())?;
let mut deps = HashMap::new();
deps.insert(AUTH_TRANSFER_ID, auth_transfer);
let pwd = ProgramWithDependencies::new(chain_caller, deps);
// Both accounts pre-claimed by auth_transfer. chain_caller doesn't
// track recipient's post-claim program_owner, so a default recipient
// would cause a state mismatch on subsequent chained calls.
let recipient_pre = AccountWithMetadata {
account: Account {
program_owner: AUTH_TRANSFER_ID,
..Account::default()
},
is_authorized: true,
account_id: AccountId::new([2; 32]),
};
let sender_pre = AccountWithMetadata {
account: Account {
program_owner: AUTH_TRANSFER_ID,
balance: 1_000_000,
..Account::default()
},
is_authorized: true,
account_id: AccountId::new([1; 32]),
};
// chain_caller expects pre_states = [recipient, sender].
let pre_states = vec![recipient_pre, sender_pre];
let balance: u128 = 1;
let pda_seed: Option<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,
)?)
}

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:?}"),
}
}