diff --git a/.github/scripts/with_retry.sh b/.github/scripts/with_retry.sh new file mode 100644 index 000000000..fb03036c6 --- /dev/null +++ b/.github/scripts/with_retry.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -uo pipefail + +with_retry() { + local command="$1" + local max_attempts="${2:-3}" + local attempt=1 + + while (( attempt <= max_attempts )); do + if eval "$command"; then + return 0 + fi + + if (( attempt < max_attempts )); then + echo "::warning:: Attempt $attempt failed, cleaning up and retrying..." >&2 + rm -rf target/debug/deps/*.o target/debug/incremental 2>/dev/null || true + cargo clean -p integration_tests 2>/dev/null || true + sleep 5 + fi + + (( attempt++ )) + done + + echo "::error:: Command failed after $max_attempts attempts: $command" >&2 + return 1 +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b6250be8..ed66e91f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,13 +215,8 @@ jobs: image: ${{ needs.ci-image.outputs.image }} env: RISC0_DEV_MODE=1 run: | - for i in 1 2 3; do - cargo nextest archive -p integration_tests --archive-file integration-tests.tar.zst --no-pager && break - echo "::warning:: Attempt $i failed, cleaning up and retrying..." >&2 - rm -rf target/debug/deps/*.o target/debug/incremental 2>/dev/null || true - cargo clean -p integration_tests 2>/dev/null || true - sleep 5 - done + source .github/scripts/with_retry.sh + with_retry 'cargo nextest archive -p integration_tests --archive-file integration-tests.tar.zst --no-pager' 5 - name: Upload integration test archive uses: actions/upload-artifact@v4 @@ -288,7 +283,9 @@ jobs: env: | RISC0_DEV_MODE=1 RUST_LOG=info - run: cargo nextest run --archive-file integration-tests.tar.zst -E "binary(${{ matrix.target }})" + run: | + source .github/scripts/with_retry.sh + with_retry 'cargo nextest run --archive-file integration-tests.tar.zst -E "binary(${{ matrix.target }})"' 5 valid-proof-test: needs: ci-image @@ -318,7 +315,9 @@ jobs: with: image: ${{ needs.ci-image.outputs.image }} env: RUST_LOG=info - run: cargo test -p integration_tests -- --exact private::private_transfer_to_owned_account + run: | + source .github/scripts/with_retry.sh + with_retry 'cargo test -p integration_tests -- --exact private::private_transfer_to_owned_account' 5 # `just build-artifacts` drives the host's Docker daemon via `cargo risczero # build`, so it goes through the image rather than `container:`. diff --git a/Cargo.lock b/Cargo.lock index 45f8e197b..fde6b7817 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,6 +1225,7 @@ name = "bridge_lock_core" version = "0.1.0" dependencies = [ "lee_core", + "risc0-zkvm", "serde", ] @@ -2002,6 +2003,7 @@ dependencies = [ "log", "ping_core", "programs", + "rand 0.8.6", "risc0-zkvm", "sequencer_service_rpc", "serde", @@ -2298,7 +2300,7 @@ dependencies = [ "clap", "json-pretty-compact", "sequencer_core_metrics", - "sequencer_service_metrics", + "sequencer_rpc_server_actor_metrics", "serde", "serde_json", ] @@ -2326,7 +2328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -2572,6 +2574,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "downloader" version = "0.2.8" @@ -4388,8 +4396,6 @@ dependencies = [ "lee", "lee_core", "log", - "logos-blockchain-core", - "logos-blockchain-key-management-system-service", "ping_core", "programs", "risc0-zkvm", @@ -4817,6 +4823,46 @@ dependencies = [ "wnaf", ] +[[package]] +name = "kameo" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cbab7323ed30490812f43ef6416a9e21bb150e9633e451ed124ca276b1ca82a" +dependencies = [ + "downcast-rs 2.0.2", + "dyn-clone", + "futures", + "kameo_macros", + "serde", + "tokio", + "tracing", +] + +[[package]] +name = "kameo_actors" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "069ef0ae25f4da6f817ce7d81f7990b3d12c3ae398b59e6e16e37b5fcc92a443" +dependencies = [ + "futures", + "glob", + "kameo", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "kameo_macros" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7566055976eb86ee8e8fbafa0fbdad985c5d7c3f4eed04fc11bb495f71e3856" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "keccak" version = "0.1.6" @@ -7702,7 +7748,9 @@ dependencies = [ name = "ping_core" version = "0.1.0" dependencies = [ + "borsh", "lee_core", + "risc0-zkvm", "serde", ] @@ -7710,6 +7758,7 @@ dependencies = [ name = "ping_receiver_program" version = "0.1.0" dependencies = [ + "cross_zone_inbox_core", "lee_core", "ping_core", ] @@ -9038,7 +9087,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4382d3af3a4ebdae7f64ba6edd9114fff92c89808004c4943b393377a25d001" dependencies = [ - "downcast-rs", + "downcast-rs 1.2.1", "paste", ] @@ -9534,38 +9583,77 @@ dependencies = [ ] [[package]] -name = "sequencer_service" +name = "sequencer_executor_actor" version = "0.1.0" dependencies = [ "anyhow", - "borsh", "bytesize", - "clap", "common", "env_logger", - "futures", "hex", - "jsonrpsee", + "kameo", "lee", + "lee_core", "log", "mempool", - "metrics-exporter-prometheus", - "programs", + "num-bigint 0.4.6", "sequencer_core", - "sequencer_service_metrics", - "sequencer_service_protocol", - "sequencer_service_rpc", + "storage", + "tempfile", + "test_programs", + "thiserror 2.0.18", "tokio", "tokio-util", ] [[package]] -name = "sequencer_service_metrics" +name = "sequencer_rpc_server_actor" +version = "0.1.0" +dependencies = [ + "borsh", + "bytesize", + "common", + "jsonrpsee", + "kameo", + "lee", + "log", + "programs", + "sequencer_core", + "sequencer_executor_actor", + "sequencer_rpc_server_actor_metrics", + "sequencer_service_protocol", + "sequencer_service_rpc", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "sequencer_rpc_server_actor_metrics" version = "0.1.0" dependencies = [ "metrics", ] +[[package]] +name = "sequencer_service" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "env_logger", + "futures", + "hex", + "kameo", + "kameo_actors", + "log", + "metrics-exporter-prometheus", + "sequencer_core", + "sequencer_executor_actor", + "sequencer_rpc_server_actor", + "tokio", + "tokio-util", +] + [[package]] name = "sequencer_service_protocol" version = "0.1.0" @@ -9574,6 +9662,7 @@ dependencies = [ "hex", "lee", "lee_core", + "serde", "serde_with", ] @@ -10710,6 +10799,7 @@ dependencies = [ "signal-hook-registry", "socket2 0.6.4", "tokio-macros", + "tracing", "windows-sys 0.61.2", ] @@ -11575,6 +11665,7 @@ version = "0.1.0" dependencies = [ "bip39", "cbindgen", + "common", "key_protocol", "lee", "lee_core", @@ -12255,6 +12346,7 @@ dependencies = [ name = "wrapped_token_core" version = "0.1.0" dependencies = [ + "borsh", "lee_core", "risc0-zkvm", "serde", @@ -12264,6 +12356,7 @@ dependencies = [ name = "wrapped_token_program" version = "0.1.0" dependencies = [ + "cross_zone_inbox_core", "lee_core", "wrapped_token_core", ] diff --git a/Cargo.toml b/Cargo.toml index fe44781f9..ce2a6e5e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,9 @@ members = [ "lez/sequencer/service", "lez/sequencer/service/protocol", "lez/sequencer/service/rpc", - "lez/sequencer/service/metrics", + "lez/sequencer/actors/executor", + "lez/sequencer/actors/rpc_server", + "lez/sequencer/actors/rpc_server/metrics", "lez/indexer/core", "lez/indexer/service", "lez/indexer/service/protocol", @@ -84,7 +86,9 @@ sequencer_core = { path = "lez/sequencer/core" } sequencer_core_metrics = { path = "lez/sequencer/core/metrics" } sequencer_service_protocol = { path = "lez/sequencer/service/protocol" } sequencer_service_rpc = { path = "lez/sequencer/service/rpc" } -sequencer_service_metrics = { path = "lez/sequencer/service/metrics" } +sequencer_executor_actor = { path = "lez/sequencer/actors/executor" } +sequencer_rpc_server_actor = { path = "lez/sequencer/actors/rpc_server" } +sequencer_rpc_server_actor_metrics = { path = "lez/sequencer/actors/rpc_server/metrics" } sequencer_service = { path = "lez/sequencer/service" } indexer_core = { path = "lez/indexer/core" } indexer_service = { path = "lez/indexer/service" } @@ -130,6 +134,8 @@ tokio = { version = "1.50", features = [ tokio-util = "0.7.18" risc0-zkvm = { version = "3.0.5", default-features = false, features = ['std'] } risc0-build = "3.0.5" +kameo = "0.22.2" +kameo_actors = "0.8.1" anyhow = "1.0.98" derive_more = "2.1.1" num_cpus = "1.13.1" @@ -353,6 +359,8 @@ clippy.let-underscore-untyped = "allow" # Reason: this lint is actually bad as it forces to use wildcard `..` instead of # field-by-field `_` which may lead to subtle bugs when new fields are added to the struct. clippy.unneeded-field-pattern = "allow" +# Reason: this lint makes no sense for us. +clippy.error_impl_error = "allow" # Nursery clippy.nursery = { level = "deny", priority = -1 } diff --git a/Justfile b/Justfile index 4741f6c84..607b92817 100644 --- a/Justfile +++ b/Justfile @@ -155,7 +155,7 @@ cross-zone-chat: clean: @echo "🧹 Cleaning run artifacts" rm -rf lez/sequencer/service/bedrock_signing_key - rm -rf lez/sequencer/service/rocksdb + rm -rf lez/sequencer/service/rocksdb* rm -rf lez/indexer/service/rocksdb* rm -rf lez/wallet/configs/debug/storage.json rm -rf lez/wallet/configs/debug/statistics.json diff --git a/README.md b/README.md index 401fff157..79a624ba9 100644 --- a/README.md +++ b/README.md @@ -169,9 +169,9 @@ The sequencer and logos blockchain node can be run locally: After stopping services above you need to remove 3 folders to start cleanly: 1. In the `logos-blockchain/logos-blockchain` folder `state` (not needed in case of docker setup) - 2. In the `logos-execution-zone` folder `lez/sequencer/service/rocksdb` + 2. In the `logos-execution-zone` folder `lez/sequencer/service/rocksdb-` 3. In the `logos-execution-zone` file `lez/sequencer/service/bedrock_signing_key` - 4. In the `logos-execution-zone` folder `lez/indexer/service/rocksdb` + 4. In the `logos-execution-zone` folder `lez/indexer/service/rocksdb-` ### Normal mode (`just` commands) We provide a `Justfile` for developer and user needs, you can run the whole setup with it. The only difference will be that logos-blockchain (bedrock) will be started from docker. diff --git a/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin b/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin index 639da90ac..69cfcd940 100644 Binary files a/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin and b/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin differ diff --git a/artifacts/lez/programs/amm.bin b/artifacts/lez/programs/amm.bin index e67b8a693..2fdf142e7 100644 Binary files a/artifacts/lez/programs/amm.bin and b/artifacts/lez/programs/amm.bin differ diff --git a/artifacts/lez/programs/associated_token_account.bin b/artifacts/lez/programs/associated_token_account.bin index 58522a1eb..60edcbbdc 100644 Binary files a/artifacts/lez/programs/associated_token_account.bin and b/artifacts/lez/programs/associated_token_account.bin differ diff --git a/artifacts/lez/programs/authenticated_transfer.bin b/artifacts/lez/programs/authenticated_transfer.bin index cd1a9b2ac..216282765 100644 Binary files a/artifacts/lez/programs/authenticated_transfer.bin and b/artifacts/lez/programs/authenticated_transfer.bin differ diff --git a/artifacts/lez/programs/bridge.bin b/artifacts/lez/programs/bridge.bin index 9c18a4e2c..c1b8d9884 100644 Binary files a/artifacts/lez/programs/bridge.bin and b/artifacts/lez/programs/bridge.bin differ diff --git a/artifacts/lez/programs/bridge_lock.bin b/artifacts/lez/programs/bridge_lock.bin index 34864afcf..ddadd4ed5 100644 Binary files a/artifacts/lez/programs/bridge_lock.bin and b/artifacts/lez/programs/bridge_lock.bin differ diff --git a/artifacts/lez/programs/clock.bin b/artifacts/lez/programs/clock.bin index 4d2165518..c47419be3 100644 Binary files a/artifacts/lez/programs/clock.bin and b/artifacts/lez/programs/clock.bin differ diff --git a/artifacts/lez/programs/cross_zone_inbox.bin b/artifacts/lez/programs/cross_zone_inbox.bin index f8ba90295..ec1da83fa 100644 Binary files a/artifacts/lez/programs/cross_zone_inbox.bin and b/artifacts/lez/programs/cross_zone_inbox.bin differ diff --git a/artifacts/lez/programs/cross_zone_outbox.bin b/artifacts/lez/programs/cross_zone_outbox.bin index c463dd5c9..ba3649f6b 100644 Binary files a/artifacts/lez/programs/cross_zone_outbox.bin and b/artifacts/lez/programs/cross_zone_outbox.bin differ diff --git a/artifacts/lez/programs/faucet.bin b/artifacts/lez/programs/faucet.bin index f9abdd3ab..399826ed6 100644 Binary files a/artifacts/lez/programs/faucet.bin and b/artifacts/lez/programs/faucet.bin differ diff --git a/artifacts/lez/programs/pinata.bin b/artifacts/lez/programs/pinata.bin index ba555a206..febc6cd86 100644 Binary files a/artifacts/lez/programs/pinata.bin and b/artifacts/lez/programs/pinata.bin differ diff --git a/artifacts/lez/programs/pinata_token.bin b/artifacts/lez/programs/pinata_token.bin index 23e71794b..d7a6844eb 100644 Binary files a/artifacts/lez/programs/pinata_token.bin and b/artifacts/lez/programs/pinata_token.bin differ diff --git a/artifacts/lez/programs/ping_receiver.bin b/artifacts/lez/programs/ping_receiver.bin index 333164164..dc2c06472 100644 Binary files a/artifacts/lez/programs/ping_receiver.bin and b/artifacts/lez/programs/ping_receiver.bin differ diff --git a/artifacts/lez/programs/ping_sender.bin b/artifacts/lez/programs/ping_sender.bin index 7ba0ba41d..0ae3175bd 100644 Binary files a/artifacts/lez/programs/ping_sender.bin and b/artifacts/lez/programs/ping_sender.bin differ diff --git a/artifacts/lez/programs/token.bin b/artifacts/lez/programs/token.bin index 40a96ac24..d91927d3e 100644 Binary files a/artifacts/lez/programs/token.bin and b/artifacts/lez/programs/token.bin differ diff --git a/artifacts/lez/programs/vault.bin b/artifacts/lez/programs/vault.bin index fa90bdbaf..5ce29680c 100644 Binary files a/artifacts/lez/programs/vault.bin and b/artifacts/lez/programs/vault.bin differ diff --git a/artifacts/lez/programs/wrapped_token.bin b/artifacts/lez/programs/wrapped_token.bin index 52a61dfe3..6633ec468 100644 Binary files a/artifacts/lez/programs/wrapped_token.bin and b/artifacts/lez/programs/wrapped_token.bin differ diff --git a/build_utils/src/lib.rs b/build_utils/src/lib.rs index 1323d830e..6753e845d 100644 --- a/build_utils/src/lib.rs +++ b/build_utils/src/lib.rs @@ -17,11 +17,19 @@ use anyhow::{Context as _, Result, bail}; /// } /// ``` pub fn include_artifacts(artifacts_sub_dir: &str) -> Result<()> { - let manifest_dir = PathBuf::from(std::env!("CARGO_MANIFEST_DIR")); + // Resolved at build-script runtime from the invoking crate, not at compile + // time: `env!` would bake in the path of whichever checkout compiled this + // rlib first, and with a shared cargo target dir every other worktree then + // embeds that checkout's artifacts instead of its own. + let invoking_manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); + let workspace_root = invoking_manifest_dir + .ancestors() + .find(|dir| dir.join("artifacts").is_dir()) + .context("no artifacts/ directory above the invoking crate")?; let out_dir = PathBuf::from(env::var("OUT_DIR")?); let mod_dir = out_dir.join(artifacts_sub_dir); let mod_file = mod_dir.join("mod.rs"); - let artifacts_dir = manifest_dir.join(format!("../artifacts/{artifacts_sub_dir}/")); + let artifacts_dir = workspace_root.join(format!("artifacts/{artifacts_sub_dir}/")); println!("cargo:rerun-if-changed={}", artifacts_dir.display()); diff --git a/examples/program_deployment/README.md b/examples/program_deployment/README.md index 240079a52..35a1cd5bf 100644 --- a/examples/program_deployment/README.md +++ b/examples/program_deployment/README.md @@ -348,9 +348,9 @@ Check the `run_hello_world_private.rs` file to see how it is used. # 8. Account authorization mechanism The Hello world example does not enforce any authorization on the input account. This means any user can execute it on any account, regardless of ownership. -LEE provides a mechanism for programs to enforce proper authorization before an execution can succeed. The meaning of authorization differs between public and private accounts: -- Public accounts: authorization requires that the transaction is signed with the account’s signing key. -- Private accounts: authorization requires that the circuit verifies knowledge of the account’s nullifier secret key. +LEE provides a mechanism for programs to enforce proper authorization before an execution can succeed. For both private and public accounts, the authorization is checked against knowledge of a secret key, yet the check is different: +- Public accounts: the transaction is signed with the account’s signing key. +- Private accounts: the circuit verifies knowledge of the account’s authorization secret key (`ask`), the key from which the account’s nullifier secret key is derived. From the program development perspective it is very simple: input accounts come with a flag indicating whether they has been properly authorized. And so, the only difference between the program `hello_world.rs` and `hello_world_with_authorization.rs` is in the lines diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index 225d75d4d..8e4991b75 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -36,8 +36,6 @@ programs.workspace = true test_programs.workspace = true testnet_initial_state.workspace = true -logos-blockchain-core.workspace = true -logos-blockchain-key-management-system-service.workspace = true anyhow.workspace = true log.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index fe07aee76..1a9783ee8 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -8,7 +8,6 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use key_protocol::key_management::key_tree::chain_index::ChainIndex; use lee::AccountId; -use log::info; use sequencer_service_rpc::RpcClient as _; pub use test_fixtures::*; use wallet::{ @@ -93,7 +92,7 @@ pub async fn send_claiming_new_account( amount, ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; Ok(()) } @@ -113,7 +112,7 @@ pub async fn create_token( total_supply, }; wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; Ok(()) } @@ -135,7 +134,7 @@ pub async fn token_send( amount, }; wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; Ok(()) } @@ -155,7 +154,7 @@ pub async fn token_send_claiming_new_account( amount, ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; Ok(()) } @@ -198,6 +197,7 @@ pub async fn sync_private(ctx: &mut TestContext) -> Result<()> { } /// Look up a restored private account for `account_id`, panicking with `label` if absent. +#[must_use] pub fn restored_private_account<'ctx>( ctx: &'ctx TestContext, account_id: AccountId, @@ -240,7 +240,7 @@ pub async fn wait_for_indexer_to_catch_up(ctx: &TestContext) -> Result { let last_seq = sequencer_service_rpc::RpcClient::get_last_block_id(ctx.sequencer_client()) .await?; - info!( + log::info!( "Indexer caught up. Indexer last block id: {ind}. Current sequencer last block id: {last_seq}" ); return Ok(ind); diff --git a/integration_tests/tests/account.rs b/integration_tests/tests/account.rs index 2b69f7e0e..70d96e107 100644 --- a/integration_tests/tests/account.rs +++ b/integration_tests/tests/account.rs @@ -8,7 +8,6 @@ use integration_tests::{TestContext, get_account, new_account, private_mention}; use key_protocol::key_management::KeyChain; use lee::Data; use lee_core::account::Nonce; -use log::info; use tokio::test; use wallet::{ account::{AccountIdWithPrivacy, HumanReadableAccount, Label}, @@ -33,7 +32,7 @@ async fn get_existing_account() -> Result<()> { assert!(account.data.is_empty()); assert_eq!(account.nonce.0, 1); - info!("Successfully retrieved account with correct details"); + log::info!("Successfully retrieved account with correct details"); Ok(()) } @@ -60,7 +59,7 @@ async fn new_public_account_with_label() -> Result<()> { assert_eq!(resolved, Some(AccountIdWithPrivacy::Public(account_id))); - info!("Successfully created public account with label"); + log::info!("Successfully created public account with label"); Ok(()) } @@ -82,7 +81,7 @@ async fn add_label_to_existing_account() -> Result<()> { assert_eq!(resolved, Some(AccountIdWithPrivacy::Private(account_id))); - info!("Successfully set label on existing private account"); + log::info!("Successfully set label on existing private account"); Ok(()) } @@ -103,7 +102,7 @@ async fn new_public_account_without_label() -> Result<()> { "No label should be stored when not provided" ); - info!("Successfully created public account without label"); + log::info!("Successfully created public account without label"); Ok(()) } diff --git a/integration_tests/tests/auth_transfer/private.rs b/integration_tests/tests/auth_transfer/private.rs index b62fe8920..3a92b8047 100644 --- a/integration_tests/tests/auth_transfer/private.rs +++ b/integration_tests/tests/auth_transfer/private.rs @@ -17,7 +17,6 @@ use lee_core::{ account::{Account, AccountWithMetadata}, encryption::ViewingPublicKey, }; -use log::info; use sequencer_service_rpc::RpcClient as _; use tokio::test; use wallet::{ @@ -38,13 +37,13 @@ async fn private_transfer_to_owned_account() -> Result<()> { send(&mut ctx, private_mention(from), private_mention(to), 100).await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; assert_private_commitment_in_state(&ctx, from, "sender").await?; assert_private_commitment_in_state(&ctx, to, "receiver").await?; - info!("Successfully transferred privately to owned account"); + log::info!("Successfully transferred privately to owned account"); Ok(()) } @@ -73,7 +72,7 @@ async fn private_transfer_to_foreign_account() -> Result<()> { anyhow::bail!("Expected TransactionExecuted return value"); }; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let new_commitment1 = ctx @@ -88,7 +87,7 @@ async fn private_transfer_to_foreign_account() -> Result<()> { assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await); } - info!("Successfully transferred privately to foreign account"); + log::info!("Successfully transferred privately to foreign account"); Ok(()) } @@ -109,7 +108,7 @@ async fn deshielded_transfer_to_public_account() -> Result<()> { send(&mut ctx, private_mention(from), public_mention(to), 100).await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let from_acc = ctx @@ -123,7 +122,7 @@ async fn deshielded_transfer_to_public_account() -> Result<()> { assert_eq!(from_acc.balance, 9900); assert_eq!(acc_2_balance, 20100); - info!("Successfully deshielded transfer to public account"); + log::info!("Successfully deshielded transfer to public account"); Ok(()) } @@ -154,7 +153,7 @@ async fn deshielded_transfer_does_not_sign_with_recipient_key() -> Result<()> { anyhow::bail!("Expected TransactionExecuted return value"); }; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await; @@ -164,7 +163,7 @@ async fn deshielded_transfer_does_not_sign_with_recipient_key() -> Result<()> { "deshielded transfer must not carry any signature, in particular not the recipient's" ); - info!("Deshielded transfer correctly did not sign with the recipient's key"); + log::info!("Deshielded transfer correctly did not sign with the recipient's key"); Ok(()) } @@ -223,7 +222,7 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> { .context("Failed to get recipient's private account")?; assert_eq!(to_res_acc.balance, 100); - info!("Successfully transferred using claiming path"); + log::info!("Successfully transferred using claiming path"); Ok(()) } @@ -237,7 +236,7 @@ async fn shielded_transfer_to_owned_private_account() -> Result<()> { send(&mut ctx, public_mention(from), private_mention(to), 100).await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let acc_to = ctx @@ -251,7 +250,7 @@ async fn shielded_transfer_to_owned_private_account() -> Result<()> { assert_eq!(acc_from_balance, 9900); assert_eq!(acc_to.balance, 20100); - info!("Successfully shielded transfer to owned private account"); + log::info!("Successfully shielded transfer to owned private account"); Ok(()) } @@ -280,7 +279,7 @@ async fn shielded_transfer_to_foreign_account() -> Result<()> { anyhow::bail!("Expected TransactionExecuted return value"); }; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await; @@ -293,7 +292,7 @@ async fn shielded_transfer_to_foreign_account() -> Result<()> { assert_eq!(acc_1_balance, 9900); - info!("Successfully shielded transfer to foreign account"); + log::info!("Successfully shielded transfer to foreign account"); Ok(()) } @@ -338,7 +337,7 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> { let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await; - info!("Waiting for next blocks to check if continuous run fetches account"); + log::info!("Waiting for next blocks to check if continuous run fetches account"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; @@ -371,7 +370,7 @@ async fn initialize_private_account() -> Result<()> { tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - info!("Syncing private accounts"); + log::info!("Syncing private accounts"); sync_private(&mut ctx).await?; assert_private_commitment_in_state(&ctx, account_id, "account").await?; @@ -388,7 +387,7 @@ async fn initialize_private_account() -> Result<()> { assert_eq!(account.balance, 0); assert!(account.data.is_empty()); - info!("Successfully initialized private account"); + log::info!("Successfully initialized private account"); Ok(()) } @@ -417,13 +416,13 @@ async fn private_transfer_using_from_label() -> Result<()> { ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; assert_private_commitment_in_state(&ctx, from, "sender").await?; assert_private_commitment_in_state(&ctx, to, "receiver").await?; - info!("Successfully transferred privately using from_label"); + log::info!("Successfully transferred privately using from_label"); Ok(()) } @@ -465,7 +464,7 @@ async fn initialize_private_account_using_label() -> Result<()> { programs::authenticated_transfer().id() ); - info!("Successfully initialized private account using label"); + log::info!("Successfully initialized private account using label"); Ok(()) } @@ -526,7 +525,7 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> { ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; sync_private(&mut ctx).await?; @@ -569,7 +568,7 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> { "both accounts must resolve to the key node created at the start of the test" ); - info!("Successfully transferred to two distinct identifiers under the same NPK"); + log::info!("Successfully transferred to two distinct identifiers under the same NPK"); Ok(()) } @@ -584,7 +583,7 @@ async fn ppt_cant_chain_call_faucet() -> Result<()> { )); ctx.sequencer_client().send_transaction(deploy_tx).await?; - info!("Waiting for deploy block creation"); + log::info!("Waiting for deploy block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let faucet_account_id = system_accounts::faucet_account_id(); @@ -592,7 +591,8 @@ async fn ppt_cant_chain_call_faucet() -> Result<()> { let faucet_program_id = programs::faucet().id(); let vault_program_id = programs::vault().id(); let auth_transfer_program_id = programs::authenticated_transfer().id(); - let nsk: lee_core::NullifierSecretKey = [3; 32]; + let ask = lee_core::AuthorizationSecretKey([3; 32]); + let nsk = lee_core::NullifierSecretKey::from(&ask); let npk = NullifierPublicKey::from(&nsk); let vpk = ViewingPublicKey::from_bytes(vec![4_u8; 1184]).unwrap(); let attacker_vault_id = { @@ -661,7 +661,8 @@ async fn prove_init_with_commitment_root( sender_id, ); - let nsk: lee_core::NullifierSecretKey = [7; 32]; + let ask = lee_core::AuthorizationSecretKey([7; 32]); + let nsk = lee_core::NullifierSecretKey::from(&ask); let npk = NullifierPublicKey::from(&nsk); let vpk = ViewingPublicKey::from_bytes(vec![4_u8; 1184]).unwrap(); let recipient_account_id = AccountId::for_regular_private_account(&npk, &vpk, 0); @@ -678,7 +679,7 @@ async fn prove_init_with_commitment_root( vpk, random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { ask: Some(ask) }, nullifier: NullifierWitness::Init { npk, commitment_root, @@ -697,7 +698,8 @@ async fn init_with_dummy_commitment_root_produces_valid_root() -> Result<()> { let (_, expected_digest) = ctx.sequencer_client().get_proofs_and_root(vec![]).await?; - let nsk: lee_core::NullifierSecretKey = [7; 32]; + let ask = lee_core::AuthorizationSecretKey([7; 32]); + let nsk = lee_core::NullifierSecretKey::from(&ask); let npk = NullifierPublicKey::from(&nsk); let vpk = ViewingPublicKey::from_bytes(vec![4_u8; 1184]).unwrap(); let recipient_account_id = AccountId::for_regular_private_account(&npk, &vpk, 0); diff --git a/integration_tests/tests/auth_transfer/public.rs b/integration_tests/tests/auth_transfer/public.rs index ea0838efd..d9d77c3cf 100644 --- a/integration_tests/tests/auth_transfer/public.rs +++ b/integration_tests/tests/auth_transfer/public.rs @@ -7,7 +7,6 @@ use integration_tests::{ public_mention, send, send_claiming_new_account, }; use lee::{PublicKey, public_transaction}; -use log::info; use sequencer_service_rpc::RpcClient as _; use tokio::test; use wallet::{ @@ -39,15 +38,15 @@ async fn successful_transfer_to_existing_account() -> Result<()> { anyhow::bail!("Expected TransactionExecuted return value"); }; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - info!("Checking correct balance move"); + log::info!("Checking correct balance move"); let acc_1_balance = account_balance(&ctx, sender).await?; let acc_2_balance = account_balance(&ctx, receiver).await?; - info!("Balance of sender: {acc_1_balance:#?}"); - info!("Balance of receiver: {acc_2_balance:#?}"); + log::info!("Balance of sender: {acc_1_balance:#?}"); + log::info!("Balance of receiver: {acc_2_balance:#?}"); assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); @@ -94,12 +93,12 @@ pub async fn successful_transfer_to_new_account() -> Result<()> { // requires it, so bypass the CLI for this one send. send_claiming_new_account(&mut ctx, sender, new_persistent_account_id, 100).await?; - info!("Checking correct balance move"); + log::info!("Checking correct balance move"); let acc_1_balance = account_balance(&ctx, sender).await?; let acc_2_balance = account_balance(&ctx, new_persistent_account_id).await?; - info!("Balance of sender: {acc_1_balance:#?}"); - info!("Balance of receiver: {acc_2_balance:#?}"); + log::info!("Balance of sender: {acc_1_balance:#?}"); + log::info!("Balance of receiver: {acc_2_balance:#?}"); assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 100); @@ -124,15 +123,15 @@ async fn failed_transfer_with_insufficient_balance() -> Result<()> { let failed_send = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await; assert!(failed_send.is_err()); - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - info!("Checking balances unchanged"); + log::info!("Checking balances unchanged"); let acc_1_balance = account_balance(&ctx, ctx.existing_public_accounts()[0]).await?; let acc_2_balance = account_balance(&ctx, ctx.existing_public_accounts()[1]).await?; - info!("Balance of sender: {acc_1_balance:#?}"); - info!("Balance of receiver: {acc_2_balance:#?}"); + log::info!("Balance of sender: {acc_1_balance:#?}"); + log::info!("Balance of receiver: {acc_2_balance:#?}"); assert_eq!(acc_1_balance, 10000); assert_eq!(acc_2_balance, 20000); @@ -156,20 +155,20 @@ async fn two_consecutive_successful_transfers() -> Result<()> { ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - info!("Checking correct balance move after first transfer"); + log::info!("Checking correct balance move after first transfer"); let acc_1_balance = account_balance(&ctx, sender).await?; let acc_2_balance = account_balance(&ctx, receiver).await?; - info!("Balance of sender: {acc_1_balance:#?}"); - info!("Balance of receiver: {acc_2_balance:#?}"); + log::info!("Balance of sender: {acc_1_balance:#?}"); + log::info!("Balance of receiver: {acc_2_balance:#?}"); assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); - info!("First TX Success!"); + log::info!("First TX Success!"); // Second transfer send( @@ -180,20 +179,20 @@ async fn two_consecutive_successful_transfers() -> Result<()> { ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - info!("Checking correct balance move after second transfer"); + log::info!("Checking correct balance move after second transfer"); let acc_1_balance = account_balance(&ctx, sender).await?; let acc_2_balance = account_balance(&ctx, receiver).await?; - info!("Balance of sender: {acc_1_balance:#?}"); - info!("Balance of receiver: {acc_2_balance:#?}"); + log::info!("Balance of sender: {acc_1_balance:#?}"); + log::info!("Balance of receiver: {acc_2_balance:#?}"); assert_eq!(acc_1_balance, 9800); assert_eq!(acc_2_balance, 20200); - info!("Second TX Success!"); + log::info!("Second TX Success!"); Ok(()) } @@ -209,7 +208,7 @@ async fn initialize_public_account() -> Result<()> { }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - info!("Checking correct execution"); + log::info!("Checking correct execution"); let account = get_account(&ctx, account_id).await?; assert_eq!( @@ -220,7 +219,7 @@ async fn initialize_public_account() -> Result<()> { assert_eq!(account.nonce.0, 1); assert!(account.data.is_empty()); - info!("Successfully initialized public account"); + log::info!("Successfully initialized public account"); Ok(()) } @@ -248,17 +247,17 @@ async fn successful_transfer_using_from_label() -> Result<()> { ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - info!("Checking correct balance move"); + log::info!("Checking correct balance move"); let acc_1_balance = account_balance(&ctx, sender).await?; let acc_2_balance = account_balance(&ctx, receiver).await?; assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); - info!("Successfully transferred using from_label"); + log::info!("Successfully transferred using from_label"); Ok(()) } @@ -286,17 +285,17 @@ async fn successful_transfer_using_to_label() -> Result<()> { ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - info!("Checking correct balance move"); + log::info!("Checking correct balance move"); let acc_1_balance = account_balance(&ctx, sender).await?; let acc_2_balance = account_balance(&ctx, receiver).await?; assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); - info!("Successfully transferred using to_label"); + log::info!("Successfully transferred using to_label"); Ok(()) } @@ -326,7 +325,7 @@ async fn cannot_transfer_funds_from_system_faucet_account() -> Result<()> { .send_transaction(LeeTransaction::Public(tx)) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let recipient_balance_after = account_balance(&ctx, recipient).await?; @@ -372,7 +371,7 @@ async fn cannot_execute_faucet_program() -> Result<()> { .send_transaction(LeeTransaction::Public(tx)) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let recipient_balance_after = account_balance(&ctx, recipient).await?; @@ -396,7 +395,7 @@ async fn user_tx_that_chain_calls_faucet_is_dropped() -> Result<()> { )); ctx.sequencer_client().send_transaction(deploy_tx).await?; - info!("Waiting for deploy block creation"); + log::info!("Waiting for deploy block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let faucet_account_id = system_accounts::faucet_account_id(); @@ -422,7 +421,7 @@ async fn user_tx_that_chain_calls_faucet_is_dropped() -> Result<()> { let tx_hash = ctx.sequencer_client().send_transaction(attack_tx).await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let faucet_balance_after = account_balance(&ctx, faucet_account_id).await?; diff --git a/integration_tests/tests/block_size_limit.rs b/integration_tests/tests/block_size_limit.rs index d97b695d8..237a5e279 100644 --- a/integration_tests/tests/block_size_limit.rs +++ b/integration_tests/tests/block_size_limit.rs @@ -9,22 +9,26 @@ use std::time::Duration; use anyhow::Result; use bytesize::ByteSize; use common::transaction::LeeTransaction; -use integration_tests::{ - TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, config::SequencerPartialConfig, -}; +use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, config::SequencerPartialConfig}; use lee::program::Program; use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use tokio::test; #[test] async fn reject_oversized_transaction() -> Result<()> { - let ctx = TestContext::builder() - .with_sequencer_partial_config(SequencerPartialConfig { - max_num_tx_in_block: 100, - max_block_size: ByteSize::mib(1), - mempool_max_size: 1000, - block_create_timeout: Duration::from_secs(10), - }) + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()) + .with_sequencer_partial_config(SequencerPartialConfig { + max_num_tx_in_block: 100, + max_block_size: ByteSize::mib(1), + mempool_max_size: 1000, + block_create_timeout: Duration::from_secs(10), + }), + ) .build() .await?; @@ -61,13 +65,16 @@ async fn reject_oversized_transaction() -> Result<()> { #[test] async fn accept_transaction_within_limit() -> Result<()> { - let ctx = TestContext::builder() - .with_sequencer_partial_config(SequencerPartialConfig { - max_num_tx_in_block: 100, - max_block_size: ByteSize::mib(1), - mempool_max_size: 1000, - block_create_timeout: Duration::from_secs(10), - }) + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()) + .with_sequencer_partial_config(SequencerPartialConfig { + max_num_tx_in_block: 100, + max_block_size: ByteSize::mib(1), + mempool_max_size: 1000, + block_create_timeout: Duration::from_secs(10), + }), + ) .build() .await?; @@ -102,13 +109,16 @@ async fn transaction_deferred_to_next_block_when_current_full() -> Result<()> { let max_program_size = claimer.elf().len().max(chain_caller.elf().len()); let block_size = ByteSize::b((max_program_size + 10 * 1024) as u64); - let ctx = TestContext::builder() - .with_sequencer_partial_config(SequencerPartialConfig { - max_num_tx_in_block: 100, - max_block_size: block_size, - mempool_max_size: 1000, - block_create_timeout: Duration::from_secs(10), - }) + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()) + .with_sequencer_partial_config(SequencerPartialConfig { + max_num_tx_in_block: 100, + max_block_size: block_size, + mempool_max_size: 1000, + block_create_timeout: Duration::from_secs(10), + }), + ) .build() .await?; diff --git a/integration_tests/tests/bridge.rs b/integration_tests/tests/bridge.rs index 7e59d6c6f..84c86daa4 100644 --- a/integration_tests/tests/bridge.rs +++ b/integration_tests/tests/bridge.rs @@ -249,7 +249,7 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { // let mut balance = bedrock_wallet_balance(bedrock_addr, bedrock_account_pk).await?; -// info!( +// log::info!( // "Queried Bedrock balance for key {bedrock_account_pk}: {:?}", // balance.balance // ); @@ -291,7 +291,7 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { // .await // .context("Failed to decode Bedrock transfer-funds response")?; -// info!( +// log::info!( // "Submitted transfer-funds to create exact deposit note, tx hash {:?}", // transfer.hash // ); @@ -343,7 +343,7 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { // .text() // .await // .unwrap_or_else(|_| "".to_owned()); -// info!( +// log::info!( // "Successfully submitted Bedrock deposit request for recipient {recipient_id} and amount // {amount}, response body: {body_text}", ); @@ -585,7 +585,7 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { // let mut stream = std::pin::pin!(stream); // while let Some(message) = stream.next().await { -// info!("Observed zone message {message:?}"); +// log::info!("Observed zone message {message:?}"); // if let ZoneMessage::Withdraw(withdraw) = message { // released_notes.extend(withdraw.inputs.iter().copied()); diff --git a/integration_tests/tests/config.rs b/integration_tests/tests/config.rs index 091058330..9501551d5 100644 --- a/integration_tests/tests/config.rs +++ b/integration_tests/tests/config.rs @@ -6,7 +6,6 @@ use anyhow::Result; use integration_tests::TestContext; -use log::info; use tokio::test; use wallet::cli::{Command, config::ConfigSubcommand}; @@ -33,7 +32,7 @@ async fn modify_config_field() -> Result<()> { }); wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; - info!("Successfully modified and restored config field"); + log::info!("Successfully modified and restored config field"); Ok(()) } diff --git a/integration_tests/tests/cross_zone_bridge.rs b/integration_tests/tests/cross_zone_bridge.rs index 0c334088c..b9c8d41ae 100644 --- a/integration_tests/tests/cross_zone_bridge.rs +++ b/integration_tests/tests/cross_zone_bridge.rs @@ -9,12 +9,10 @@ //! wrapped token is minted to the recipient. Reuses the M3/M4 spine unchanged; //! only the source caller (`bridge_lock`) and target (`wrapped_token`) are new. //! -//! Not production-safe. The inbox allowlist gates the target program, not the -//! source emitter, and `extract_emission` recognizes any known emitter, so in a -//! zone that allows `wrapped_token` as a target a permissionless `ping_sender` -//! send can carry a `wrapped_token::Mint` and mint with no lock. Making this safe -//! needs source verification, where a value-bearing target checks the message -//! originated from `bridge_lock`; that is out of scope for the demo. +//! A `ping_sender` send carrying a `wrapped_token::Mint` is refused as long as no +//! operator writes a `(ping_sender, wrapped_token)` route: the allowlist is a +//! source-and-target pair. Nothing forbids writing that route, and the token +//! still trusts the table rather than checking its own sources, which is #673. use std::time::Duration; @@ -24,7 +22,6 @@ use cross_zone_outbox_core::outbox_pda; use integration_tests::{ config::{self, SequencerPartialConfig}, indexer_client::IndexerClient, - setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer}, }; use lee::{ AccountId, PrivateKey, PublicKey, PublicTransaction, @@ -32,6 +29,9 @@ use lee::{ }; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute, GenesisAction}; use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use tokio::test; const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600); @@ -41,11 +41,6 @@ const RECIPIENT: [u8; 32] = [9; 32]; #[test] async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { - // Declared first so it outlives both zones (drops run in reverse order). - let (_bedrock, bedrock_addr) = setup_bedrock_node() - .await - .context("Failed to set up shared Bedrock node")?; - let partial = SequencerPartialConfig::default(); let channel_a = config::bedrock_channel_id(); let channel_b = config::bedrock_channel_id_b(); @@ -72,37 +67,48 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { holder: holder_id, amount: INITIAL_BALANCE, }]; - let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel_a) - .with_genesis(genesis_a) - .setup() - .await - .context("Failed to set up zone A sequencer")?; - let (_seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel_b) - .with_genesis(vec![]) - .with_cross_zone(cross_zone.clone()) - .setup() - .await - .context("Failed to set up zone B sequencer")?; - let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone)) - .await - .context("Failed to set up zone B indexer")?; + + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel_a, + }) + .disable_wallet() + .disable_indexer() + .with_sequencer_partial_config(partial) + .with_genesis(genesis_a), + ) + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel_b, + }) + .disable_wallet() + .with_sequencer_partial_config(partial) + .with_genesis(vec![]) + .with_cross_zone(Some(cross_zone)), + ) + .build() + .await?; + + let seq_client_a = &ctx + .zone_default_sequencer_component(channel_a) + .sequencer_client; + + let ind_client_b = ctx.indexer_client_zone(channel_b).unwrap(); // Lock LOCK_AMOUNT on zone A, addressed to the recipient on zone B. let lock = build_lock_tx(&holder_key, holder_id, zone_b); - sequencer_client(seq_a.addr())? + seq_client_a .send_transaction(lock) .await .context("Failed to submit lock on zone A")?; // Wait until zone B's indexer reflects the verified mint. let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); - let indexer = indexer_client(idx_b.addr()) - .await - .context("Failed to build indexer client")?; - let minted = wait_for_mint(&indexer, holding_id).await?; + let minted = wait_for_mint(ind_client_b, holding_id).await?; assert_eq!( minted, LOCK_AMOUNT, "zone B must mint exactly the locked amount" @@ -111,14 +117,13 @@ async fn lock_on_zone_a_mints_wrapped_token_on_zone_b() -> Result<()> { // Conservation: the mint on B must be backed by an equal lock on A. The lock // has already landed (it preceded delivery), so zone A reflects the debit and // escrow now. - let seq_a_client = sequencer_client(seq_a.addr())?; let escrow_id = bridge_lock_core::escrow_account_id(programs::bridge_lock().id()); - let escrowed = seq_a_client.get_account(escrow_id).await?.balance; + let escrowed = seq_client_a.get_account(escrow_id).await?.balance; assert_eq!( escrowed, LOCK_AMOUNT, "zone A escrow must hold the locked amount" ); - let remaining = seq_a_client.get_account(holder_id).await?.balance; + let remaining = seq_client_a.get_account(holder_id).await?.balance; assert_eq!( remaining, INITIAL_BALANCE - LOCK_AMOUNT, @@ -156,14 +161,14 @@ fn build_lock_tx( target_program_id: wrapped_token_id, target_accounts, payload, - outbox_program_id: outbox_id, ordinal, }; let accounts = vec![ + bridge_lock_core::config_account_id(bridge_lock_id), holder_id, bridge_lock_core::escrow_account_id(bridge_lock_id), - outbox_pda(outbox_id, &target_zone, ordinal), + outbox_pda(outbox_id, bridge_lock_id, &target_zone, ordinal), ]; // One nonce per signature: the holder signs, at its genesis nonce 0. let message = Message::try_new(bridge_lock_id, accounts, vec![0_u128.into()], lock) diff --git a/integration_tests/tests/cross_zone_ingress_guard.rs b/integration_tests/tests/cross_zone_ingress_guard.rs index 86731477f..af8b4cbf9 100644 --- a/integration_tests/tests/cross_zone_ingress_guard.rs +++ b/integration_tests/tests/cross_zone_ingress_guard.rs @@ -9,41 +9,46 @@ //! inbox guest's caller-is-none assertion passes for a top-level user tx, so the //! sequencer ingress guard is the only thing that stops this. -use anyhow::{Context as _, Result}; +use anyhow::Result; use common::transaction::LeeTransaction; use cross_zone_inbox_core::{ CrossZoneMessage, Instruction, inbox_config_account_id, inbox_seen_shard_account_id, }; -use integration_tests::{ - config::{self, SequencerPartialConfig}, - setup::{SequencerSetup, sequencer_client, setup_bedrock_node}, -}; +use integration_tests::config::{self, SequencerPartialConfig}; use lee::{ PublicTransaction, public_transaction::{Message, WitnessSet}, }; use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use tokio::test; #[test] async fn user_origin_inbox_call_rejected() -> Result<()> { - let (_bedrock, bedrock_addr) = setup_bedrock_node() - .await - .context("Failed to set up Bedrock node")?; let partial = SequencerPartialConfig::default(); let channel = config::bedrock_channel_id(); - let (seq, _seq_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel) - .with_genesis(vec![]) - .setup() - .await - .context("Failed to set up sequencer")?; + + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel, + }) + .disable_indexer() + .with_sequencer_partial_config(partial) + .with_genesis(vec![]), + ) + .build() + .await?; // A user hand-builds a top-level inbox Dispatch and submits it via RPC. let inbox_id = programs::cross_zone_inbox().id(); let msg = CrossZoneMessage { src_zone: [2; 32], src_block_id: 1, + src_block_hash: [7; 32], src_tx_index: 0, src_program_id: [9; 8], target_program_id: programs::ping_receiver().id(), @@ -63,7 +68,11 @@ async fn user_origin_inbox_call_rejected() -> Result<()> { WitnessSet::from_raw_parts(vec![]), )); - let result = sequencer_client(seq.addr())?.send_transaction(tx).await; + let result = ctx + .default_sequencer_component() + .sequencer_client + .send_transaction(tx) + .await; let err = result.expect_err("the sequencer must reject a user-origin inbox call"); assert!( err.to_string().contains("sequencer-only"), diff --git a/integration_tests/tests/cross_zone_ping.rs b/integration_tests/tests/cross_zone_ping.rs index c4724b13b..fb00410d2 100644 --- a/integration_tests/tests/cross_zone_ping.rs +++ b/integration_tests/tests/cross_zone_ping.rs @@ -16,15 +16,18 @@ use std::time::Duration; use anyhow::{Context as _, Result}; use common::transaction::LeeTransaction; use cross_zone_outbox_core::outbox_pda; -use integration_tests::{ - config::{self, SequencerPartialConfig}, - setup::{SequencerSetup, sequencer_client, setup_bedrock_node}, -}; +use integration_tests::config::{self, SequencerPartialConfig}; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; -use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use ping_core::{ + ReceiverInstruction, SenderInstruction, ping_record_pda, receiver_config_account_id, + sender_config_account_id, +}; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use tokio::test; const DELIVERY_TIMEOUT: Duration = Duration::from_secs(480); @@ -32,11 +35,6 @@ const PING_PAYLOAD: &[u8] = b"hello-cross-zone"; #[test] async fn ping_crosses_from_zone_a_to_zone_b() -> Result<()> { - // Declared first so it outlives both zones (drops run in reverse order). - let (_bedrock, bedrock_addr) = setup_bedrock_node() - .await - .context("Failed to set up shared Bedrock node")?; - let partial = SequencerPartialConfig::default(); let channel_a = config::bedrock_channel_id(); let channel_b = config::bedrock_channel_id_b(); @@ -57,30 +55,50 @@ async fn ping_crosses_from_zone_a_to_zone_b() -> Result<()> { }], }; - let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel_a) - .with_genesis(vec![]) - .setup() - .await - .context("Failed to set up zone A sequencer")?; - let (seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel_b) - .with_genesis(vec![]) - .with_cross_zone(cross_zone) - .setup() - .await - .context("Failed to set up zone B sequencer")?; + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel_a, + }) + .disable_wallet() + .disable_indexer() + .with_sequencer_partial_config(partial) + .with_genesis(vec![]), + ) + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel_b, + }) + .disable_wallet() + .disable_indexer() + .with_sequencer_partial_config(partial) + .with_genesis(vec![]) + .with_cross_zone(Some(cross_zone)), + ) + .build() + .await?; // Submit the ping on zone A, addressed to ping_receiver on zone B. let ping = build_ping_tx(zone_b, receiver_id); - sequencer_client(seq_a.addr())? + + let seq_client_a = &ctx + .zone_default_sequencer_component(channel_a) + .sequencer_client; + + let seq_client_b = &ctx + .zone_default_sequencer_component(channel_b) + .sequencer_client; + + seq_client_a .send_transaction(ping) .await .context("Failed to submit ping on zone A")?; // Wait until zone B's sequencer records the delivered payload. let record_id = ping_record_pda(receiver_id); - let delivered = wait_for_delivery(sequencer_client(seq_b.addr())?, record_id).await?; + let delivered = wait_for_delivery(seq_client_b.clone(), record_id).await?; assert_eq!( delivered, PING_PAYLOAD, @@ -104,18 +122,21 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); let send = SenderInstruction::Send { - outbox_program_id: outbox_id, target_zone, target_program_id: receiver_id, - target_accounts: vec![ping_record_pda(receiver_id).into_value()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload, ordinal, }; - let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal); + let sender_id = programs::ping_sender().id(); + let outbox_account = outbox_pda(outbox_id, sender_id, &target_zone, ordinal); let message = Message::try_new( - programs::ping_sender().id(), - vec![outbox_account], + sender_id, + vec![sender_config_account_id(sender_id), outbox_account], vec![], send, ) diff --git a/integration_tests/tests/cross_zone_state_machine.rs b/integration_tests/tests/cross_zone_state_machine.rs index f9d92a312..47027eb2d 100644 --- a/integration_tests/tests/cross_zone_state_machine.rs +++ b/integration_tests/tests/cross_zone_state_machine.rs @@ -10,11 +10,9 @@ //! `outbox::Emit`). Fast, so they pin guest logic before the e2e exercises the //! plumbing. Run with `RISC0_DEV_MODE=1`. -use std::collections::BTreeMap; - use cross_zone_inbox_core::{ - CrossZoneMessage, CrossZoneRoute, InboxConfig, Instruction as InboxInstruction, SeenShard, - inbox_config_account_id, inbox_seen_shard_account_id, message_key, + CrossZoneMessage, InboxConfig, Instruction as InboxInstruction, SeenShard, + inbox_config_account_id, inbox_seen_shard_account_id, inbox_source_marker_account_id, }; use cross_zone_outbox_core::{OutboxRecord, outbox_pda}; use lee::{ @@ -22,44 +20,33 @@ use lee::{ public_transaction::{Message, WitnessSet}, }; use lee_core::account::Account; -use ping_core::{ReceiverInstruction, ping_record_pda}; +use ping_core::{ + ReceiverInstruction, outbox_bytes, ping_record_pda, read_outbox, receiver_config_account_id, + sender_config_account_id, +}; const INITIAL_BALANCE: u128 = 100; const LOCK_AMOUNT: u128 = 30; const RECIPIENT: [u8; 32] = [9; 32]; +/// These tests drive the guest directly, so any fixed source-block hash does. +const SRC_BLOCK_HASH: [u8; 32] = [7; 32]; /// State registering the cross-zone builtins these tests exercise. fn base_state() -> V03State { V03State::new().with_programs([ programs::cross_zone_inbox(), programs::cross_zone_outbox(), + programs::ping_sender(), programs::ping_receiver(), programs::bridge_lock(), programs::wrapped_token(), ]) } -/// Seeds an inbox config (inbox-owned) allowing `src_zone -> target`. -fn seed_inbox_config( - state: &mut V03State, - self_zone: [u8; 32], - src_zone: [u8; 32], - src_program_id: lee_core::program::ProgramId, - target: lee_core::program::ProgramId, -) { +/// Seeds the inbox config (inbox-owned), which is now just this zone's id. +fn seed_inbox_config(state: &mut V03State, self_zone: [u8; 32]) { let inbox_id = programs::cross_zone_inbox().id(); - let mut allowed_routes = BTreeMap::new(); - allowed_routes.insert( - src_zone, - vec![CrossZoneRoute { - src_program_id, - target_program_id: target, - }], - ); - let config = InboxConfig { - self_zone, - allowed_routes, - }; + let config = InboxConfig { self_zone }; *state = std::mem::replace(state, V03State::new()).with_public_accounts([( inbox_config_account_id(inbox_id), Account { @@ -74,34 +61,209 @@ fn seed_inbox_config( )]); } -/// Seeds the wrapped-token config account pinning the inbox as authorized minter, -/// matching what genesis seeds for a real zone. -fn seed_wrapped_config(state: &mut V03State) { +/// Seeds the wrapped-token config pinning the inbox as minter and `sources` as the +/// peer pairs it will mint for, matching what genesis seeds for a real zone. +fn seed_wrapped_config( + state: &mut V03State, + sources: Vec<([u8; 32], lee_core::program::ProgramId)>, +) { let wrapped_token_id = programs::wrapped_token().id(); + let config = wrapped_token_core::WrappedTokenConfig { + minter: programs::cross_zone_inbox().id(), + sources, + }; *state = std::mem::replace(state, V03State::new()).with_public_accounts([( wrapped_token_core::config_account_id(wrapped_token_id), Account { program_owner: wrapped_token_id, - data: wrapped_token_core::minter_bytes(programs::cross_zone_inbox().id()) - .to_vec() + data: config + .to_bytes() .try_into() - .expect("minter id fits in account data"), + .expect("wrapped-token config fits in account data"), ..Default::default() }, )]); } +/// Seeds the ping-receiver config pinning the inbox as deliverer and `sources` as +/// the peer pairs it accepts a delivery from. +fn seed_receiver_config( + state: &mut V03State, + sources: Vec<([u8; 32], lee_core::program::ProgramId)>, +) { + let receiver_id = programs::ping_receiver().id(); + let config = ping_core::ReceiverConfig { + deliverer: programs::cross_zone_inbox().id(), + sources, + }; + *state = std::mem::replace(state, V03State::new()).with_public_accounts([( + receiver_config_account_id(receiver_id), + Account { + program_owner: receiver_id, + data: config + .to_bytes() + .try_into() + .expect("receiver config fits in account data"), + ..Default::default() + }, + )]); +} + +/// Seeds the ping-sender config account pinning the real outbox, matching what +/// genesis seeds for a real zone. +fn seed_ping_sender_config(state: &mut V03State) { + let sender_id = programs::ping_sender().id(); + *state = std::mem::replace(state, V03State::new()).with_public_accounts([( + sender_config_account_id(sender_id), + Account { + program_owner: sender_id, + data: outbox_bytes(programs::cross_zone_outbox().id()) + .to_vec() + .try_into() + .expect("outbox id fits in account data"), + ..Default::default() + }, + )]); +} + +/// Seeds the bridge-lock config account pinning the real outbox and the wrapped +/// token, matching what genesis seeds for a real zone. +fn seed_bridge_lock_config(state: &mut V03State) { + let bridge_lock_id = programs::bridge_lock().id(); + *state = std::mem::replace(state, V03State::new()).with_public_accounts([( + bridge_lock_core::config_account_id(bridge_lock_id), + Account { + program_owner: bridge_lock_id, + data: bridge_lock_core::config_bytes( + programs::cross_zone_outbox().id(), + programs::wrapped_token().id(), + ) + .to_vec() + .try_into() + .expect("pinned ids fit in account data"), + ..Default::default() + }, + )]); +} + +/// The account list a dispatch declares, mirroring `cross_zone::build_inbox_dispatch_tx`: +/// config, seen shard, source marker, then the target's own accounts. +fn dispatch_accounts( + inbox_id: lee_core::program::ProgramId, + msg: &CrossZoneMessage, + targets: Vec, +) -> Vec { + let mut ids = vec![ + inbox_config_account_id(inbox_id), + inbox_seen_shard_account_id(inbox_id, &msg.src_zone, msg.src_block_id), + inbox_source_marker_account_id(inbox_id, &msg.src_zone, msg.src_program_id), + ]; + ids.extend(targets); + ids +} + +/// A `ping_sender::Send` carrying `payload` to `target_zone`, over the accounts +/// given rather than the correct ones, so tests can vary them. +fn send_tx(accounts: Vec, target_zone: [u8; 32], ordinal: u32) -> PublicTransaction { + let receiver_id = programs::ping_receiver().id(); + let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { + payload: b"ping".to_vec(), + }) + .expect("serialize ping instruction"); + let send = ping_core::SenderInstruction::Send { + target_zone, + target_program_id: receiver_id, + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], + payload: words.iter().flat_map(|word| word.to_le_bytes()).collect(), + ordinal, + }; + let message = Message::try_new(programs::ping_sender().id(), accounts, vec![], send) + .expect("build ping_sender message"); + PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])) +} + /// The wrapped-token `Mint` the bridge forwards, serialized as the cross-zone /// payload (risc0 words, little-endian bytes). fn mint_payload() -> Vec { + mint_payload_of(LOCK_AMOUNT) +} + +fn mint_payload_of(amount: u128) -> Vec { let mint = wrapped_token_core::Instruction::Mint { recipient: RECIPIENT, - amount: LOCK_AMOUNT, + amount, }; let words = risc0_zkvm::serde::to_vec(&mint).expect("serialize mint"); words.iter().flat_map(|word| word.to_le_bytes()).collect() } +/// Runs a bridge mint of `amount` through the inbox, as the watcher would. +fn dispatch_mint(amount: u128) -> Result { + let inbox_id = programs::cross_zone_inbox().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let self_zone = [1_u8; 32]; + let src_zone = [2_u8; 32]; + let src_block_id = 5; + + let mut state = base_state(); + seed_inbox_config(&mut state, self_zone); + seed_wrapped_config(&mut state, vec![(src_zone, [9_u32; 8])]); + + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_block_hash: SRC_BLOCK_HASH, + src_tx_index: 0, + src_program_id: [9_u32; 8], + target_program_id: wrapped_token_id, + payload: mint_payload_of(amount), + l1_inclusion_witness: None, + }; + + let message = Message::try_new( + inbox_id, + dispatch_accounts( + inbox_id, + &msg, + vec![ + wrapped_token_core::config_account_id(wrapped_token_id), + wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT), + ], + ), + vec![], + InboxInstruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) +} + +/// One message must not be able to pin a holding near `u128::MAX`, which would +/// make every later honest mint to that recipient overflow and fail for good. +#[test] +fn a_mint_above_the_cap_is_rejected() { + assert!( + dispatch_mint(wrapped_token_core::MAX_MINT_AMOUNT + 1).is_err(), + "an amount over the per-mint cap must not execute" + ); +} + +#[test] +fn a_mint_at_the_cap_is_accepted() { + let diff = dispatch_mint(wrapped_token_core::MAX_MINT_AMOUNT) + .expect("the cap itself is a legitimate amount"); + let holding_id = + wrapped_token_core::holding_account_id(programs::wrapped_token().id(), &RECIPIENT); + let minted = wrapped_token_core::read_balance( + &diff.public_diff()[&holding_id].data.clone().into_inner(), + ); + assert_eq!(minted, wrapped_token_core::MAX_MINT_AMOUNT); +} + /// Drives `cross_zone_inbox::Dispatch` directly through the state machine /// (no watcher) and asserts the message is delivered to `ping_receiver`, which /// records the payload into its own PDA. @@ -115,7 +277,8 @@ fn inbox_dispatch_delivers_payload_to_ping_receiver() { let src_block_id = 5; let mut state = base_state(); - seed_inbox_config(&mut state, self_zone, src_zone, [9_u32; 8], receiver_id); + seed_inbox_config(&mut state, self_zone); + seed_receiver_config(&mut state, vec![(src_zone, [9_u32; 8])]); // The payload is the ping_receiver instruction, serialized as risc0 words in // little-endian bytes (the contract the inbox reverses when forwarding). @@ -129,6 +292,7 @@ fn inbox_dispatch_delivers_payload_to_ping_receiver() { let msg = CrossZoneMessage { src_zone, src_block_id, + src_block_hash: SRC_BLOCK_HASH, src_tx_index: 0, src_program_id: [9_u32; 8], target_program_id: receiver_id, @@ -136,12 +300,15 @@ fn inbox_dispatch_delivers_payload_to_ping_receiver() { l1_inclusion_witness: None, }; - let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); let record_id = ping_record_pda(receiver_id); let message = Message::try_new( inbox_id, - vec![inbox_config_account_id(inbox_id), seen_id, record_id], + dispatch_accounts( + inbox_id, + &msg, + vec![receiver_config_account_id(receiver_id), record_id], + ), vec![], InboxInstruction::Dispatch(msg), ) @@ -184,33 +351,12 @@ fn lock_escrows_balance_and_emits_to_outbox() { ..Default::default() }, )]); + seed_bridge_lock_config(&mut state); let payload = mint_payload(); - let target_accounts = vec![ - wrapped_token_core::config_account_id(wrapped_token_id).into_value(), - wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT).into_value(), - ]; - let lock = bridge_lock_core::Instruction::Lock { - amount: LOCK_AMOUNT, - target_zone: zone_b, - target_program_id: wrapped_token_id, - target_accounts, - payload: payload.clone(), - outbox_program_id: outbox_id, - ordinal, - }; - let escrow_id = bridge_lock_core::escrow_account_id(bridge_lock_id); - let outbox_record_id = outbox_pda(outbox_id, &zone_b, ordinal); - let message = Message::try_new( - bridge_lock_id, - vec![holder_id, escrow_id, outbox_record_id], - vec![0_u128.into()], - lock, - ) - .expect("build lock message"); - let witness = WitnessSet::for_message(&message, &[&holder_key]); - let tx = PublicTransaction::new(message, witness); + let outbox_record_id = outbox_pda(outbox_id, bridge_lock_id, &zone_b, ordinal); + let tx = lock_tx(&holder_key, holder_id, zone_b, ordinal, 0); let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) .expect("lock must validate and execute"); @@ -229,7 +375,12 @@ fn lock_escrows_balance_and_emits_to_outbox() { let record = OutboxRecord::from_bytes(&public_diff[&outbox_record_id].data.clone().into_inner()) .expect("outbox PDA holds an OutboxRecord"); + assert_eq!( + record.emitter, bridge_lock_id, + "the record names the program that emitted it" + ); assert_eq!(record.target_zone, zone_b); + assert_eq!(record.ordinal, ordinal); assert_eq!(record.target_program_id, wrapped_token_id); assert_eq!( record.payload, payload, @@ -237,57 +388,663 @@ fn lock_escrows_balance_and_emits_to_outbox() { ); } -/// Drives a hand-built `cross_zone_inbox::Dispatch` (as the watcher would inject) -/// and asserts it chains into `wrapped_token::Mint`, crediting the recipient. -#[test] -fn inbox_dispatch_mints_wrapped_token() { - let inbox_id = programs::cross_zone_inbox().id(); +/// A `bridge_lock::Lock` emitting to `(zone_b, ordinal)`, ready to run twice. +fn lock_tx( + holder_key: &PrivateKey, + holder_id: AccountId, + zone_b: [u8; 32], + ordinal: u32, + nonce: u128, +) -> PublicTransaction { let wrapped_token_id = programs::wrapped_token().id(); + lock_tx_to( + holder_key, + holder_id, + zone_b, + ordinal, + nonce, + wrapped_token_id, + mint_target_accounts(wrapped_token_id), + ) +} - let self_zone = [1_u8; 32]; - let src_zone = [2_u8; 32]; - let src_block_id = 5; +/// The mint's own account list: the wrapped-token config, then the recipient's +/// holding. What `wrapped_token::Mint` requires on the destination zone. +fn mint_target_accounts(wrapped_token_id: lee_core::program::ProgramId) -> Vec<[u8; 32]> { + vec![ + wrapped_token_core::config_account_id(wrapped_token_id).into_value(), + wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT).into_value(), + ] +} + +/// The same lock aimed at `target_program_id` over `target_accounts`, so a test +/// can vary what the destination would be asked to do. +fn lock_tx_to( + holder_key: &PrivateKey, + holder_id: AccountId, + zone_b: [u8; 32], + ordinal: u32, + nonce: u128, + target_program_id: lee_core::program::ProgramId, + target_accounts: Vec<[u8; 32]>, +) -> PublicTransaction { + let bridge_lock_id = programs::bridge_lock().id(); + let outbox_id = programs::cross_zone_outbox().id(); + + let lock = bridge_lock_core::Instruction::Lock { + amount: LOCK_AMOUNT, + target_zone: zone_b, + target_program_id, + target_accounts, + payload: mint_payload(), + ordinal, + }; + let message = Message::try_new( + bridge_lock_id, + vec![ + bridge_lock_core::config_account_id(bridge_lock_id), + holder_id, + bridge_lock_core::escrow_account_id(bridge_lock_id), + outbox_pda(outbox_id, bridge_lock_id, &zone_b, ordinal), + ], + vec![nonce.into()], + lock, + ) + .expect("build lock message"); + let witness = WitnessSet::for_message(&message, &[holder_key]); + PublicTransaction::new(message, witness) +} + +/// A slot holds one message for ever, so a second emission into it fails rather +/// than replacing the record. Without this a later emitter silently destroys an +/// earlier one, and for a bridge that means an escrow with no record of what it +/// was for. +#[test] +fn a_second_emit_at_the_same_slot_is_rejected() { + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + let mut state = base_state().with_public_accounts([( + holder_id, + Account { + program_owner: programs::bridge_lock().id(), + balance: INITIAL_BALANCE, + ..Default::default() + }, + )]); + seed_bridge_lock_config(&mut state); + + let first = lock_tx(&holder_key, holder_id, zone_b, ordinal, 0); + let diff = ValidatedStateDiff::from_public_transaction(&first, &state, 1, 0) + .expect("the first lock executes"); + state.apply_state_diff(diff); + + // Same slot, fresh nonce, so the only thing that can reject it is the slot + // already holding a record. Matched on the guest's own message rather than + // any error, or a future change that rejected it earlier for an unrelated + // reason would keep this passing. + let second = lock_tx(&holder_key, holder_id, zone_b, ordinal, 1); + let Err(err) = ValidatedStateDiff::from_public_transaction(&second, &state, 2, 0) else { + panic!("a second emission into a written slot must not execute"); + }; + assert!( + format!("{err:?}").contains("Outbox slot already written"), + "rejected for the wrong reason: {err:?}" + ); + + // Control: the same second lock into a fresh ordinal executes, so the + // refusal above is the slot and not the transaction's shape. + let elsewhere = lock_tx(&holder_key, holder_id, zone_b, ordinal + 1, 1); + ValidatedStateDiff::from_public_transaction(&elsewhere, &state, 2, 0) + .expect("a lock into an unwritten slot executes"); +} + +/// Two programs emitting to one zone and ordinal address two different slots, +/// so neither can overwrite or block the other. +#[test] +fn two_emitters_share_an_ordinal_without_colliding() { + let outbox_id = programs::cross_zone_outbox().id(); + let sender_id = programs::ping_sender().id(); + let bridge_lock_id = programs::bridge_lock().id(); + let receiver_id = programs::ping_receiver().id(); + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + let mut state = base_state().with_public_accounts([( + holder_id, + Account { + program_owner: bridge_lock_id, + balance: INITIAL_BALANCE, + ..Default::default() + }, + )]); + seed_ping_sender_config(&mut state); + seed_bridge_lock_config(&mut state); + + let lock_slot = outbox_pda(outbox_id, bridge_lock_id, &zone_b, ordinal); + let send_slot = outbox_pda(outbox_id, sender_id, &zone_b, ordinal); + assert_ne!( + lock_slot, send_slot, + "the same zone and ordinal under two emitters are two slots" + ); + + let lock = lock_tx(&holder_key, holder_id, zone_b, ordinal, 0); + let diff = ValidatedStateDiff::from_public_transaction(&lock, &state, 1, 0) + .expect("the lock executes"); + state.apply_state_diff(diff); + + let send = send_tx( + vec![sender_config_account_id(sender_id), send_slot], + zone_b, + ordinal, + ); + let send_diff = ValidatedStateDiff::from_public_transaction(&send, &state, 2, 0) + .expect("the send executes into its own slot, not the lock's"); + + let record = OutboxRecord::from_bytes( + &send_diff.public_diff()[&send_slot] + .data + .clone() + .into_inner(), + ) + .expect("outbox PDA holds an OutboxRecord"); + assert_eq!(record.emitter, sender_id); + assert_eq!(record.target_program_id, receiver_id); + + // And the lock's own slot is untouched by it. + let lock_record = + OutboxRecord::from_bytes(&state.get_account_by_id(lock_slot).data.into_inner()) + .expect("the lock's record survives"); + assert_eq!(lock_record.emitter, bridge_lock_id); +} + +/// A caller can no longer aim an emission at a program of their own and still +/// succeed, leaving no record of it. With the program no longer an instruction +/// field, the account is the only way left to try. +#[test] +fn a_send_into_a_foreign_outbox_slot_is_rejected() { + let sender_id = programs::ping_sender().id(); + let zone_b = [2_u8; 32]; + let ordinal = 0; let mut state = base_state(); - seed_inbox_config( - &mut state, - self_zone, - src_zone, - [9_u32; 8], - wrapped_token_id, + seed_ping_sender_config(&mut state); + + // A slot under some other program, which is what the caller would have to + // pass to reach it. + let foreign_slot = outbox_pda([3; 8], sender_id, &zone_b, ordinal); + let send = send_tx( + vec![sender_config_account_id(sender_id), foreign_slot], + zone_b, + ordinal, ); - seed_wrapped_config(&mut state); + + // Refused inside the pinned outbox, not by the sender: the chained call goes + // there whatever account the caller passes, which is the point. + let Err(err) = ValidatedStateDiff::from_public_transaction(&send, &state, 1, 0) else { + panic!("a send into a slot outside the pinned outbox must not execute"); + }; + assert!( + format!("{err:?}").contains("Account must be the outbox PDA"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// Nothing releases an escrow, so a message the destination will refuse is a +/// burn: debited here, never minted there. The refusal has to come before the +/// debit. +#[test] +fn a_lock_naming_another_target_program_is_rejected() { + let bridge_lock_id = programs::bridge_lock().id(); + let zone_b = [2_u8; 32]; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + let mut state = base_state().with_public_accounts([( + holder_id, + Account { + program_owner: bridge_lock_id, + balance: INITIAL_BALANCE, + ..Default::default() + }, + )]); + seed_bridge_lock_config(&mut state); + + let elsewhere = programs::ping_receiver().id(); + let lock = lock_tx_to( + &holder_key, + holder_id, + zone_b, + 0, + 0, + elsewhere, + mint_target_accounts(elsewhere), + ); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&lock, &state, 1, 0) else { + panic!("a lock aimed at another program must not execute"); + }; + assert!( + format!("{err:?}").contains("only mints through the wrapped token it is pinned to"), + "rejected for the wrong reason: {err:?}" + ); + assert_eq!( + state.get_account_by_id(holder_id).balance, + INITIAL_BALANCE, + "a refused lock leaves the holder's balance alone" + ); +} + +/// The same burn by a different route: the right target program, the wrong +/// accounts for it. `wrapped_token::Mint` fails its own address asserts on the +/// destination, so the escrow has to be refused here instead. +#[test] +fn a_lock_naming_other_mint_accounts_is_rejected() { + let bridge_lock_id = programs::bridge_lock().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let zone_b = [2_u8; 32]; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + let mut state = base_state().with_public_accounts([( + holder_id, + Account { + program_owner: bridge_lock_id, + balance: INITIAL_BALANCE, + ..Default::default() + }, + )]); + seed_bridge_lock_config(&mut state); + + // A holding under someone other than the payload's recipient: a mint the + // destination would credit to the wrong account if it credited it at all. + let other_holding = + wrapped_token_core::holding_account_id(wrapped_token_id, &[4; 32]).into_value(); + let lock = lock_tx_to( + &holder_key, + holder_id, + zone_b, + 0, + 0, + wrapped_token_id, + vec![ + wrapped_token_core::config_account_id(wrapped_token_id).into_value(), + other_holding, + ], + ); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&lock, &state, 1, 0) else { + panic!("a lock over the wrong mint accounts must not execute"); + }; + assert!( + format!("{err:?}").contains("target accounts must be the mint's config"), + "rejected for the wrong reason: {err:?}" + ); + assert_eq!( + state.get_account_by_id(holder_id).balance, + INITIAL_BALANCE, + "a refused lock leaves the holder's balance alone" + ); +} + +/// The config is read by address, so substituting another account for it fails +/// rather than reading the pins out of whatever that account holds. Without the +/// address check, 64 bytes a caller controls would re-pin both for one lock. +#[test] +fn a_lock_with_a_substituted_config_account_is_rejected() { + let bridge_lock_id = programs::bridge_lock().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let outbox_id = programs::cross_zone_outbox().id(); + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + // A bridge-lock-owned account holding pins of the caller's choosing, so only + // the address check stands between it and being read as the config. + let decoy_key = PrivateKey::try_new([8; 32]).expect("valid key"); + let decoy_id = AccountId::from(&PublicKey::new_from_private_key(&decoy_key)); + let mut state = base_state().with_public_accounts([ + ( + holder_id, + Account { + program_owner: bridge_lock_id, + balance: INITIAL_BALANCE, + ..Default::default() + }, + ), + ( + decoy_id, + Account { + program_owner: bridge_lock_id, + data: bridge_lock_core::config_bytes([3; 8], [4; 8]) + .to_vec() + .try_into() + .expect("pinned ids fit in account data"), + ..Default::default() + }, + ), + ]); + seed_bridge_lock_config(&mut state); + + let lock = bridge_lock_core::Instruction::Lock { + amount: LOCK_AMOUNT, + target_zone: zone_b, + target_program_id: wrapped_token_id, + target_accounts: mint_target_accounts(wrapped_token_id), + payload: mint_payload(), + ordinal, + }; + let message = Message::try_new( + bridge_lock_id, + vec![ + decoy_id, + holder_id, + bridge_lock_core::escrow_account_id(bridge_lock_id), + outbox_pda(outbox_id, bridge_lock_id, &zone_b, ordinal), + ], + vec![0_u128.into()], + lock, + ) + .expect("build lock message"); + let tx = PublicTransaction::new( + message.clone(), + WitnessSet::for_message(&message, &[&holder_key]), + ); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) else { + panic!("a lock over a substituted config account must not execute"); + }; + assert!( + format!("{err:?}").contains("must be the bridge-lock config PDA"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// A bridge with no pin cannot fall back to caller-named programs: it stops +/// locking. The state a zone reaches by skipping the genesis init. +#[test] +fn a_lock_before_the_pins_are_set_is_rejected() { + let bridge_lock_id = programs::bridge_lock().id(); + let zone_b = [2_u8; 32]; + + let holder_key = PrivateKey::try_new([7; 32]).expect("valid key"); + let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key)); + let state = base_state().with_public_accounts([( + holder_id, + Account { + program_owner: bridge_lock_id, + balance: INITIAL_BALANCE, + ..Default::default() + }, + )]); + + let lock = lock_tx(&holder_key, holder_id, zone_b, 0, 0); + let Err(err) = ValidatedStateDiff::from_public_transaction(&lock, &state, 1, 0) else { + panic!("a lock with nothing pinned must not execute"); + }; + assert!( + format!("{err:?}").contains("config account holds an outbox and a mint target"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// Written once, on the same terms as the sender's: an identical re-init is the +/// genesis replay, a different one would redirect every lock on the zone. +#[test] +fn the_bridge_pins_are_written_once_and_replayable() { + let bridge_lock_id = programs::bridge_lock().id(); + let config_id = bridge_lock_core::config_account_id(bridge_lock_id); + let outbox_id = programs::cross_zone_outbox().id(); + let wrapped_token_id = programs::wrapped_token().id(); + + let init = |outbox: lee_core::program::ProgramId, target: lee_core::program::ProgramId| { + let message = Message::try_new( + bridge_lock_id, + vec![config_id], + vec![], + bridge_lock_core::Instruction::InitConfig { + outbox_program_id: outbox, + target_program_id: target, + }, + ) + .expect("build InitConfig message"); + PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])) + }; + + let mut state = base_state(); + + let diff = ValidatedStateDiff::from_public_transaction( + &init(outbox_id, wrapped_token_id), + &state, + 1, + 0, + ) + .expect("the first init claims the config PDA"); + state.apply_state_diff(diff); + assert_eq!( + bridge_lock_core::read_config(&state.get_account_by_id(config_id).data.into_inner()), + Some((outbox_id, wrapped_token_id)), + "the config pins both programs after genesis" + ); + + ValidatedStateDiff::from_public_transaction(&init(outbox_id, wrapped_token_id), &state, 2, 0) + .expect("replaying the identical init is a no-op, not a failure"); + + // Either half moving is a redirect: the outbox decides whether the emission is + // recorded, the target where the value lands. + for (outbox, target, what) in [ + ([3; 8], wrapped_token_id, "outbox"), + (outbox_id, [3; 8], "mint target"), + ] { + let Err(err) = + ValidatedStateDiff::from_public_transaction(&init(outbox, target), &state, 3, 0) + else { + panic!("a re-init naming a different {what} must not execute"); + }; + assert!( + format!("{err:?}").contains("already pins a different outbox or mint target"), + "rejected for the wrong reason: {err:?}" + ); + } +} + +/// An emitter with no pin cannot fall back to a caller-named outbox: it stops +/// emitting. The state a zone reaches by skipping the genesis init. +#[test] +fn a_send_before_the_pin_is_set_is_rejected() { + let sender_id = programs::ping_sender().id(); + let outbox_id = programs::cross_zone_outbox().id(); + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let state = base_state(); + let slot = outbox_pda(outbox_id, sender_id, &zone_b, ordinal); + let send = send_tx( + vec![sender_config_account_id(sender_id), slot], + zone_b, + ordinal, + ); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&send, &state, 1, 0) else { + panic!("a send with no outbox pinned must not execute"); + }; + assert!( + format!("{err:?}").contains("config account holds an outbox program id"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// The config is read by address, so substituting another account for it fails +/// rather than pinning the outbox to whatever that account happens to hold. +#[test] +fn a_send_with_a_substituted_config_account_is_rejected() { + let sender_id = programs::ping_sender().id(); + let outbox_id = programs::cross_zone_outbox().id(); + let zone_b = [2_u8; 32]; + let ordinal = 0; + + let mut state = base_state(); + seed_ping_sender_config(&mut state); + + let slot = outbox_pda(outbox_id, sender_id, &zone_b, ordinal); + let send = send_tx(vec![ping_record_pda(sender_id), slot], zone_b, ordinal); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&send, &state, 1, 0) else { + panic!("a send over a substituted config account must not execute"); + }; + assert!( + format!("{err:?}").contains("must be the ping-sender config PDA"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// Written once: an identical re-init has to succeed, since genesis is replayed +/// during multi-sequencer reconstruction, while one naming a different outbox has +/// to fail, or anyone could redirect every emission on the zone after genesis. +#[test] +fn the_outbox_pin_is_written_once_and_replayable() { + let sender_id = programs::ping_sender().id(); + let config_id = sender_config_account_id(sender_id); + + // Unsigned and nonce-free, as genesis builds it: the config PDA has no signer. + let init = |outbox: lee_core::program::ProgramId| { + let message = Message::try_new( + sender_id, + vec![config_id], + vec![], + ping_core::SenderInstruction::InitConfig { + outbox_program_id: outbox, + }, + ) + .expect("build InitConfig message"); + PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])) + }; + + let mut state = base_state(); + let outbox_id = programs::cross_zone_outbox().id(); + + let first = init(outbox_id); + let diff = ValidatedStateDiff::from_public_transaction(&first, &state, 1, 0) + .expect("the first init claims the config PDA"); + state.apply_state_diff(diff); + assert_eq!( + read_outbox(&state.get_account_by_id(config_id).data.into_inner()), + Some(outbox_id), + "the config pins the outbox after genesis" + ); + + ValidatedStateDiff::from_public_transaction(&init(outbox_id), &state, 2, 0) + .expect("replaying the identical init is a no-op, not a failure"); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&init([3; 8]), &state, 3, 0) else { + panic!("a re-init naming a different outbox must not execute"); + }; + assert!( + format!("{err:?}").contains("already pins a different outbox"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// A token that authorizes nothing mints for nobody. The state a zone reaches with +/// no peers configured, where the config is still seeded so its PDA cannot be +/// claimed by a first initializer. +#[test] +fn a_mint_is_refused_when_the_token_authorizes_no_source() { + let inbox_id = programs::cross_zone_inbox().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let self_zone = [1_u8; 32]; + let src_zone = [2_u8; 32]; + + let mut state = base_state(); + seed_inbox_config(&mut state, self_zone); + seed_wrapped_config(&mut state, vec![]); let msg = CrossZoneMessage { src_zone, - src_block_id, + src_block_id: 5, + src_block_hash: SRC_BLOCK_HASH, src_tx_index: 0, - src_program_id: [9_u32; 8], + src_program_id: programs::bridge_lock().id(), target_program_id: wrapped_token_id, payload: mint_payload(), l1_inclusion_witness: None, }; - - let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); - let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id); - let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); - let message = Message::try_new( inbox_id, - vec![ - inbox_config_account_id(inbox_id), - seen_id, - wrapped_config_id, - holding_id, - ], + dispatch_accounts( + inbox_id, + &msg, + vec![ + wrapped_token_core::config_account_id(wrapped_token_id), + wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT), + ], + ), vec![], InboxInstruction::Dispatch(msg), ) .expect("build dispatch message"); let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); - let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) - .expect("dispatch must validate and execute"); + let Err(err) = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) else { + panic!("a token authorizing nothing must not mint"); + }; + assert!( + format!("{err:?}").contains("peer source this token authorizes"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// The marker only means something because the caller is pinned to the inbox. +/// Invoked directly, with the caller handing in the marker themselves, the mint +/// must refuse before it ever looks at it. +#[test] +fn a_top_level_mint_is_refused() { + let inbox_id = programs::cross_zone_inbox().id(); + let wrapped_token_id = programs::wrapped_token().id(); + let src_zone = [2_u8; 32]; + let src_program_id = programs::bridge_lock().id(); + + let mut state = base_state(); + seed_wrapped_config(&mut state, vec![(src_zone, src_program_id)]); + + let marker_id = inbox_source_marker_account_id(inbox_id, &src_zone, src_program_id); + let message = Message::try_new( + wrapped_token_id, + vec![ + marker_id, + wrapped_token_core::config_account_id(wrapped_token_id), + wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT), + ], + vec![], + wrapped_token_core::Instruction::Mint { + recipient: RECIPIENT, + amount: LOCK_AMOUNT, + }, + ) + .expect("build mint message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + let Err(err) = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) else { + panic!("a directly invoked mint must not execute"); + }; + assert!( + format!("{err:?}").contains("only callable by the authorized minter"), + "rejected for the wrong reason: {err:?}" + ); +} + +/// Drives a hand-built `cross_zone_inbox::Dispatch` (as the watcher would inject) +/// and asserts it chains into `wrapped_token::Mint`, crediting the recipient. +#[test] +fn inbox_dispatch_mints_wrapped_token() { + let diff = dispatch_mint(LOCK_AMOUNT).expect("dispatch must validate and execute"); + let holding_id = + wrapped_token_core::holding_account_id(programs::wrapped_token().id(), &RECIPIENT); let minted = wrapped_token_core::read_balance( &diff.public_diff()[&holding_id].data.clone().into_inner(), ); @@ -297,12 +1054,11 @@ fn inbox_dispatch_mints_wrapped_token() { ); } -/// A zone that bridges must allow `wrapped_token` as a target. When that -/// allowance was per peer rather than per source program, it was enough for any -/// emitter on the peer to reach it, and `ping_sender` lets its caller choose the -/// target and payload freely. Any user on the peer could therefore mint wrapped -/// tokens with no lock and no escrow behind them, by routing a `Mint` payload -/// through the ping emitter. The route is the pair, so this must not execute. +/// `ping_sender` lets its caller choose the target and payload freely, so any user +/// on a peer can aim a `Mint` payload at `wrapped_token`. The inbox no longer +/// refuses it; the token does, because the marker names `ping_sender` and the +/// token authorized only the bridge. This is the check that replaced the central +/// route table, so it must be the thing that rejects here. #[test] fn a_mint_from_an_unrouted_emitter_is_rejected() { let inbox_id = programs::cross_zone_inbox().id(); @@ -314,18 +1070,13 @@ fn a_mint_from_an_unrouted_emitter_is_rejected() { let mut state = base_state(); // The config a bridging zone writes: the lock program may mint, nothing else. - seed_inbox_config( - &mut state, - self_zone, - src_zone, - programs::bridge_lock().id(), - wrapped_token_id, - ); - seed_wrapped_config(&mut state); + seed_inbox_config(&mut state, self_zone); + seed_wrapped_config(&mut state, vec![(src_zone, programs::bridge_lock().id())]); let msg = CrossZoneMessage { src_zone, src_block_id, + src_block_hash: SRC_BLOCK_HASH, src_tx_index: 0, // The emitter a user can drive directly, aimed at the bridge's target. src_program_id: programs::ping_sender().id(), @@ -334,27 +1085,24 @@ fn a_mint_from_an_unrouted_emitter_is_rejected() { l1_inclusion_witness: None, }; - let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id); let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); let message = Message::try_new( inbox_id, - vec![ - inbox_config_account_id(inbox_id), - seen_id, - wrapped_config_id, - holding_id, - ], + dispatch_accounts(inbox_id, &msg, vec![wrapped_config_id, holding_id]), vec![], InboxInstruction::Dispatch(msg), ) .expect("build dispatch message"); let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + let Err(err) = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0) else { + panic!("a delivery from a source the token did not authorize must not mint"); + }; assert!( - ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0).is_err(), - "a delivery from an emitter with no route to wrapped_token must not mint" + format!("{err:?}").contains("peer source this token authorizes"), + "rejected for the wrong reason: {err:?}" ); } @@ -372,18 +1120,13 @@ fn a_mint_from_the_routed_emitter_is_accepted() { let src_block_id = 5; let mut state = base_state(); - seed_inbox_config( - &mut state, - self_zone, - src_zone, - bridge_lock_id, - wrapped_token_id, - ); - seed_wrapped_config(&mut state); + seed_inbox_config(&mut state, self_zone); + seed_wrapped_config(&mut state, vec![(src_zone, programs::bridge_lock().id())]); let msg = CrossZoneMessage { src_zone, src_block_id, + src_block_hash: SRC_BLOCK_HASH, src_tx_index: 0, src_program_id: bridge_lock_id, target_program_id: wrapped_token_id, @@ -391,18 +1134,12 @@ fn a_mint_from_the_routed_emitter_is_accepted() { l1_inclusion_witness: None, }; - let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id); let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT); let message = Message::try_new( inbox_id, - vec![ - inbox_config_account_id(inbox_id), - seen_id, - wrapped_config_id, - holding_id, - ], + dispatch_accounts(inbox_id, &msg, vec![wrapped_config_id, holding_id]), vec![], InboxInstruction::Dispatch(msg), ) @@ -431,21 +1168,16 @@ fn mint_replay_rejected() { let src_tx_index = 0; let mut state = base_state(); - seed_inbox_config( - &mut state, - self_zone, - src_zone, - [9_u32; 8], - wrapped_token_id, - ); - seed_wrapped_config(&mut state); + seed_inbox_config(&mut state, self_zone); + seed_wrapped_config(&mut state, vec![(src_zone, [9_u32; 8])]); - // Seed the seen-shard as already containing this message's key, so the inbox - // takes the replay no-op branch. The shard is inbox-owned (claimed on a prior - // delivery), so the guest leaves it untouched. + // Seed the seen-shard as already holding this delivery, so the inbox takes + // the replay no-op branch. The shard is inbox-owned (claimed on a prior + // delivery) and bound to the same source block, so the guest leaves it + // untouched. let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); let mut shard = SeenShard::default(); - shard.insert(message_key(&src_zone, src_block_id, src_tx_index)); + shard.insert(SRC_BLOCK_HASH, src_tx_index); state = state.with_public_accounts([( seen_id, Account { @@ -462,6 +1194,7 @@ fn mint_replay_rejected() { let msg = CrossZoneMessage { src_zone, src_block_id, + src_block_hash: SRC_BLOCK_HASH, src_tx_index, src_program_id: [9_u32; 8], target_program_id: wrapped_token_id, @@ -474,12 +1207,7 @@ fn mint_replay_rejected() { let message = Message::try_new( inbox_id, - vec![ - inbox_config_account_id(inbox_id), - seen_id, - wrapped_config_id, - holding_id, - ], + dispatch_accounts(inbox_id, &msg, vec![wrapped_config_id, holding_id]), vec![], InboxInstruction::Dispatch(msg), ) @@ -503,3 +1231,125 @@ fn mint_replay_rejected() { assert_eq!(shard_after, shard, "replay must not modify the seen-shard"); } } + +/// A peer publishing two blocks at one block id gets at most one delivered from. +/// +/// Both resolve to the same shard account; the first binds it. Failing rather +/// than no-opping is the point: a replay no-op would let a peer choose which of +/// two messages at one coordinate the target program ever sees. +#[test] +fn a_delivery_from_a_second_block_at_the_same_id_is_refused() { + let inbox_id = programs::cross_zone_inbox().id(); + let receiver_id = programs::ping_receiver().id(); + + let self_zone = [1_u8; 32]; + let src_zone = [2_u8; 32]; + let src_block_id = 5; + let other_block_hash = [8_u8; 32]; + + let mut state = base_state(); + seed_inbox_config(&mut state, self_zone); + seed_receiver_config(&mut state, vec![(src_zone, [9_u32; 8])]); + + // The shard as the first delivery left it: bound, holding transaction 0. + let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id); + let mut shard = SeenShard::default(); + shard.insert(SRC_BLOCK_HASH, 0); + state = state.with_public_accounts([( + seen_id, + Account { + program_owner: inbox_id, + balance: 0, + data: shard + .to_bytes() + .try_into() + .expect("shard fits in account data"), + nonce: 0_u128.into(), + }, + )]); + + let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { + payload: b"from-the-other-block".to_vec(), + }) + .expect("serialize ping instruction"); + let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); + + // A different transaction index, so this is not a replay: only the source + // block differs from what the shard is bound to. + let msg = CrossZoneMessage { + src_zone, + src_block_id, + src_block_hash: other_block_hash, + src_tx_index: 1, + src_program_id: [9_u32; 8], + target_program_id: receiver_id, + payload, + l1_inclusion_witness: None, + }; + + let record_id = ping_record_pda(receiver_id); + let message = Message::try_new( + inbox_id, + dispatch_accounts( + inbox_id, + &msg, + vec![receiver_config_account_id(receiver_id), record_id], + ), + vec![], + InboxInstruction::Dispatch(msg), + ) + .expect("build dispatch message"); + let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![])); + + assert!( + ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0).is_err(), + "a delivery from a block the shard is not bound to must not execute" + ); + + // Control: the same delivery naming the bound block executes, so the refusal + // above is the binding and not the transaction's shape. + let control_words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record { + payload: b"from-the-bound-block".to_vec(), + }) + .expect("serialize ping instruction"); + let control_msg = CrossZoneMessage { + src_zone, + src_block_id, + src_block_hash: SRC_BLOCK_HASH, + src_tx_index: 1, + src_program_id: [9_u32; 8], + target_program_id: receiver_id, + payload: control_words + .iter() + .flat_map(|word| word.to_le_bytes()) + .collect(), + l1_inclusion_witness: None, + }; + let control_message = Message::try_new( + inbox_id, + dispatch_accounts( + inbox_id, + &control_msg, + vec![receiver_config_account_id(receiver_id), record_id], + ), + vec![], + InboxInstruction::Dispatch(control_msg), + ) + .expect("build dispatch message"); + let control_tx = PublicTransaction::new(control_message, WitnessSet::from_raw_parts(vec![])); + + let diff = ValidatedStateDiff::from_public_transaction(&control_tx, &state, 1, 0) + .expect("a second delivery from the bound block executes"); + let public_diff = diff.public_diff(); + let seen_after = public_diff + .get(&seen_id) + .expect("the shard records the new delivery"); + let shard_after = + SeenShard::from_bytes(&seen_after.data.clone().into_inner()).expect("seen shard decodes"); + assert!(shard_after.contains(0), "the first delivery is still there"); + assert!(shard_after.contains(1), "and the second is recorded"); + assert_eq!( + shard_after.src_block_hash, SRC_BLOCK_HASH, + "a shard stays bound to the block that claimed it" + ); +} diff --git a/integration_tests/tests/cross_zone_verified.rs b/integration_tests/tests/cross_zone_verified.rs index 92ccdacbe..21cb4e102 100644 --- a/integration_tests/tests/cross_zone_verified.rs +++ b/integration_tests/tests/cross_zone_verified.rs @@ -17,13 +17,18 @@ use cross_zone_outbox_core::outbox_pda; use integration_tests::{ config::{self, SequencerPartialConfig}, indexer_client::IndexerClient, - setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer}, }; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; -use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use ping_core::{ + ReceiverInstruction, SenderInstruction, ping_record_pda, receiver_config_account_id, + sender_config_account_id, +}; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use tokio::test; const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600); @@ -31,11 +36,6 @@ const PING_PAYLOAD: &[u8] = b"hello-verified-zone"; #[test] async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> { - // Declared first so it outlives both zones (drops run in reverse order). - let (_bedrock, bedrock_addr) = setup_bedrock_node() - .await - .context("Failed to set up shared Bedrock node")?; - let partial = SequencerPartialConfig::default(); let channel_a = config::bedrock_channel_id(); let channel_b = config::bedrock_channel_id_b(); @@ -54,31 +54,40 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> { }], }; + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel_a, + }) + .disable_wallet() + .with_sequencer_partial_config(partial) + .with_genesis(vec![]), + ) + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel_b, + }) + .disable_wallet() + .with_sequencer_partial_config(partial) + .with_genesis(vec![]) + .with_cross_zone(Some(cross_zone)), + ) + .build() + .await?; + // Zone A: source. Zone B: destination, with the watcher on its sequencer and // the verifier on its indexer. - let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel_a) - .with_genesis(vec![]) - .setup() - .await - .context("Failed to set up zone A sequencer")?; - let (_idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None) - .await - .context("Failed to set up zone A indexer")?; - let (_seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel_b) - .with_genesis(vec![]) - .with_cross_zone(cross_zone.clone()) - .setup() - .await - .context("Failed to set up zone B sequencer")?; - let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, Some(cross_zone)) - .await - .context("Failed to set up zone B indexer")?; + let ind_client_b = ctx.indexer_client_zone(channel_b).unwrap(); + + let seq_client_a = &ctx + .zone_default_sequencer_component(channel_a) + .sequencer_client; // Submit the ping on zone A, addressed to ping_receiver on zone B. let ping = build_ping_tx(zone_b, receiver_id); - sequencer_client(seq_a.addr())? + seq_client_a .send_transaction(ping) .await .context("Failed to submit ping on zone A")?; @@ -86,11 +95,8 @@ async fn indexer_verifies_and_delivers_cross_zone_ping() -> Result<()> { // Wait until zone B's indexer records the delivered payload. The indexer only // applies the dispatch after re-deriving and verifying it. let record_id = ping_record_pda(receiver_id); - let indexer = indexer_client(idx_b.addr()) - .await - .context("Failed to build indexer client")?; - let delivered = wait_for_indexer_delivery(&indexer, record_id).await?; + let delivered = wait_for_indexer_delivery(ind_client_b, record_id).await?; assert_eq!( delivered, PING_PAYLOAD, "Zone B's indexer must record the verified cross-zone payload" @@ -109,18 +115,21 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); let send = SenderInstruction::Send { - outbox_program_id: outbox_id, target_zone, target_program_id: receiver_id, - target_accounts: vec![ping_record_pda(receiver_id).into_value()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload, ordinal, }; - let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal); + let sender_id = programs::ping_sender().id(); + let outbox_account = outbox_pda(outbox_id, sender_id, &target_zone, ordinal); let message = Message::try_new( - programs::ping_sender().id(), - vec![outbox_account], + sender_id, + vec![sender_config_account_id(sender_id), outbox_account], vec![], send, ) diff --git a/integration_tests/tests/cross_zone_watcher_restart.rs b/integration_tests/tests/cross_zone_watcher_restart.rs index 86dfc7059..e30875201 100644 --- a/integration_tests/tests/cross_zone_watcher_restart.rs +++ b/integration_tests/tests/cross_zone_watcher_restart.rs @@ -25,7 +25,10 @@ use integration_tests::{ }; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::program::ProgramId; -use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use ping_core::{ + ReceiverInstruction, SenderInstruction, ping_record_pda, receiver_config_account_id, + sender_config_account_id, +}; use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use tokio::test; @@ -190,18 +193,21 @@ fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransactio let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); let send = SenderInstruction::Send { - outbox_program_id: outbox_id, target_zone, target_program_id: receiver_id, - target_accounts: vec![ping_record_pda(receiver_id).into_value()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload, ordinal, }; - let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal); + let sender_id = programs::ping_sender().id(); + let outbox_account = outbox_pda(outbox_id, sender_id, &target_zone, ordinal); let message = Message::try_new( - programs::ping_sender().id(), - vec![outbox_account], + sender_id, + vec![sender_config_account_id(sender_id), outbox_account], vec![], send, ) diff --git a/integration_tests/tests/indexer_block_batching.rs b/integration_tests/tests/indexer_block_batching.rs index d5999f105..89619f3c8 100644 --- a/integration_tests/tests/indexer_block_batching.rs +++ b/integration_tests/tests/indexer_block_batching.rs @@ -6,16 +6,15 @@ use anyhow::Result; use indexer_service_rpc::RpcClient as _; use integration_tests::{TestContext, wait_for_indexer_to_catch_up}; -use log::info; #[tokio::test] async fn indexer_block_batching() -> Result<()> { let ctx = TestContext::new().await?; - info!("Waiting for indexer to parse blocks"); + log::info!("Waiting for indexer to parse blocks"); let last_block_indexer = wait_for_indexer_to_catch_up(&ctx).await?; - info!("Last block on ind now is {last_block_indexer}"); + log::info!("Last block on ind now is {last_block_indexer}"); assert!(last_block_indexer > 0); @@ -31,7 +30,7 @@ async fn indexer_block_batching() -> Result<()> { for block in &block_batch[1..] { assert_eq!(block.header.prev_block_hash, prev_block_hash); - info!("Block {} chain-consistent", block.header.block_id); + log::info!("Block {} chain-consistent", block.header.block_id); prev_block_hash = block.header.hash; } diff --git a/integration_tests/tests/indexer_ffi_block_batching.rs b/integration_tests/tests/indexer_ffi_block_batching.rs index c244fbb0d..38b637220 100644 --- a/integration_tests/tests/indexer_ffi_block_batching.rs +++ b/integration_tests/tests/indexer_ffi_block_batching.rs @@ -6,7 +6,6 @@ use anyhow::Result; use indexer_ffi::api::types::FfiOption; -use log::info; #[path = "indexer_ffi_helpers/mod.rs"] mod indexer_ffi_helpers; @@ -20,10 +19,10 @@ fn indexer_ffi_block_batching() -> Result<()> { // WAIT: poll until the indexer has finalized at least two blocks (so the // chain-consistency check below verifies at least one block link), returning // early instead of sleeping for the full timeout. - info!("Waiting for indexer to parse blocks"); + log::info!("Waiting for indexer to parse blocks"); let last_block_indexer = indexer_ffi_helpers::wait_for_indexer_ffi_block(&indexer_ffi, 2)?; - info!("Last block on indexer FFI now is {last_block_indexer}"); + log::info!("Last block on indexer FFI now is {last_block_indexer}"); assert!(last_block_indexer > 0); @@ -44,7 +43,7 @@ fn indexer_ffi_block_batching() -> Result<()> { assert_eq!(last_block_prev_hash, block.header.hash.data); - info!("Block {} chain-consistent", block.header.block_id); + log::info!("Block {} chain-consistent", block.header.block_id); last_block_prev_hash = block.header.prev_block_hash.data; } diff --git a/integration_tests/tests/indexer_ffi_helpers/mod.rs b/integration_tests/tests/indexer_ffi_helpers/mod.rs index 09e0a9271..bd8d8e57e 100644 --- a/integration_tests/tests/indexer_ffi_helpers/mod.rs +++ b/integration_tests/tests/indexer_ffi_helpers/mod.rs @@ -17,8 +17,11 @@ use indexer_ffi::{ types::{FfiAccountId, FfiOption, FfiVec, account::FfiAccount, block::FfiBlock}, }, }; -use integration_tests::{BlockingTestContext, TestContext}; +use integration_tests::BlockingTestContext; use tempfile::TempDir; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; unsafe extern "C" { pub unsafe fn query_last_block(indexer: *const IndexerServiceFFI) -> LastBlockIdResult; @@ -83,7 +86,12 @@ pub fn setup_indexer_ffi(bedrock_addr: SocketAddr) -> Result<(IndexerServiceFFI, } pub fn setup() -> Result<(BlockingTestContext, IndexerServiceFFI, TempDir)> { - let ctx = TestContext::builder().disable_indexer().build_blocking()?; + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()).disable_indexer(), + ) + .build_blocking()?; + // Don't borrow `ctx.runtime()`: `ctx` (and its by-value tokio runtime) is // moved into the returned tuple, which would leave any pointer into it // dangling. Pass a null runtime so the FFI owns its own — the same path the diff --git a/integration_tests/tests/indexer_ffi_state_consistency.rs b/integration_tests/tests/indexer_ffi_state_consistency.rs index 0a41c68c0..593b1ea76 100644 --- a/integration_tests/tests/indexer_ffi_state_consistency.rs +++ b/integration_tests/tests/indexer_ffi_state_consistency.rs @@ -14,7 +14,6 @@ use integration_tests::{ verify_commitment_is_in_state, }; use lee::AccountId; -use log::info; use wallet::cli::{Command, programs::native_token_transfer::AuthTransferSubcommand}; #[path = "indexer_ffi_helpers/mod.rs"] @@ -36,10 +35,10 @@ fn indexer_ffi_state_consistency() -> Result<()> { ctx.block_on_mut(|ctx| wallet::cli::execute_subcommand(ctx.wallet_mut(), command))?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); - info!("Checking correct balance move"); + log::info!("Checking correct balance move"); let acc_1_balance = ctx.block_on(|ctx| { sequencer_service_rpc::RpcClient::get_account_balance( ctx.sequencer_client(), @@ -53,8 +52,8 @@ fn indexer_ffi_state_consistency() -> Result<()> { ) })?; - info!("Balance of sender: {acc_1_balance:#?}"); - info!("Balance of receiver: {acc_2_balance:#?}"); + log::info!("Balance of sender: {acc_1_balance:#?}"); + log::info!("Balance of receiver: {acc_2_balance:#?}"); assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); @@ -74,7 +73,7 @@ fn indexer_ffi_state_consistency() -> Result<()> { ctx.block_on_mut(|ctx| wallet::cli::execute_subcommand(ctx.wallet_mut(), command))?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let new_commitment1 = ctx @@ -95,10 +94,10 @@ fn indexer_ffi_state_consistency() -> Result<()> { ctx.block_on(|ctx| verify_commitment_is_in_state(new_commitment2, ctx.sequencer_client())); assert!(commitment_check2); - info!("Successfully transferred privately to owned account"); + log::info!("Successfully transferred privately to owned account"); // WAIT - info!("Waiting for indexer to parse blocks"); + log::info!("Waiting for indexer to parse blocks"); std::thread::sleep(L2_TO_L1_TIMEOUT); let acc1_ind_state_ffi = unsafe { @@ -125,7 +124,7 @@ fn indexer_ffi_state_consistency() -> Result<()> { let acc2_ind_state_pre = unsafe { &*acc2_ind_state_ffi.value }; let acc2_ind_state: Account = acc2_ind_state_pre.into(); - info!("Checking correct state transition"); + log::info!("Checking correct state transition"); let acc1_seq_state = ctx.block_on(|ctx| { sequencer_service_rpc::RpcClient::get_account( ctx.sequencer_client(), diff --git a/integration_tests/tests/indexer_ffi_state_consistency_with_labels.rs b/integration_tests/tests/indexer_ffi_state_consistency_with_labels.rs index fbc0b422d..f19ad9a4c 100644 --- a/integration_tests/tests/indexer_ffi_state_consistency_with_labels.rs +++ b/integration_tests/tests/indexer_ffi_state_consistency_with_labels.rs @@ -10,7 +10,6 @@ use std::time::Duration; use anyhow::Result; use indexer_service_protocol::Account; use integration_tests::{L2_TO_L1_TIMEOUT, TIME_TO_WAIT_FOR_BLOCK_SECONDS, public_mention}; -use log::info; use wallet::{ account::Label, cli::{Command, programs::native_token_transfer::AuthTransferSubcommand}, @@ -52,7 +51,7 @@ fn indexer_ffi_state_consistency_with_labels() -> Result<()> { ctx.block_on_mut(|ctx| wallet::cli::execute_subcommand(ctx.wallet_mut(), command))?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let acc_1_balance = ctx.block_on(|ctx| { @@ -71,7 +70,7 @@ fn indexer_ffi_state_consistency_with_labels() -> Result<()> { assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); - info!("Waiting for indexer to parse blocks"); + log::info!("Waiting for indexer to parse blocks"); std::thread::sleep(L2_TO_L1_TIMEOUT); let acc1_ind_state_ffi = unsafe { @@ -95,7 +94,7 @@ fn indexer_ffi_state_consistency_with_labels() -> Result<()> { assert_eq!(acc1_ind_state, acc1_seq_state.into()); - info!("Indexer state is consistent after label-based transfer"); + log::info!("Indexer state is consistent after label-based transfer"); Ok(()) } diff --git a/integration_tests/tests/indexer_stall.rs b/integration_tests/tests/indexer_stall.rs index ae1b8b6a3..3b2f95e44 100644 --- a/integration_tests/tests/indexer_stall.rs +++ b/integration_tests/tests/indexer_stall.rs @@ -9,7 +9,6 @@ use anyhow::{Context as _, Result}; use indexer_service_protocol::IndexerSyncState; use indexer_service_rpc::RpcClient as _; use integration_tests::{TestContext, wait_for_indexer_to_catch_up}; -use log::info; const CAUGHT_UP_STATUS_TIMEOUT: Duration = Duration::from_secs(60); @@ -32,7 +31,7 @@ async fn indexer_status_rpc_reports_caught_up_with_no_stall() -> Result<()> { if status.state == IndexerSyncState::CaughtUp { return anyhow::Ok(status); } - info!("Waiting for caught-up indexer status, got {status:?}"); + log::info!("Waiting for caught-up indexer status, got {status:?}"); tokio::time::sleep(Duration::from_millis(500)).await; } }) diff --git a/integration_tests/tests/indexer_state_consistency.rs b/integration_tests/tests/indexer_state_consistency.rs index 4ed2fd260..bdd3e816c 100644 --- a/integration_tests/tests/indexer_state_consistency.rs +++ b/integration_tests/tests/indexer_state_consistency.rs @@ -13,7 +13,6 @@ use integration_tests::{ wait_for_indexer_to_catch_up, }; use lee::AccountId; -use log::info; #[tokio::test] async fn indexer_state_consistency() -> Result<()> { @@ -25,15 +24,15 @@ async fn indexer_state_consistency() -> Result<()> { ); send(&mut ctx, public_mention(acc0), public_mention(acc1), 100).await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; - info!("Checking correct balance move"); + log::info!("Checking correct balance move"); let acc_1_balance = account_balance(&ctx, ctx.existing_public_accounts()[0]).await?; let acc_2_balance = account_balance(&ctx, ctx.existing_public_accounts()[1]).await?; - info!("Balance of sender: {acc_1_balance:#?}"); - info!("Balance of receiver: {acc_2_balance:#?}"); + log::info!("Balance of sender: {acc_1_balance:#?}"); + log::info!("Balance of receiver: {acc_2_balance:#?}"); assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); @@ -43,15 +42,15 @@ async fn indexer_state_consistency() -> Result<()> { send(&mut ctx, private_mention(from), private_mention(to), 100).await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; assert_private_commitment_in_state(&ctx, from, "sender").await?; assert_private_commitment_in_state(&ctx, to, "receiver").await?; - info!("Successfully transferred privately to owned account"); + log::info!("Successfully transferred privately to owned account"); - info!("Waiting for indexer to parse blocks"); + log::info!("Waiting for indexer to parse blocks"); wait_for_indexer_to_catch_up(&ctx).await?; let acc1_ind_state = ctx @@ -65,7 +64,7 @@ async fn indexer_state_consistency() -> Result<()> { .await .unwrap(); - info!("Checking correct state transition"); + log::info!("Checking correct state transition"); let acc1_seq_state = get_account(&ctx, ctx.existing_public_accounts()[0]).await?; let acc2_seq_state = get_account(&ctx, ctx.existing_public_accounts()[1]).await?; diff --git a/integration_tests/tests/indexer_state_consistency_with_labels.rs b/integration_tests/tests/indexer_state_consistency_with_labels.rs index 219c3ebfe..d8af09342 100644 --- a/integration_tests/tests/indexer_state_consistency_with_labels.rs +++ b/integration_tests/tests/indexer_state_consistency_with_labels.rs @@ -12,7 +12,6 @@ use integration_tests::{ TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, get_account, public_mention, send, wait_for_indexer_to_catch_up, }; -use log::info; use wallet::{ account::Label, cli::{CliAccountMention, Command}, @@ -47,7 +46,7 @@ async fn indexer_state_consistency_with_labels() -> Result<()> { ) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; let acc_1_balance = account_balance(&ctx, ctx.existing_public_accounts()[0]).await?; @@ -56,7 +55,7 @@ async fn indexer_state_consistency_with_labels() -> Result<()> { assert_eq!(acc_1_balance, 9900); assert_eq!(acc_2_balance, 20100); - info!("Waiting for indexer to parse blocks"); + log::info!("Waiting for indexer to parse blocks"); wait_for_indexer_to_catch_up(&ctx).await?; let acc1_ind_state = ctx @@ -68,7 +67,7 @@ async fn indexer_state_consistency_with_labels() -> Result<()> { assert_eq!(acc1_ind_state, acc1_seq_state.into()); - info!("Indexer state is consistent after label-based transfer"); + log::info!("Indexer state is consistent after label-based transfer"); Ok(()) } diff --git a/integration_tests/tests/indexer_test_run.rs b/integration_tests/tests/indexer_test_run.rs index b54cdf825..2c4213e6e 100644 --- a/integration_tests/tests/indexer_test_run.rs +++ b/integration_tests/tests/indexer_test_run.rs @@ -5,7 +5,6 @@ use anyhow::Result; use integration_tests::{TestContext, wait_for_indexer_to_catch_up}; -use log::info; #[tokio::test] async fn indexer_test_run() -> Result<()> { @@ -16,8 +15,8 @@ async fn indexer_test_run() -> Result<()> { let last_block_seq = sequencer_service_rpc::RpcClient::get_last_block_id(ctx.sequencer_client()).await?; - info!("Last block on seq now is {last_block_seq}"); - info!("Last block on ind now is {last_block_indexer}"); + log::info!("Last block on seq now is {last_block_seq}"); + log::info!("Last block on ind now is {last_block_indexer}"); assert!(last_block_indexer > 0); diff --git a/integration_tests/tests/indexer_test_run_ffi.rs b/integration_tests/tests/indexer_test_run_ffi.rs index e37b619ee..594bf7a04 100644 --- a/integration_tests/tests/indexer_test_run_ffi.rs +++ b/integration_tests/tests/indexer_test_run_ffi.rs @@ -4,7 +4,6 @@ )] use anyhow::Result; -use log::info; #[path = "indexer_ffi_helpers/mod.rs"] mod indexer_ffi_helpers; @@ -19,7 +18,7 @@ fn indexer_test_run_ffi() -> Result<()> { // returning early instead of sleeping for the full timeout. let last_block_indexer_ffi = indexer_ffi_helpers::wait_for_indexer_ffi_block(&indexer_ffi, 1)?; - info!("Last block on indexer FFI now is {last_block_indexer_ffi}"); + log::info!("Last block on indexer FFI now is {last_block_indexer_ffi}"); assert!(last_block_indexer_ffi > 0); diff --git a/integration_tests/tests/keys.rs b/integration_tests/tests/keys.rs index c7e5d3c27..1b99cdfb1 100644 --- a/integration_tests/tests/keys.rs +++ b/integration_tests/tests/keys.rs @@ -14,7 +14,6 @@ use integration_tests::{ }; use key_protocol::key_management::key_tree::chain_index::ChainIndex; use lee::AccountId; -use log::info; use sequencer_service_rpc::RpcClient as _; use tokio::test; use wallet::cli::{ @@ -83,7 +82,7 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> { .context("Failed to get recipient's private account")?; assert_eq!(to_res_acc.balance, 100); - info!("Successfully transferred using claiming path"); + log::info!("Successfully transferred using claiming path"); Ok(()) } @@ -125,7 +124,7 @@ async fn restore_keys_from_seed() -> Result<()> { send_claiming_new_account(&mut ctx, from, to_account_id3, 102).await?; send_claiming_new_account(&mut ctx, from, to_account_id4, 103).await?; - info!("Preparation complete, performing keys restoration"); + log::info!("Preparation complete, performing keys restoration"); // Restore keys from seed wallet::cli::execute_keys_restoration(ctx.wallet_mut(), 10).await?; @@ -150,7 +149,7 @@ async fn restore_keys_from_seed() -> Result<()> { assert_eq!(acc1.account.balance, 100); assert_eq!(acc2.account.balance, 101); - info!("Tree checks passed, testing restored accounts can transact"); + log::info!("Tree checks passed, testing restored accounts can transact"); // Test that restored accounts can send transactions send( @@ -196,7 +195,7 @@ async fn restore_keys_from_seed() -> Result<()> { assert_eq!(acc3, 91); // 102 - 11 assert_eq!(acc4, 114); // 103 + 11 - info!("Successfully restored keys and verified transactions"); + log::info!("Successfully restored keys and verified transactions"); Ok(()) } diff --git a/integration_tests/tests/multi_sequencer.rs b/integration_tests/tests/multi_sequencer.rs index 34eae1e5a..ee2cde05e 100644 --- a/integration_tests/tests/multi_sequencer.rs +++ b/integration_tests/tests/multi_sequencer.rs @@ -14,18 +14,14 @@ use indexer_service_rpc::RpcClient as _; use integration_tests::{ config::{self, SequencerPartialConfig}, indexer_client::IndexerClient, - setup::{SequencerSetup, indexer_client, sequencer_client, setup_bedrock_node, setup_indexer}, }; -use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key}; -use sequencer_core::{block_publisher::post_channel_config, config::BedrockConfig}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts}; use tokio::test; -/// 1 s bedrock slots: rotate the turn every ~20 s of tenure; steal a stalled -/// turn after ~30 s (bounds the stall while B is accredited but not started). -const POSTING_TIMEFRAME_SLOTS: u32 = 20; -const POSTING_TIMEOUT_SLOTS: u32 = 30; const PHASE_TIMEOUT: Duration = Duration::from_secs(360); const POLL_INTERVAL: Duration = Duration::from_secs(2); const TRANSFER_AMOUNT: u128 = 10; @@ -34,86 +30,74 @@ const ROTATION_BLOCKS: u64 = 8; #[test] async fn multi_sequencer_committee_converges() -> Result<()> { - let (_bedrock, bedrock_addr) = setup_bedrock_node() - .await - .context("Failed to set up Bedrock node")?; - - // Fixed seeds so A can accredit B's public key before B exists. - let key_a = [0xA1_u8; ED25519_SECRET_KEY_SIZE]; - let key_b = [0xB2_u8; ED25519_SECRET_KEY_SIZE]; - let pub_a = Ed25519Key::from_bytes(&key_a).public_key(); - let pub_b = Ed25519Key::from_bytes(&key_b).public_key(); - + let bedrock_channel_id = config::bedrock_channel_id(); let partial = SequencerPartialConfig { block_create_timeout: Duration::from_secs(5), ..SequencerPartialConfig::default() }; - // Phase 1: A solo (its first inscription creates the channel), plus an indexer. - let (seq_a, _a_home) = SequencerSetup::new(partial, bedrock_addr) - .with_genesis(vec![]) - .with_bedrock_signing_key(key_a) - .setup() - .await - .context("Failed to set up sequencer A")?; - let a = sequencer_client(seq_a.addr())?; - let (idx, _idx_home) = setup_indexer(bedrock_addr, config::bedrock_channel_id(), None) - .await - .context("Failed to set up indexer")?; - let indexer = indexer_client(idx.addr()).await?; + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 2, + bedrock_channel: bedrock_channel_id, + }) + .disable_wallet() + .with_sequencer_partial_config(partial) + .with_genesis(vec![]), + ) + .build() + .await?; - wait_for_height(&a, 2, "sequencer A to produce past genesis").await?; + let mut seq_iterator = ctx.sequencer_components_iter(bedrock_channel_id).unwrap(); - // Phase 2: live roster change to [A, B] with rotation enabled, posted - // straight to bedrock with A's admin key (the operator one-shot path). - post_channel_config( - &BedrockConfig { - channel_id: config::bedrock_channel_id(), - node_url: config::addr_to_url(config::UrlProtocol::Http, bedrock_addr)?, - funding_key: config::bedrock_funding_key(), - auth: None, - priority_fee: sequencer_core::config::default_priority_fee(), - }, - &Ed25519Key::from_bytes(&key_a), - vec![pub_a, pub_b], - POSTING_TIMEFRAME_SLOTS, - POSTING_TIMEOUT_SLOTS, - 1, - 1, - ) - .await - .context("Failed to configure the channel committee")?; + let seq_client_a = &(seq_iterator.next().unwrap().sequencer_client); + let seq_client_b = &(seq_iterator.next().unwrap().sequencer_client); - let height_at_config = a.get_last_block_id().await?; + let indexer = ctx.indexer_client(); + + wait_for_height(seq_client_a, 2, "sequencer A to produce past genesis").await?; + + log::info!("Passed wait for height A to be at least 2"); + + let height_at_config = seq_client_a.get_last_block_id().await?; wait_for_height( - &a, + seq_client_a, height_at_config + 1, "A to produce after the roster change", ) .await?; - // Phase 3: B joins live and syncs the existing chain. - let (seq_b, _b_home) = SequencerSetup::new(partial, bedrock_addr) - .with_genesis(vec![]) - .with_bedrock_signing_key(key_b) - .setup() - .await - .context("Failed to set up sequencer B")?; - let b = sequencer_client(seq_b.addr())?; + log::info!( + "Passed wait for height A to be at least {}", + height_at_config + 1 + ); - let join_height = a.get_last_block_id().await?; - wait_for_height(&b, join_height, "B to sync to A's height at join").await?; + let join_height = seq_client_a.get_last_block_id().await?; + wait_for_height(seq_client_b, join_height, "B to sync to A's height at join").await?; + + log::info!("Passed wait for height B to be at least {join_height}"); // Phase 4: rotation + convergence over ≈4 turn windows. let rotation_target = join_height + ROTATION_BLOCKS; wait_for_height( - &a, + seq_client_a, rotation_target, "the chain to advance across turn windows", ) .await?; - wait_for_height(&b, rotation_target, "B to follow across turn windows").await?; - assert_same_chain(&a, &b).await?; + + log::info!("Passed wait for height A to be at least {rotation_target}"); + + wait_for_height( + seq_client_b, + rotation_target, + "B to follow across turn windows", + ) + .await?; + assert_same_chain(seq_client_a, seq_client_b).await?; + + log::info!("Passed wait for height B to be at least {rotation_target}"); // Phase 5: a tx submitted only to B is included by B and visible on A. let accounts = initial_public_user_accounts(); @@ -121,8 +105,8 @@ async fn multi_sequencer_committee_converges() -> Result<()> { let to = accounts[1].account_id; let sign_key = initial_pub_accounts_private_keys()[0].pub_sign_key.clone(); - let to_balance_before = a.get_account_balance(to).await?; - let nonce = b.get_accounts_nonces(vec![from]).await?[0]; + let to_balance_before = seq_client_a.get_account_balance(to).await?; + let nonce = seq_client_b.get_accounts_nonces(vec![from]).await?[0]; let tx = common::test_utils::create_transaction_native_token_transfer( from, nonce.0, @@ -130,21 +114,30 @@ async fn multi_sequencer_committee_converges() -> Result<()> { TRANSFER_AMOUNT, &sign_key, ); - b.send_transaction(tx) + seq_client_b + .send_transaction(tx) .await .context("Failed to submit the transfer to B")?; - wait_for_balance(&a, to, to_balance_before + TRANSFER_AMOUNT).await?; + wait_for_balance(seq_client_a, to, to_balance_before + TRANSFER_AMOUNT).await?; + + log::info!( + "Passed wait for height balance {to} to be {}", + to_balance_before + TRANSFER_AMOUNT + ); // Phase 6: the indexer finalizes the same chain, with no stall. - wait_for_finalized(&indexer, join_height).await?; + wait_for_finalized(indexer, join_height).await?; + + log::info!("Passed indexer to see finalized {join_height}"); + let finalized = indexer.get_last_finalized_block_id().await?.unwrap_or(0); for id in 1..=finalized { let block_i = indexer .get_block_by_id(id) .await? .with_context(|| format!("Indexer is missing finalized block {id}"))?; - let block_a = a + let block_a = seq_client_a .get_block(id) .await? .with_context(|| format!("A is missing block {id}"))?; @@ -165,6 +158,8 @@ async fn multi_sequencer_committee_converges() -> Result<()> { /// Polls the sequencer until its chain height reaches `target`. async fn wait_for_height(client: &SequencerClient, target: u64, what: &str) -> Result<()> { + log::info!("Waiting for {what:?}, target is {target}"); + let wait = async { loop { if client.get_last_block_id().await? >= target { @@ -184,6 +179,8 @@ async fn wait_for_balance( account: lee::AccountId, expected: u128, ) -> Result<()> { + log::info!("Waiting for {account} to have {expected} tokens"); + let wait = async { loop { if client.get_account_balance(account).await? == expected { @@ -199,6 +196,8 @@ async fn wait_for_balance( /// Polls the indexer until its finalized height reaches `target`. async fn wait_for_finalized(indexer: &IndexerClient, target: u64) -> Result<()> { + log::info!("Waiting for indexer to see target finalized, target is {target}"); + let wait = async { loop { if indexer.get_last_finalized_block_id().await?.unwrap_or(0) >= target { diff --git a/integration_tests/tests/private_pda.rs b/integration_tests/tests/private_pda.rs index b73e943a2..8f5bbf474 100644 --- a/integration_tests/tests/private_pda.rs +++ b/integration_tests/tests/private_pda.rs @@ -27,7 +27,6 @@ use lee_core::{ encryption::ViewingPublicKey, program::PdaSeed, }; -use log::info; use sequencer_service_rpc::RpcClient as _; use tokio::test; use wallet::{AccountIdentity, WalletCore}; @@ -181,7 +180,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { // ── Receive ────────────────────────────────────────────────────────────────────────────────── - info!("Sending to alice_pda_0 (identifier=0)"); + log::info!("Sending to alice_pda_0 (identifier=0)"); fund_private_pda( ctx.wallet_mut(), sender_0, @@ -195,7 +194,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { ) .await?; - info!("Sending to alice_pda_1 (identifier=1)"); + log::info!("Sending to alice_pda_1 (identifier=1)"); fund_private_pda( ctx.wallet_mut(), sender_1, @@ -209,7 +208,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { ) .await?; - info!("Waiting for block"); + log::info!("Waiting for block"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // Sync so alice's wallet discovers and stores both PDAs. @@ -263,7 +262,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { let amount_spend_0: u128 = 13; let amount_spend_1: u128 = 37; - info!("Alice spending from alice_pda_0"); + log::info!("Alice spending from alice_pda_0"); spend_private_pda( ctx.wallet_mut(), alice_pda_0_id, @@ -276,7 +275,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { ) .await?; - info!("Alice spending from alice_pda_1"); + log::info!("Alice spending from alice_pda_1"); spend_private_pda( ctx.wallet_mut(), alice_pda_1_id, @@ -289,7 +288,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { ) .await?; - info!("Waiting for block"); + log::info!("Waiting for block"); tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; sync_private(&mut ctx).await?; @@ -326,6 +325,6 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { "alice_pda_1 post-spend commitment not in state" ); - info!("Private PDA family member receive-and-spend test passed"); + log::info!("Private PDA family member receive-and-spend test passed"); Ok(()) } diff --git a/integration_tests/tests/program_deployment.rs b/integration_tests/tests/program_deployment.rs index 3c620168e..8c4bb1b62 100644 --- a/integration_tests/tests/program_deployment.rs +++ b/integration_tests/tests/program_deployment.rs @@ -8,8 +8,10 @@ use std::{io::Write as _, time::Duration}; use anyhow::Result; use common::transaction::LeeTransaction; use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, get_account, new_account}; -use log::info; use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use tokio::test; use wallet::{cli::Command, config::WalletConfigOverrides}; @@ -45,7 +47,7 @@ async fn deploy_and_execute_program() -> Result<()> { .send_transaction(LeeTransaction::Public(transaction)) .await?; - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); // Waiting for long time as it may take some time for such a big transaction to be included in a // block tokio::time::sleep(Duration::from_secs(2 * TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; @@ -58,7 +60,7 @@ async fn deploy_and_execute_program() -> Result<()> { assert_eq!(post_state_account.data.as_ref(), expected_data); assert_eq!(post_state_account.nonce.0, 1); - info!("Successfully deployed and executed program"); + log::info!("Successfully deployed and executed program"); Ok(()) } @@ -68,13 +70,17 @@ async fn deploy_invalid_program_fails() -> Result<()> { // An invalid program bytecode is rejected by the sequencer during block production, so the // deployment transaction is never included in a block. Shrink the wallet's polling window so // the command gives up quickly instead of waiting for the full default timeout. - let mut ctx = TestContext::builder() - .with_wallet_config_overrides(WalletConfigOverrides { - seq_poll_timeout: Some(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)), - seq_tx_poll_max_blocks: Some(5), - seq_poll_max_retries: Some(2), - ..WalletConfigOverrides::default() - }) + + let mut ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()) + .with_wallet_config_overrides(WalletConfigOverrides { + seq_poll_timeout: Some(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)), + seq_tx_poll_max_blocks: Some(5), + seq_poll_max_retries: Some(2), + ..WalletConfigOverrides::default() + }), + ) .build() .await?; @@ -92,7 +98,7 @@ async fn deploy_invalid_program_fails() -> Result<()> { "Deploying an invalid program should fail, but got: {result:?}" ); - info!("Deploying an invalid program failed as expected"); + log::info!("Deploying an invalid program failed as expected"); Ok(()) } diff --git a/integration_tests/tests/sequencer_bootstrap.rs b/integration_tests/tests/sequencer_bootstrap.rs index c4468c1b5..dc6d72739 100644 --- a/integration_tests/tests/sequencer_bootstrap.rs +++ b/integration_tests/tests/sequencer_bootstrap.rs @@ -13,7 +13,6 @@ use std::{path::Path, time::Duration}; use anyhow::{Context as _, Result, bail}; use indexer_service_rpc::RpcClient as _; use lee::{AccountId, PrivateKey, PublicKey}; -use logos_blockchain_core::mantle::ops::channel::ChannelId; use sequencer_core::config::GenesisAction; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use test_fixtures::{ @@ -237,8 +236,11 @@ async fn empty_local_reconstructs_from_populated_bedrock() -> Result<()> { // lost its local DB. drop(handle_a); tokio::time::sleep(Duration::from_secs(2)).await; - std::fs::remove_dir_all(home_a.path().join("rocksdb")) - .context("Failed to wipe sequencer L2 store")?; + std::fs::remove_dir_all(home_a.path().join(format!( + "rocksdb-{}", + test_fixtures::config::bedrock_channel_id() + ))) + .context("Failed to wipe sequencer L2 store")?; // Sequencer B restarts on the same home from that empty store and reconstructs. let handle_b = SequencerSetup::new(slow_blocks(), bedrock_addr) @@ -274,11 +276,13 @@ async fn empty_local_reconstructs_from_populated_bedrock() -> Result<()> { /// Case 3: local store is not empty, but the Bedrock channel is empty. /// /// A sequencer produces blocks (committing to a channel), is stopped, and is -/// restarted against a fresh/empty channel — i.e. the channel it committed to -/// was wiped or the node points at a different chain. Startup must fail rather -/// than silently resume onto a foreign channel. Crucially this must hold even -/// though the sequencer only ever *produced* (so it never recorded a per-block -/// anchor): the committed-but-missing-channel invariant catches it. +/// restarted with the same channel id against a Bedrock node where that channel +/// is empty — i.e. the channel it committed to was wiped. Startup must fail +/// rather than silently resume onto a foreign channel. Crucially this must hold +/// even though the sequencer only ever *produced* (so it never recorded a +/// per-block anchor): the committed-but-missing-channel invariant catches it. +/// A *different* channel id no longer exercises this, because the db path is +/// per-channel and a new id simply fresh-starts beside the old store. #[test] async fn nonempty_local_against_empty_channel_fails_startup() -> Result<()> { const PRODUCED_TARGET: u64 = 3; @@ -310,9 +314,12 @@ async fn nonempty_local_against_empty_channel_fails_startup() -> Result<()> { drop(handle_a); tokio::time::sleep(Duration::from_secs(2)).await; - // Restart on the SAME home (A's committed store: blocks + checkpoint) but - // pointed at a fresh, never-used channel — the channel it committed to is gone. - let empty_channel = ChannelId::from([0x5a_u8; 32]); + // Restart on the SAME home (A's committed store: blocks + checkpoint) and the + // SAME channel id, but against a fresh Bedrock node where that channel does + // not exist — the channel it committed to is gone. + let (_bedrock_b, bedrock_addr_b) = setup_bedrock_node() + .await + .context("Failed to setup second Bedrock")?; // Startup aborts on the missing-channel invariant (a panic in // `start_from_config`). Run it on a dedicated OS thread with its own runtime @@ -325,8 +332,7 @@ async fn nonempty_local_against_empty_channel_fails_startup() -> Result<()> { runtime.block_on(async { tokio::time::timeout( Duration::from_secs(90), - SequencerSetup::new(slow_blocks(), bedrock_addr) - .with_channel_id(empty_channel) + SequencerSetup::new(slow_blocks(), bedrock_addr_b) .with_genesis(genesis) .setup_at(&home_a_path), ) @@ -473,7 +479,10 @@ async fn local_behind_channel_reconstructs_forward() -> Result<()> { ]; let home = tempfile::tempdir().context("Failed to create sequencer home")?; - let rocksdb = home.path().join("rocksdb"); + let rocksdb = home.path().join(format!( + "rocksdb-{}", + test_fixtures::config::bedrock_channel_id() + )); // Bring the sequencer up to an early tip, then stop it so its store is at rest. { diff --git a/integration_tests/tests/shared_accounts.rs b/integration_tests/tests/shared_accounts.rs index cc6e6e1d3..83615540d 100644 --- a/integration_tests/tests/shared_accounts.rs +++ b/integration_tests/tests/shared_accounts.rs @@ -21,7 +21,6 @@ use anyhow::{Context as _, Result}; use integration_tests::{ TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention, sync_private, }; -use log::info; use tokio::test; use wallet::{ account::Label, @@ -81,7 +80,7 @@ async fn group_create_and_shared_account_registration() -> Result<()> { assert_eq!(entry.group_label, Label::new("test-group")); assert!(entry.pda_seed.is_none()); - info!("Shared account registered: {shared_account_id}"); + log::info!("Shared account registered: {shared_account_id}"); Ok(()) } @@ -156,7 +155,7 @@ async fn group_invite_join_key_agreement() -> Result<()> { "Key agreement: same GMS produces same keys" ); - info!("Key agreement verified via invite/join"); + log::info!("Key agreement verified via invite/join"); Ok(()) } @@ -225,7 +224,7 @@ async fn fund_shared_account_from_public() -> Result<()> { .shared_private_account(shared_id) .context("Shared account not found after sync")?; - info!( + log::info!( "Shared account balance after funding: {}", entry.account.balance ); diff --git a/integration_tests/tests/tps.rs b/integration_tests/tests/tps.rs index 5977dfc19..02b11cda0 100644 --- a/integration_tests/tests/tps.rs +++ b/integration_tests/tests/tps.rs @@ -14,7 +14,7 @@ use std::time::{Duration, Instant}; use anyhow::{Context as _, Result}; use bytesize::ByteSize; use common::transaction::LeeTransaction; -use integration_tests::{TestContext, config::SequencerPartialConfig}; +use integration_tests::config::SequencerPartialConfig; use lee::{ Account, AccountId, PrivacyPreservingTransaction, PrivateKey, PublicKey, PublicTransaction, privacy_preserving_transaction::{self as pptx, circuit}, @@ -22,14 +22,16 @@ use lee::{ public_transaction as putx, }; use lee_core::{ - DUMMY_COMMITMENT_HASH, InputAccountIdentity, MembershipProof, NullifierPublicKey, - NullifierWitness, PrivateWitness, WitnessKind, + AuthorizationSecretKey, DUMMY_COMMITMENT_HASH, InputAccountIdentity, MembershipProof, + NullifierPublicKey, NullifierSecretKey, NullifierWitness, PrivateWitness, WitnessKind, account::{AccountWithMetadata, Nonce, data::Data}, encryption::ViewingPublicKey, }; -use log::info; use sequencer_core::config::GenesisAction; use sequencer_service_rpc::RpcClient as _; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use tokio::test; pub(crate) struct TpsTestManager { @@ -178,9 +180,13 @@ pub async fn tps_test() -> Result<()> { let target_tps = 8; let tps_test = TpsTestManager::new(target_tps, num_transactions); - let ctx = TestContext::builder() - .with_sequencer_partial_config(TpsTestManager::generate_sequencer_partial_config()) - .with_genesis(tps_test.generate_genesis()) + + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()) + .with_sequencer_partial_config(TpsTestManager::generate_sequencer_partial_config()) + .with_genesis(tps_test.generate_genesis()), + ) .build() .await?; @@ -191,7 +197,7 @@ pub async fn tps_test() -> Result<()> { .context("Failed to claim vault funds for TPS accounts")?; let target_time = tps_test.target_time(); - info!( + log::info!( "TPS test begin. Target time is {target_time:?} for {num_transactions} transactions ({target_tps} TPS)" ); @@ -205,7 +211,7 @@ pub async fn tps_test() -> Result<()> { .send_transaction(LeeTransaction::Public(tx)) .await .unwrap(); - info!("Sent tx {i}"); + log::info!("Sent tx {i}"); tx_hashes.push(tx_hash); } @@ -225,7 +231,7 @@ pub async fn tps_test() -> Result<()> { }); if tx_obj.is_ok_and(|opt| opt.is_some()) { - info!("Found tx {i} with hash {tx_hash}"); + log::info!("Found tx {i} with hash {tx_hash}"); break; } } @@ -234,7 +240,7 @@ pub async fn tps_test() -> Result<()> { let tx_processed = tx_hashes.len(); let actual_tps = tx_processed as u64 / time_elapsed; - info!("Processed {tx_processed} transactions in {time_elapsed:?} ({actual_tps} TPS)",); + log::info!("Processed {tx_processed} transactions in {time_elapsed:?} ({actual_tps} TPS)",); assert_eq!(tx_processed, num_transactions); @@ -243,7 +249,7 @@ pub async fn tps_test() -> Result<()> { "Elapsed time {time_elapsed:?} exceeded target time {target_time:?}" ); - info!("TPS test finished successfully"); + log::info!("TPS test finished successfully"); Ok(()) } @@ -255,7 +261,8 @@ pub async fn tps_test() -> Result<()> { #[expect(dead_code, reason = "No idea if we need this, should we remove it?")] fn build_privacy_transaction() -> PrivacyPreservingTransaction { let program = programs::authenticated_transfer(); - let sender_nsk = [1; 32]; + let sender_ask = AuthorizationSecretKey([1; 32]); + let sender_nsk = NullifierSecretKey::from(&sender_ask); let sender_vpk = ViewingPublicKey::from_seed(&[99_u8; 32], &[100_u8; 32]); let sender_npk = NullifierPublicKey::from(&sender_nsk); let sender_pre = AccountWithMetadata::new( @@ -268,7 +275,8 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction { true, AccountId::for_regular_private_account(&sender_npk, &sender_vpk, 0), ); - let recipient_nsk = [2; 32]; + let recipient_ask = AuthorizationSecretKey([2; 32]); + let recipient_nsk = NullifierSecretKey::from(&recipient_ask); let recipient_vpk = ViewingPublicKey::from_seed(&[101_u8; 32], &[102_u8; 32]); let recipient_npk = NullifierPublicKey::from(&recipient_nsk); let recipient_pre = AccountWithMetadata::new( @@ -296,7 +304,9 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction { vpk: sender_vpk, random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, nsk: sender_nsk, @@ -307,7 +317,9 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction { vpk: recipient_vpk, random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_ask), + }, nullifier: NullifierWitness::Init { npk: recipient_npk, commitment_root: DUMMY_COMMITMENT_HASH, diff --git a/integration_tests/tests/two_zone.rs b/integration_tests/tests/two_zone.rs index 8f4b26971..d03f4221e 100644 --- a/integration_tests/tests/two_zone.rs +++ b/integration_tests/tests/two_zone.rs @@ -6,16 +6,18 @@ //! Two zones (sequencer + indexer each, on separate channels) sharing one //! Bedrock node, each producing and finalizing blocks independently. -use std::{net::SocketAddr, time::Duration}; +use std::time::Duration; use anyhow::{Context as _, Result}; use indexer_service_rpc::RpcClient as _; use integration_tests::{ config::{self, SequencerPartialConfig}, indexer_client::IndexerClient, - setup::{SequencerSetup, setup_bedrock_node, setup_indexer}, }; -use sequencer_service_rpc::{RpcClient as _, SequencerClientBuilder}; +use sequencer_service_rpc::{RpcClient as _, SequencerClient}; +use test_fixtures::{ + MultiZoneTestContextBuilder, ZoneTestContextBuilder, config::MultiNodeTestContextConfig, +}; use tokio::test; const ZONE_LIVE_TIMEOUT: Duration = Duration::from_secs(360); @@ -25,38 +27,45 @@ const MIN_BLOCK_ID: u64 = 2; #[test] async fn two_zones_share_one_bedrock_and_both_advance() -> Result<()> { - // Declared first so it outlives both zones (drops run in reverse order). - let (_bedrock, bedrock_addr) = setup_bedrock_node() - .await - .context("Failed to set up shared Bedrock node")?; - - let partial = SequencerPartialConfig::default(); let channel_a = config::bedrock_channel_id(); let channel_b = config::bedrock_channel_id_b(); + let partial = SequencerPartialConfig::default(); - // Empty genesis is enough: the clock transaction drives block production. - let (seq_a, _seq_a_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel_a) - .with_genesis(vec![]) - .setup() - .await - .context("Failed to set up zone A sequencer")?; - let (idx_a, _idx_a_home) = setup_indexer(bedrock_addr, channel_a, None) - .await - .context("Failed to set up zone A indexer")?; - let (seq_b, _seq_b_home) = SequencerSetup::new(partial, bedrock_addr) - .with_channel_id(channel_b) - .with_genesis(vec![]) - .setup() - .await - .context("Failed to set up zone B sequencer")?; - let (idx_b, _idx_b_home) = setup_indexer(bedrock_addr, channel_b, None) - .await - .context("Failed to set up zone B indexer")?; + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel_a, + }) + .disable_wallet() + .with_sequencer_partial_config(partial) + .with_genesis(vec![]), + ) + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig { + num_nodes: 1, + bedrock_channel: channel_b, + }) + .disable_wallet() + .with_sequencer_partial_config(partial) + .with_genesis(vec![]), + ) + .build() + .await?; + + let ind_client_a = ctx.indexer_client_zone(channel_a).unwrap(); + let ind_client_b = ctx.indexer_client_zone(channel_b).unwrap(); + + let seq_client_a = &ctx + .zone_default_sequencer_component(channel_a) + .sequencer_client; + let seq_client_b = &ctx + .zone_default_sequencer_component(channel_b) + .sequencer_client; let (height_a, height_b) = tokio::try_join!( - wait_until_zone_live("A", seq_a.addr(), idx_a.addr()), - wait_until_zone_live("B", seq_b.addr(), idx_b.addr()), + wait_until_zone_live("A", seq_client_a, ind_client_a), + wait_until_zone_live("B", seq_client_b, ind_client_b), )?; assert!( @@ -75,31 +84,22 @@ async fn two_zones_share_one_bedrock_and_both_advance() -> Result<()> { /// to it. Returns the indexer's finalized block id. async fn wait_until_zone_live( label: &str, - sequencer_addr: SocketAddr, - indexer_addr: SocketAddr, + sequencer_client: &SequencerClient, + indexer_client: &IndexerClient, ) -> Result { - let sequencer_url = config::addr_to_url(config::UrlProtocol::Http, sequencer_addr) - .context("Failed to build sequencer URL")?; - let sequencer = SequencerClientBuilder::default() - .build(sequencer_url) - .context("Failed to build sequencer client")?; - - let indexer_url = config::addr_to_url(config::UrlProtocol::Ws, indexer_addr) - .context("Failed to build indexer URL")?; - let indexer = IndexerClient::new(&indexer_url) - .await - .context("Failed to build indexer client")?; - let wait = async { loop { - if sequencer.get_last_block_id().await? >= MIN_BLOCK_ID { + if sequencer_client.get_last_block_id().await? >= MIN_BLOCK_ID { break; } tokio::time::sleep(Duration::from_secs(2)).await; } - let target = sequencer.get_last_block_id().await?; + let target = sequencer_client.get_last_block_id().await?; loop { - let finalized = indexer.get_last_finalized_block_id().await?.unwrap_or(0); + let finalized = indexer_client + .get_last_finalized_block_id() + .await? + .unwrap_or(0); if finalized >= target { log::info!( "Zone {label} live: sequencer at {target}, indexer finalized {finalized}" diff --git a/integration_tests/tests/wallet_ffi.rs b/integration_tests/tests/wallet_ffi.rs index a19bc3550..8f6715e05 100644 --- a/integration_tests/tests/wallet_ffi.rs +++ b/integration_tests/tests/wallet_ffi.rs @@ -27,7 +27,6 @@ use lee::{ privacy_preserving_transaction::circuit::ProgramWithDependencies, program::Program, }; use lee_core::program::DEFAULT_PROGRAM_ID; -use log::info; use wallet::{account::HumanReadableAccount, program_facades::vault::Vault}; use wallet_ffi::{ FfiAccount, FfiAccountIdWithPrivacy, FfiAccountIdentity, FfiAccountList, FfiBytes32, @@ -284,6 +283,12 @@ unsafe extern "C" { ) -> LabelList; fn wallet_ffi_free_label_list(label_list: *mut LabelList) -> error::WalletFfiError; + + fn wallet_ffi_poll_transaction_status( + handle: *mut WalletHandle, + tx_hash: FfiBytes32, + transaction_status: *mut bool, + ) -> error::WalletFfiError; } fn new_wallet_ffi_with_test_context_config( @@ -389,7 +394,7 @@ fn load_existing_ffi_wallet(home: &Path) -> Result<*mut WalletHandle> { #[test] fn wallet_ffi_create_public_accounts() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let n_accounts = 10; // Create `n_accounts` public accounts with wallet FFI @@ -430,7 +435,7 @@ fn wallet_ffi_create_public_accounts() -> Result<()> { #[test] fn wallet_ffi_create_private_accounts() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let n_accounts = 10; // Create `n_accounts` receiving keys with wallet FFI let new_npks_ffi = unsafe { @@ -465,7 +470,7 @@ fn wallet_ffi_create_private_accounts() -> Result<()> { #[test] fn wallet_ffi_save_and_load_persistent_storage() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; // Create a receiving key and save let first_npk = unsafe { @@ -505,7 +510,7 @@ fn wallet_ffi_save_and_load_persistent_storage() -> Result<()> { #[test] fn test_wallet_ffi_list_accounts() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; // Create the wallet FFI and track which account IDs were created as public/private let (wallet_ffi_handle, created_public_ids) = unsafe { let home = tempfile::tempdir()?; @@ -574,7 +579,7 @@ fn test_wallet_ffi_list_accounts() -> Result<()> { #[test] fn test_wallet_ffi_get_balance_public() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let account_id: AccountId = ctx.ctx().existing_public_accounts()[0]; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { @@ -596,7 +601,7 @@ fn test_wallet_ffi_get_balance_public() -> Result<()> { }; assert_eq!(balance, 10000); - info!("Successfully retrieved account balance"); + log::info!("Successfully retrieved account balance"); unsafe { wallet_ffi_destroy(wallet_ffi_handle); @@ -607,7 +612,7 @@ fn test_wallet_ffi_get_balance_public() -> Result<()> { #[test] fn test_wallet_ffi_get_account_public() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let account_id: AccountId = ctx.ctx().existing_public_accounts()[0]; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { @@ -640,14 +645,14 @@ fn test_wallet_ffi_get_account_public() -> Result<()> { wallet_ffi_destroy(wallet_ffi_handle); } - info!("Successfully retrieved account with correct details"); + log::info!("Successfully retrieved account with correct details"); Ok(()) } #[test] fn test_wallet_ffi_get_account_private() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let account_id: AccountId = ctx.ctx().existing_private_accounts()[0]; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { @@ -679,14 +684,14 @@ fn test_wallet_ffi_get_account_private() -> Result<()> { wallet_ffi_destroy(wallet_ffi_handle); } - info!("Successfully retrieved account with correct details"); + log::info!("Successfully retrieved account with correct details"); Ok(()) } #[test] fn test_wallet_ffi_get_public_account_keys() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let account_id: AccountId = ctx.ctx().existing_public_accounts()[0]; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { @@ -717,7 +722,7 @@ fn test_wallet_ffi_get_public_account_keys() -> Result<()> { assert_eq!(key, expected_key); - info!("Successfully retrieved account key"); + log::info!("Successfully retrieved account key"); unsafe { wallet_ffi_destroy(wallet_ffi_handle); @@ -728,7 +733,7 @@ fn test_wallet_ffi_get_public_account_keys() -> Result<()> { #[test] fn test_wallet_ffi_get_private_account_keys() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let account_id: AccountId = ctx.ctx().existing_private_accounts()[0]; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { @@ -767,7 +772,7 @@ fn test_wallet_ffi_get_private_account_keys() -> Result<()> { wallet_ffi_destroy(wallet_ffi_handle); } - info!("Successfully retrieved account keys"); + log::info!("Successfully retrieved account keys"); Ok(()) } @@ -814,7 +819,7 @@ fn wallet_ffi_base58_to_account_id() -> Result<()> { #[test] fn wallet_ffi_init_public_account_auth_transfer() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -851,7 +856,7 @@ fn wallet_ffi_init_public_account_auth_transfer() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Check that the program owner is now the authenticated transfer program @@ -880,7 +885,7 @@ fn wallet_ffi_init_public_account_auth_transfer() -> Result<()> { #[test] fn wallet_ffi_init_private_account_auth_transfer() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -904,7 +909,7 @@ fn wallet_ffi_init_private_account_auth_transfer() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -940,7 +945,7 @@ fn wallet_ffi_init_private_account_auth_transfer() -> Result<()> { #[test] fn test_wallet_ffi_transfer_public() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -962,7 +967,7 @@ fn test_wallet_ffi_transfer_public() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let from_balance = unsafe { @@ -987,8 +992,18 @@ fn test_wallet_ffi_transfer_public() -> Result<()> { assert_eq!(from_balance, 9900); assert_eq!(to_balance, 20100); + // Also check for transaction inclusion + let hash_bytes = unsafe { transfer_result.tx_hash_bytes() }; + let mut is_included = false; + + unsafe { + wallet_ffi_poll_transaction_status(wallet_ffi_handle, hash_bytes, &raw mut is_included) + .unwrap(); + } + + assert!(is_included); + unsafe { - wallet_ffi_free_transfer_result(&raw mut transfer_result); wallet_ffi_destroy(wallet_ffi_handle); } @@ -997,7 +1012,7 @@ fn test_wallet_ffi_transfer_public() -> Result<()> { #[test] fn test_wallet_ffi_transfer_shielded() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1034,7 +1049,7 @@ fn test_wallet_ffi_transfer_shielded() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1080,7 +1095,7 @@ fn test_wallet_ffi_transfer_shielded() -> Result<()> { #[test] fn test_wallet_ffi_transfer_deshielded() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1102,7 +1117,7 @@ fn test_wallet_ffi_transfer_deshielded() -> Result<()> { } .unwrap(); - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1143,7 +1158,7 @@ fn test_wallet_ffi_transfer_deshielded() -> Result<()> { #[test] fn test_wallet_ffi_transfer_private() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1181,7 +1196,7 @@ fn test_wallet_ffi_transfer_private() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1226,7 +1241,7 @@ fn test_wallet_ffi_transfer_private() -> Result<()> { #[test] fn restore_keys_from_seed_ffi() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1271,9 +1286,9 @@ fn restore_keys_from_seed_ffi() -> Result<()> { wallet_ffi_create_account_public(wallet_ffi_handle, &raw mut public_account_id_2).unwrap(); } - info!("Accounts created"); + log::info!("Accounts created"); - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1305,7 +1320,7 @@ fn restore_keys_from_seed_ffi() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1333,7 +1348,7 @@ fn restore_keys_from_seed_ffi() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1357,7 +1372,7 @@ fn restore_keys_from_seed_ffi() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1381,7 +1396,7 @@ fn restore_keys_from_seed_ffi() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1398,11 +1413,11 @@ fn restore_keys_from_seed_ffi() -> Result<()> { wallet_ffi_free_transfer_result(&raw mut transfer_result_4); } - info!("Preparation complete, performing keys restoration"); + log::info!("Preparation complete, performing keys restoration"); let password = CString::new(ctx.ctx().wallet_password())?; - info!("Checking balance correctness before restoration"); + log::info!("Checking balance correctness before restoration"); let private_account_id_1_balance = unsafe { let mut out_balance: [u8; 16] = [0; 16]; @@ -1465,7 +1480,7 @@ fn restore_keys_from_seed_ffi() -> Result<()> { wallet_ffi_sync_to_block(wallet_ffi_handle, current_height).unwrap(); }; - info!("Checking balance correctness after restoration"); + log::info!("Checking balance correctness after restoration"); let private_account_id_1_balance = unsafe { let mut out_balance: [u8; 16] = [0; 16]; @@ -1516,7 +1531,7 @@ fn restore_keys_from_seed_ffi() -> Result<()> { assert_eq!(public_account_id_1_balance, 102); assert_eq!(public_account_id_2_balance, 103); - info!("Accounts restored"); + log::info!("Accounts restored"); Ok(()) } @@ -1546,7 +1561,7 @@ fn restore_keys_from_seed_ffi() -> Result<()> { // .unwrap(); // } -// info!("Waiting for next block creation"); +// log::info!("Waiting for next block creation"); // std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // let from_balance = unsafe { @@ -1586,7 +1601,7 @@ fn restore_keys_from_seed_ffi() -> Result<()> { #[test] fn test_wallet_ffi_transfer_generic_public() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1637,7 +1652,7 @@ fn test_wallet_ffi_transfer_generic_public() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let from_balance = unsafe { @@ -1680,7 +1695,7 @@ fn test_wallet_ffi_transfer_generic_public() -> Result<()> { #[test] fn test_wallet_ffi_transfer_generic_private() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1736,7 +1751,7 @@ fn test_wallet_ffi_transfer_generic_private() -> Result<()> { assert_eq!(transaction_result.secrets_size, 2); - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1789,7 +1804,7 @@ fn test_wallet_ffi_transfer_generic_private() -> Result<()> { #[test] fn test_wallet_ffi_vault_balance_and_claim_public() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1809,7 +1824,7 @@ fn test_wallet_ffi_vault_balance_and_claim_public() -> Result<()> { }) .unwrap(); - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let vault_balance = unsafe { @@ -1836,7 +1851,7 @@ fn test_wallet_ffi_vault_balance_and_claim_public() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let vault_balance_after_claim = unsafe { @@ -1874,7 +1889,7 @@ fn test_wallet_ffi_vault_balance_and_claim_public() -> Result<()> { #[test] fn test_wallet_ffi_vault_balance_and_claim_private() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1895,7 +1910,7 @@ fn test_wallet_ffi_vault_balance_and_claim_private() -> Result<()> { }) .unwrap(); - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let vault_balance = unsafe { @@ -1922,7 +1937,7 @@ fn test_wallet_ffi_vault_balance_and_claim_private() -> Result<()> { .unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); // Sync private account local storage with onchain encrypted state @@ -1966,7 +1981,7 @@ fn test_wallet_ffi_vault_balance_and_claim_private() -> Result<()> { #[test] fn test_wallet_ffi_single_label() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -1978,7 +1993,7 @@ fn test_wallet_ffi_single_label() -> Result<()> { wallet_ffi_create_account_public(wallet_ffi_handle, &raw mut out_account_id_1).unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let lab_1 = CString::from_str("LABEL1").unwrap().into_raw(); @@ -2015,7 +2030,7 @@ fn test_wallet_ffi_single_label() -> Result<()> { #[test] fn test_wallet_ffi_more_labels() -> Result<()> { - let ctx = BlockingTestContext::new()?; + let ctx = BlockingTestContext::new_default()?; let home = tempfile::tempdir()?; let FfiCreateWalletOutput { wallet: wallet_ffi_handle, @@ -2027,7 +2042,7 @@ fn test_wallet_ffi_more_labels() -> Result<()> { wallet_ffi_create_account_public(wallet_ffi_handle, &raw mut out_account_id_1).unwrap(); } - info!("Waiting for next block creation"); + log::info!("Waiting for next block creation"); std::thread::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)); let lab_1 = CString::from_str("LABEL1").unwrap().into_raw(); diff --git a/lee/key_protocol/src/key_management/group_key_holder.rs b/lee/key_protocol/src/key_management/group_key_holder.rs index 1aef6c916..289ccfe42 100644 --- a/lee/key_protocol/src/key_management/group_key_holder.rs +++ b/lee/key_protocol/src/key_management/group_key_holder.rs @@ -339,7 +339,7 @@ mod tests { } /// Pins the end-to-end derivation for a fixed (GMS, `ProgramId`, `PdaSeed`). Any change - /// to `secret_spending_key_for_pda`, the `PrivateKeyHolder` nsk/npk chain, or the + /// to `secret_spending_key_for_pda`, the `PrivateKeyHolder` ask/nsk/npk chain, or the /// `AccountId::for_private_pda` formula breaks this test. Mirrors the pinned-value /// pattern from `for_private_pda_matches_pinned_value` in `lee_core`. #[test] @@ -357,8 +357,8 @@ mod tests { let account_id = AccountId::for_private_pda(&program_id, &seed, &npk, &vpk, u128::MAX); let expected_npk = NullifierPublicKey([ - 136, 176, 234, 71, 208, 8, 143, 142, 126, 155, 132, 18, 71, 27, 88, 56, 100, 90, 79, - 215, 76, 92, 60, 166, 104, 35, 51, 91, 16, 114, 188, 112, + 73, 227, 101, 209, 210, 127, 85, 171, 217, 20, 52, 68, 63, 127, 88, 157, 162, 165, 221, + 15, 86, 162, 128, 15, 56, 89, 95, 33, 216, 229, 181, 123, ]); // AccountId is derived from (program_id, seed, npk), so it changes when npk changes. // We verify npk is pinned, and AccountId is deterministically derived from it. diff --git a/lee/key_protocol/src/key_management/key_tree/keys_private.rs b/lee/key_protocol/src/key_management/key_tree/keys_private.rs index 8165e808e..18144a476 100644 --- a/lee/key_protocol/src/key_management/key_tree/keys_private.rs +++ b/lee/key_protocol/src/key_management/key_tree/keys_private.rs @@ -1,6 +1,8 @@ use std::collections::BTreeMap; -use lee_core::{NullifierPublicKey, PrivateAccountKind, encryption::ViewingPublicKey}; +use lee_core::{ + NullifierPublicKey, NullifierSecretKey, PrivateAccountKind, encryption::ViewingPublicKey, +}; use serde::{Deserialize, Serialize}; use sha2::Digest as _; @@ -32,11 +34,13 @@ impl ChildKeysPrivate { #[must_use] pub fn nth_child(&self, cci: u32) -> Self { + const DOMAIN: &[u8; 21] = b"/LEE/v0.3/Keys/Parent"; + // `parent_hash`` is used to incorporate entropy based on the parent node's keys // to generate the `ssk` and `ccc` values. let mut parent_hash = sha2::Sha256::new(); - parent_hash.update(b"LEE/keys"); - parent_hash.update(self.value.0.private_key_holder.nullifier_secret_key); + parent_hash.update(DOMAIN); + parent_hash.update(self.value.0.private_key_holder.nullifier_secret_key()); parent_hash.update(self.value.0.private_key_holder.viewing_secret_key.d); parent_hash.update(self.value.0.private_key_holder.viewing_secret_key.z); let parent_pt = parent_hash.finalize(); @@ -58,10 +62,10 @@ impl ChildKeysPrivate { } fn from_ssk_and_ccc(ssk: SecretSpendingKey, ccc: [u8; 32], cci: Option) -> Self { - let nsk = ssk.generate_nullifier_secret_key(cci); + let ask = ssk.generate_authorization_secret_key(cci); let vsk = ssk.generate_viewing_secret_seed_key(cci); - let npk = NullifierPublicKey::from(&nsk); + let npk = NullifierPublicKey::from(&NullifierSecretKey::from(&ask)); let vpk = ViewingPublicKey::from(&vsk); Self { @@ -71,7 +75,7 @@ impl ChildKeysPrivate { nullifier_public_key: npk, viewing_public_key: vpk, private_key_holder: PrivateKeyHolder { - nullifier_secret_key: nsk, + authorization_secret_key: ask, viewing_secret_key: vsk, }, }, @@ -130,95 +134,104 @@ mod tests { 111, 13, 5, 195, 75, 20, 255, 162, 85, 40, 251, 8, 168, ]; + let expected_ask = lee_core::AuthorizationSecretKey([ + 3, 154, 34, 187, 166, 138, 64, 10, 172, 210, 224, 75, 165, 157, 94, 27, 81, 209, 194, + 189, 60, 171, 252, 226, 25, 136, 158, 59, 56, 39, 60, 175, + ]); + let expected_nsk: NullifierSecretKey = [ - 154, 102, 103, 5, 34, 235, 227, 13, 22, 182, 226, 11, 7, 67, 110, 162, 99, 193, 174, - 34, 234, 19, 222, 2, 22, 12, 163, 252, 88, 11, 0, 163, + 227, 13, 41, 248, 160, 185, 37, 158, 48, 134, 157, 185, 50, 249, 13, 114, 128, 43, 92, + 148, 161, 91, 158, 206, 209, 246, 46, 49, 114, 165, 72, 64, ]; let expected_npk = lee_core::NullifierPublicKey([ - 7, 123, 125, 191, 233, 183, 201, 4, 20, 214, 155, 210, 45, 234, 27, 240, 194, 111, 97, - 247, 155, 113, 122, 246, 192, 0, 70, 61, 76, 71, 70, 2, + 42, 60, 83, 112, 244, 198, 238, 159, 150, 105, 13, 134, 103, 228, 213, 247, 121, 42, + 65, 51, 122, 196, 228, 163, 244, 251, 219, 119, 8, 14, 68, 16, ]); let expected_vsk = ViewingSecretKey::new( [ - 187, 143, 146, 12, 68, 148, 25, 203, 21, 92, 131, 2, 221, 81, 117, 62, 98, 194, - 159, 177, 102, 254, 236, 182, 76, 242, 116, 219, 17, 166, 99, 36, + 92, 182, 50, 80, 228, 152, 149, 83, 44, 33, 179, 59, 237, 153, 45, 46, 216, 142, + 62, 31, 28, 18, 44, 27, 130, 54, 10, 13, 148, 111, 214, 107, ], [ - 80, 97, 83, 209, 145, 99, 168, 99, 89, 29, 153, 236, 82, 99, 134, 114, 168, 19, - 223, 69, 34, 47, 76, 76, 15, 97, 245, 184, 25, 103, 251, 82, + 135, 73, 174, 183, 171, 136, 40, 174, 28, 18, 73, 1, 183, 13, 208, 39, 113, 79, + 136, 163, 234, 119, 117, 192, 103, 49, 193, 16, 188, 111, 4, 78, ], ); - // Length matches MlKem768EncapsulationKey::LEN. + // Length matches MlKem768EncapsulationKey::LEN. Oracle-sourced from the ML-KEM-768 + // implementation, unlike every other vector here; its trailing 32 bytes are + // rho = SHA3-512(d || 3)[..32] per FIPS-203, checked against the independent `d`. let expected_vpk: [u8; 1184] = [ - 127, 229, 162, 212, 104, 117, 4, 150, 192, 103, 122, 195, 14, 35, 12, 60, 52, 23, 220, - 150, 100, 203, 34, 34, 127, 232, 156, 43, 218, 109, 6, 160, 67, 35, 210, 194, 25, 181, - 118, 237, 25, 129, 51, 160, 189, 51, 99, 184, 57, 28, 121, 240, 236, 2, 170, 198, 26, - 91, 172, 110, 52, 32, 186, 35, 179, 202, 234, 249, 15, 242, 100, 198, 168, 163, 120, - 205, 118, 85, 195, 210, 187, 95, 150, 154, 8, 68, 165, 237, 87, 166, 101, 57, 4, 18, - 11, 122, 235, 180, 199, 154, 165, 158, 55, 136, 30, 237, 43, 167, 215, 68, 80, 102, 0, - 71, 90, 130, 206, 240, 215, 69, 199, 83, 7, 60, 184, 128, 230, 184, 61, 93, 201, 204, - 165, 104, 9, 127, 220, 52, 246, 217, 131, 251, 2, 170, 133, 6, 51, 40, 224, 101, 61, - 16, 135, 32, 182, 201, 68, 58, 171, 54, 161, 184, 243, 38, 106, 200, 251, 17, 172, 8, - 24, 73, 230, 55, 85, 20, 147, 222, 165, 200, 116, 135, 47, 20, 227, 56, 220, 64, 120, - 215, 245, 58, 86, 102, 149, 252, 193, 163, 160, 59, 82, 138, 249, 171, 1, 54, 199, 193, - 171, 85, 38, 64, 56, 121, 106, 84, 57, 252, 94, 147, 16, 191, 196, 104, 47, 129, 84, - 21, 252, 160, 81, 207, 184, 199, 3, 177, 74, 117, 115, 175, 138, 108, 36, 198, 5, 32, - 15, 218, 3, 20, 19, 15, 251, 209, 86, 128, 139, 148, 78, 10, 34, 144, 149, 74, 102, 48, - 59, 70, 124, 47, 193, 100, 26, 9, 104, 178, 102, 156, 199, 242, 101, 147, 161, 87, 27, - 234, 192, 204, 41, 36, 43, 83, 219, 15, 211, 66, 91, 76, 73, 13, 113, 155, 203, 193, - 160, 130, 84, 103, 47, 70, 100, 147, 169, 65, 119, 84, 121, 122, 161, 76, 203, 144, - 248, 145, 22, 8, 46, 121, 44, 77, 20, 149, 66, 179, 56, 149, 231, 98, 184, 9, 64, 14, - 67, 196, 34, 8, 123, 21, 80, 169, 168, 223, 230, 133, 0, 66, 159, 230, 69, 201, 205, - 169, 105, 196, 21, 71, 84, 70, 58, 165, 165, 134, 186, 232, 60, 70, 51, 57, 239, 74, - 174, 116, 234, 36, 178, 49, 42, 168, 250, 104, 141, 106, 0, 109, 52, 86, 104, 243, 62, - 214, 137, 48, 107, 2, 152, 206, 227, 175, 147, 236, 19, 113, 27, 191, 231, 235, 167, - 114, 104, 23, 126, 203, 94, 242, 149, 171, 115, 170, 89, 244, 58, 29, 176, 73, 203, 44, - 8, 32, 9, 226, 32, 78, 246, 38, 235, 149, 133, 25, 243, 47, 124, 180, 200, 211, 165, - 137, 56, 169, 117, 31, 244, 65, 91, 135, 146, 158, 20, 75, 102, 32, 65, 250, 103, 199, - 36, 48, 31, 155, 164, 191, 222, 85, 37, 66, 243, 17, 120, 104, 0, 228, 83, 200, 116, 6, - 199, 106, 236, 139, 246, 216, 152, 241, 211, 85, 106, 200, 44, 231, 240, 66, 3, 193, - 147, 16, 145, 65, 49, 33, 53, 247, 69, 47, 44, 113, 86, 117, 6, 20, 193, 183, 128, 178, - 181, 21, 251, 99, 39, 149, 210, 146, 106, 181, 186, 7, 36, 63, 186, 234, 191, 164, 193, - 162, 127, 250, 122, 189, 219, 21, 92, 48, 86, 209, 184, 99, 160, 201, 162, 145, 20, - 138, 154, 18, 37, 180, 209, 165, 165, 51, 187, 78, 193, 175, 135, 6, 55, 216, 178, 10, - 40, 246, 98, 128, 80, 14, 38, 69, 113, 123, 54, 94, 43, 50, 106, 167, 17, 77, 163, 148, - 117, 225, 9, 7, 253, 240, 157, 96, 103, 33, 100, 37, 37, 20, 53, 138, 234, 55, 45, 232, - 154, 9, 150, 192, 116, 36, 119, 106, 95, 119, 34, 220, 84, 174, 19, 227, 33, 209, 96, - 197, 148, 230, 197, 59, 117, 130, 7, 116, 11, 0, 197, 16, 249, 151, 31, 4, 64, 29, 165, - 247, 110, 176, 166, 4, 112, 136, 101, 208, 7, 179, 38, 183, 134, 58, 107, 207, 160, 38, - 159, 67, 112, 20, 225, 199, 179, 133, 117, 144, 54, 199, 15, 204, 80, 154, 116, 84, 88, - 109, 113, 5, 207, 226, 21, 62, 247, 122, 14, 156, 9, 8, 76, 26, 148, 67, 196, 128, 176, - 78, 51, 161, 151, 75, 248, 154, 31, 168, 9, 4, 3, 107, 222, 245, 178, 21, 84, 7, 25, - 155, 118, 97, 135, 63, 89, 233, 11, 207, 148, 155, 38, 106, 104, 102, 140, 104, 67, - 149, 20, 30, 196, 44, 197, 128, 34, 182, 80, 30, 32, 137, 34, 212, 164, 177, 164, 12, - 115, 41, 156, 111, 71, 230, 120, 111, 218, 25, 117, 218, 75, 167, 32, 37, 57, 50, 99, - 181, 203, 40, 105, 248, 150, 114, 121, 73, 127, 198, 191, 161, 44, 56, 213, 243, 71, 2, - 56, 192, 243, 107, 179, 27, 96, 21, 116, 169, 64, 15, 97, 166, 151, 200, 11, 40, 204, - 71, 168, 220, 9, 55, 43, 146, 244, 212, 166, 192, 180, 189, 237, 162, 42, 29, 33, 52, - 193, 4, 178, 157, 244, 28, 209, 44, 26, 36, 147, 126, 94, 164, 37, 47, 115, 38, 23, - 165, 96, 106, 140, 42, 69, 146, 194, 93, 71, 175, 49, 147, 32, 246, 97, 94, 41, 116, - 127, 174, 18, 16, 14, 163, 17, 180, 213, 203, 166, 33, 139, 214, 18, 170, 27, 41, 59, - 175, 200, 101, 14, 128, 45, 179, 167, 136, 232, 138, 56, 124, 145, 75, 233, 132, 161, - 196, 164, 72, 80, 60, 187, 38, 90, 90, 17, 66, 134, 59, 2, 165, 29, 76, 24, 38, 211, - 177, 83, 119, 20, 239, 59, 77, 34, 3, 42, 47, 60, 89, 46, 103, 168, 120, 17, 199, 50, - 17, 103, 107, 48, 8, 53, 220, 159, 212, 65, 198, 80, 8, 11, 235, 97, 203, 196, 240, 44, - 56, 121, 77, 91, 196, 160, 129, 242, 149, 226, 57, 106, 180, 76, 161, 203, 18, 37, 166, - 153, 44, 40, 28, 74, 8, 11, 6, 166, 54, 10, 103, 247, 23, 35, 7, 47, 173, 133, 71, 85, - 3, 168, 250, 120, 126, 174, 37, 80, 128, 107, 7, 161, 130, 155, 136, 92, 48, 215, 119, - 196, 124, 85, 157, 234, 2, 166, 137, 65, 121, 222, 112, 47, 17, 43, 23, 111, 88, 5, - 195, 41, 8, 191, 227, 21, 173, 35, 199, 196, 188, 162, 191, 195, 204, 137, 54, 16, 73, - 178, 150, 249, 234, 22, 216, 123, 157, 144, 218, 118, 53, 193, 67, 65, 84, 162, 244, - 165, 24, 110, 246, 146, 228, 212, 180, 150, 116, 201, 37, 128, 76, 41, 188, 42, 79, - 148, 52, 196, 176, 178, 224, 48, 168, 13, 129, 193, 131, 185, 131, 93, 40, 145, 56, - 180, 29, 153, 83, 39, 69, 232, 96, 238, 137, 104, 150, 2, 202, 239, 149, 248, 154, 115, - 115, 127, 3, 8, 32, 61, 96, 66, 25, 181, 14, 72, 73, 97, 186, 134, 140, 33, 69, 33, 74, + 95, 42, 170, 49, 164, 173, 200, 156, 66, 32, 71, 126, 122, 140, 148, 144, 114, 143, + 233, 199, 104, 82, 179, 49, 43, 114, 130, 182, 71, 4, 45, 101, 65, 136, 196, 72, 129, + 128, 204, 239, 137, 84, 230, 210, 18, 214, 252, 40, 198, 210, 24, 158, 53, 151, 166, + 24, 47, 143, 8, 158, 119, 240, 204, 210, 242, 96, 191, 147, 106, 98, 198, 93, 193, 163, + 31, 132, 36, 16, 50, 83, 24, 225, 250, 106, 55, 231, 188, 90, 194, 128, 10, 225, 186, + 41, 225, 165, 126, 57, 32, 163, 129, 42, 68, 113, 177, 239, 106, 144, 217, 188, 192, + 174, 38, 161, 189, 24, 107, 14, 54, 167, 221, 120, 194, 6, 22, 163, 86, 96, 47, 220, + 227, 176, 173, 52, 150, 183, 25, 40, 200, 19, 134, 51, 172, 126, 35, 147, 79, 207, 235, + 9, 243, 197, 84, 4, 194, 142, 207, 118, 121, 133, 58, 12, 58, 226, 22, 106, 172, 56, + 223, 161, 145, 60, 28, 47, 95, 84, 127, 1, 235, 72, 0, 131, 202, 15, 151, 93, 52, 18, + 13, 247, 91, 80, 240, 229, 85, 72, 135, 84, 230, 113, 196, 162, 3, 24, 87, 176, 80, + 202, 99, 44, 87, 229, 96, 254, 27, 181, 181, 58, 191, 116, 19, 68, 235, 35, 86, 227, + 89, 49, 70, 102, 54, 153, 224, 117, 34, 113, 57, 121, 202, 42, 248, 24, 125, 134, 134, + 57, 126, 204, 131, 191, 181, 71, 197, 184, 137, 48, 76, 29, 174, 137, 154, 253, 50, 68, + 184, 122, 173, 106, 144, 207, 48, 213, 156, 182, 26, 103, 203, 133, 131, 47, 184, 189, + 109, 4, 182, 126, 71, 180, 153, 18, 82, 77, 201, 23, 176, 92, 12, 146, 48, 26, 236, + 139, 157, 174, 214, 77, 253, 163, 94, 52, 133, 88, 200, 251, 156, 197, 201, 7, 239, + 117, 83, 57, 188, 85, 31, 196, 106, 164, 147, 36, 32, 241, 143, 54, 121, 195, 183, 98, + 182, 135, 90, 84, 118, 212, 91, 115, 41, 75, 193, 156, 44, 9, 196, 199, 241, 123, 148, + 31, 105, 126, 160, 234, 16, 196, 149, 192, 66, 34, 199, 132, 160, 98, 229, 90, 158, 46, + 108, 112, 126, 165, 115, 234, 128, 164, 241, 132, 171, 186, 212, 121, 74, 217, 165, + 111, 216, 21, 169, 89, 86, 173, 163, 183, 61, 28, 117, 104, 211, 206, 30, 194, 180, 34, + 180, 151, 150, 212, 90, 75, 139, 138, 253, 52, 60, 252, 5, 126, 152, 12, 153, 77, 232, + 167, 14, 163, 130, 76, 18, 117, 96, 113, 144, 234, 22, 56, 106, 210, 78, 83, 50, 43, + 99, 120, 20, 172, 89, 61, 10, 75, 121, 118, 226, 153, 53, 161, 144, 53, 246, 37, 213, + 216, 48, 183, 124, 58, 161, 145, 126, 238, 120, 112, 103, 65, 176, 40, 104, 60, 47, 10, + 138, 154, 89, 174, 164, 69, 182, 168, 196, 131, 68, 18, 189, 204, 74, 180, 16, 233, + 178, 175, 57, 180, 212, 58, 148, 92, 2, 16, 255, 103, 27, 212, 117, 12, 10, 54, 105, + 253, 9, 124, 250, 210, 14, 127, 151, 74, 49, 209, 59, 125, 184, 183, 175, 251, 200, + 172, 120, 59, 41, 89, 199, 3, 161, 189, 138, 50, 69, 108, 102, 155, 210, 17, 73, 235, + 75, 145, 132, 67, 89, 88, 225, 182, 156, 248, 199, 112, 52, 22, 134, 80, 40, 250, 42, + 185, 57, 200, 90, 137, 16, 158, 98, 114, 48, 151, 35, 128, 49, 49, 118, 195, 57, 40, + 94, 103, 156, 186, 1, 112, 130, 178, 59, 22, 71, 153, 173, 195, 178, 216, 149, 24, 202, + 245, 123, 117, 106, 44, 55, 128, 37, 165, 26, 103, 158, 52, 10, 188, 10, 195, 146, 204, + 85, 66, 66, 162, 73, 25, 59, 107, 57, 149, 100, 216, 24, 69, 49, 134, 233, 96, 29, 176, + 8, 188, 121, 145, 44, 35, 199, 4, 48, 24, 76, 69, 250, 92, 126, 40, 52, 162, 72, 113, + 81, 96, 116, 105, 150, 59, 211, 236, 141, 87, 178, 9, 17, 117, 43, 139, 17, 150, 153, + 114, 195, 212, 2, 192, 56, 91, 70, 200, 75, 2, 57, 171, 147, 184, 236, 15, 64, 26, 191, + 131, 179, 13, 195, 195, 166, 208, 180, 93, 186, 155, 102, 189, 57, 82, 73, 39, 44, 249, + 249, 183, 33, 112, 59, 130, 20, 193, 41, 40, 128, 131, 106, 136, 51, 75, 56, 188, 167, + 119, 5, 118, 73, 84, 168, 38, 121, 182, 190, 252, 182, 87, 142, 33, 66, 131, 75, 36, + 216, 181, 186, 213, 148, 191, 182, 115, 159, 83, 1, 14, 170, 55, 21, 251, 65, 135, 117, + 171, 147, 38, 210, 129, 251, 151, 177, 213, 1, 18, 22, 241, 62, 173, 80, 76, 85, 129, + 139, 192, 137, 205, 203, 114, 181, 121, 40, 141, 9, 194, 58, 20, 200, 126, 151, 51, + 129, 146, 92, 156, 93, 192, 72, 26, 33, 138, 107, 138, 124, 193, 138, 8, 244, 84, 116, + 28, 156, 123, 1, 19, 186, 119, 231, 157, 70, 160, 5, 34, 80, 201, 4, 39, 38, 217, 85, + 53, 10, 40, 136, 145, 225, 26, 65, 32, 76, 33, 245, 72, 166, 5, 165, 44, 67, 86, 99, + 87, 9, 148, 131, 72, 223, 71, 179, 243, 39, 36, 34, 145, 86, 134, 12, 127, 103, 3, 191, + 254, 216, 195, 12, 197, 184, 238, 67, 34, 226, 4, 100, 135, 165, 40, 164, 113, 110, + 132, 68, 100, 72, 217, 67, 169, 199, 96, 120, 152, 27, 26, 241, 103, 61, 162, 154, 113, + 55, 75, 156, 17, 114, 105, 145, 158, 13, 251, 50, 221, 219, 150, 88, 5, 184, 92, 137, + 164, 25, 117, 51, 87, 233, 93, 5, 84, 125, 251, 162, 110, 231, 36, 2, 235, 251, 185, + 45, 180, 132, 53, 104, 206, 144, 133, 67, 164, 76, 84, 152, 236, 157, 253, 115, 97, + 195, 177, 172, 233, 51, 161, 196, 66, 59, 233, 88, 133, 12, 146, 172, 148, 236, 58, 5, + 226, 48, 53, 219, 185, 72, 86, 7, 249, 151, 205, 32, 57, 163, 17, 71, 37, 162, 97, 137, + 142, 252, 190, 58, 196, 70, 181, 4, 48, 123, 9, 75, 198, 100, 134, 36, 18, 45, 99, 18, + 191, 75, 55, 30, 144, 197, 0, 44, 71, 199, 78, 121, 92, 76, 84, 43, 133, 139, 77, 105, + 83, 178, 221, 215, 108, 55, 58, 7, 106, 96, 146, 9, 70, 140, 250, 187, 206, 95, 54, 74, + 30, 146, 15, 182, 5, 79, 41, 135, 59, 75, 103, 82, 63, 39, 69, 178, 215, 49, 234, 146, + 127, 186, 192, 189, 107, 140, 11, 39, 162, 120, 90, 133, 106, 184, 87, 144, 5, 80, 80, + 22, 241, 181, 128, 201, 61, 186, 124, 9, 165, 192, 78, 67, 141, 57, 10, 94, 36, 75, + 118, 21, 105, 252, 45, 196, 60, 23, 182, 189, 252, 152, 182, 72, 229, 213, 89, 165, + 222, 151, 52, 182, 110, 127, 158, ]; assert!(expected_ssk == keys.value.0.secret_spending_key); assert!(expected_ccc == keys.ccc); - assert!(expected_nsk == keys.value.0.private_key_holder.nullifier_secret_key); + assert!(expected_ask == keys.value.0.private_key_holder.authorization_secret_key); + assert!(expected_nsk == keys.value.0.private_key_holder.nullifier_secret_key()); assert!(expected_npk == keys.value.0.nullifier_public_key); assert!(expected_vsk == keys.value.0.private_key_holder.viewing_secret_key); assert!(expected_vpk == keys.value.0.viewing_public_key.to_bytes()); @@ -230,105 +243,120 @@ mod tests { let child_node = ChildKeysPrivate::nth_child(&root_node, 42_u32); let expected_ssk = key_management::secret_holders::SecretSpendingKey([ - 151, 183, 113, 151, 215, 187, 207, 64, 197, 182, 207, 32, 5, 49, 180, 98, 119, 14, 248, - 175, 39, 100, 47, 109, 148, 173, 217, 253, 159, 234, 209, 113, + 109, 21, 107, 97, 112, 105, 143, 134, 185, 35, 168, 205, 138, 110, 125, 155, 193, 57, + 36, 19, 214, 180, 194, 46, 107, 235, 43, 80, 132, 19, 254, 231, ]); let expected_ccc = [ - 138, 243, 142, 163, 62, 107, 63, 131, 230, 158, 185, 60, 204, 50, 243, 222, 13, 123, - 98, 116, 131, 194, 7, 25, 129, 209, 163, 72, 178, 143, 192, 240, + 64, 218, 41, 59, 115, 126, 128, 3, 77, 77, 54, 84, 87, 253, 181, 112, 244, 254, 176, + 243, 86, 127, 219, 255, 64, 164, 218, 129, 83, 65, 176, 179, ]; + let expected_ask = lee_core::AuthorizationSecretKey([ + 33, 29, 199, 63, 191, 14, 196, 15, 51, 70, 236, 125, 93, 120, 78, 99, 90, 239, 220, + 168, 226, 46, 208, 238, 70, 117, 17, 28, 163, 89, 177, 129, + ]); + let expected_nsk: NullifierSecretKey = [ - 196, 33, 11, 39, 220, 84, 119, 182, 187, 194, 135, 20, 124, 33, 244, 205, 96, 58, 102, - 52, 74, 67, 110, 213, 24, 16, 160, 64, 247, 3, 107, 235, + 97, 83, 94, 170, 125, 55, 27, 105, 106, 42, 73, 99, 169, 221, 210, 124, 117, 52, 98, + 131, 98, 202, 79, 95, 151, 196, 239, 242, 6, 127, 160, 160, ]; let expected_npk = lee_core::NullifierPublicKey([ - 247, 253, 217, 86, 157, 208, 39, 172, 59, 190, 88, 165, 7, 173, 183, 106, 172, 211, 4, - 180, 51, 107, 177, 107, 51, 117, 231, 176, 200, 103, 1, 121, + 81, 199, 141, 154, 209, 135, 105, 75, 106, 240, 124, 190, 245, 233, 152, 34, 225, 234, + 212, 221, 121, 68, 255, 97, 231, 142, 26, 54, 169, 53, 69, 242, ]); let expected_vsk = ViewingSecretKey::new( [ - 185, 209, 179, 92, 7, 131, 98, 121, 215, 46, 154, 56, 238, 106, 162, 225, 83, 82, - 134, 3, 80, 186, 35, 178, 161, 204, 205, 163, 28, 19, 149, 18, + 14, 173, 255, 235, 8, 1, 246, 119, 243, 18, 235, 31, 209, 92, 142, 7, 175, 223, + 228, 201, 173, 165, 148, 137, 141, 160, 175, 161, 110, 139, 54, 183, ], [ - 174, 24, 72, 205, 129, 123, 131, 9, 146, 152, 224, 151, 10, 184, 224, 109, 94, 149, - 117, 60, 26, 10, 212, 125, 113, 147, 87, 67, 73, 26, 101, 193, + 10, 253, 101, 172, 213, 221, 88, 85, 178, 89, 218, 73, 28, 212, 1, 2, 105, 161, + 180, 24, 49, 6, 182, 155, 22, 220, 121, 42, 175, 59, 233, 109, ], ); - // Length matches MlKem768EncapsulationKey::LEN. + // Length matches MlKem768EncapsulationKey::LEN. Oracle-sourced from the ML-KEM-768 + // implementation, unlike every other vector here; its trailing 32 bytes are + // rho = SHA3-512(d || 3)[..32] per FIPS-203, checked against the independent `d`. let expected_vpk: [u8; 1184] = [ - 215, 229, 207, 120, 148, 177, 148, 197, 72, 222, 134, 3, 231, 146, 123, 226, 36, 84, - 232, 179, 205, 16, 241, 142, 9, 81, 58, 54, 12, 115, 148, 182, 19, 245, 22, 203, 57, - 71, 11, 204, 156, 130, 30, 170, 199, 201, 25, 2, 21, 34, 155, 136, 124, 145, 223, 128, - 177, 207, 92, 38, 252, 165, 118, 61, 128, 71, 154, 242, 105, 165, 52, 7, 6, 244, 120, - 227, 134, 191, 25, 169, 150, 123, 246, 138, 25, 196, 126, 156, 144, 33, 123, 120, 44, - 142, 89, 201, 49, 219, 205, 87, 236, 110, 64, 129, 102, 100, 155, 26, 101, 121, 42, - 236, 82, 111, 141, 117, 75, 71, 194, 73, 123, 170, 110, 69, 149, 107, 96, 195, 55, 122, - 140, 131, 106, 140, 156, 147, 75, 28, 128, 138, 113, 86, 37, 63, 173, 214, 200, 2, 214, - 84, 234, 176, 120, 252, 184, 99, 192, 65, 112, 150, 99, 26, 174, 187, 183, 187, 64, 90, - 248, 100, 66, 63, 195, 3, 44, 43, 128, 59, 149, 107, 66, 180, 67, 200, 183, 200, 36, - 91, 7, 65, 228, 159, 79, 44, 89, 35, 163, 145, 92, 227, 104, 2, 72, 5, 7, 193, 21, 51, - 116, 198, 184, 6, 192, 188, 68, 183, 163, 193, 142, 244, 217, 155, 197, 187, 189, 174, - 225, 45, 126, 112, 93, 194, 156, 102, 150, 1, 188, 222, 76, 108, 73, 149, 44, 28, 219, - 66, 95, 215, 204, 148, 217, 16, 36, 121, 112, 2, 51, 10, 195, 137, 12, 93, 203, 146, - 138, 211, 15, 201, 42, 72, 146, 186, 160, 222, 235, 127, 83, 48, 182, 49, 248, 29, 138, - 16, 32, 232, 179, 163, 187, 161, 174, 152, 187, 93, 76, 166, 48, 230, 219, 111, 123, - 181, 103, 130, 28, 109, 235, 115, 45, 57, 193, 206, 160, 17, 52, 92, 194, 25, 3, 80, - 97, 142, 249, 151, 94, 250, 95, 12, 57, 11, 165, 92, 47, 85, 182, 48, 22, 60, 97, 244, - 59, 194, 135, 180, 133, 106, 227, 56, 192, 60, 91, 15, 241, 146, 89, 240, 130, 219, - 202, 187, 43, 85, 98, 50, 104, 64, 114, 113, 80, 54, 69, 69, 5, 43, 90, 19, 0, 0, 188, - 251, 184, 70, 160, 18, 117, 76, 53, 209, 166, 96, 34, 224, 137, 115, 183, 168, 243, 19, - 1, 255, 4, 97, 162, 199, 104, 72, 213, 111, 62, 54, 172, 82, 184, 82, 143, 71, 99, 25, - 104, 74, 120, 70, 84, 235, 32, 22, 20, 218, 163, 77, 194, 125, 75, 22, 72, 236, 192, - 200, 107, 91, 156, 201, 10, 178, 87, 19, 181, 211, 91, 17, 145, 200, 17, 179, 65, 75, - 200, 186, 89, 144, 91, 184, 116, 214, 51, 91, 42, 162, 243, 202, 92, 18, 54, 0, 213, - 67, 149, 151, 51, 29, 220, 196, 160, 201, 68, 113, 210, 164, 175, 152, 121, 168, 231, - 161, 91, 132, 218, 1, 171, 176, 84, 100, 57, 1, 3, 2, 196, 194, 76, 181, 79, 171, 157, - 35, 162, 155, 192, 210, 149, 142, 120, 189, 127, 151, 96, 202, 225, 73, 242, 81, 112, - 237, 224, 155, 130, 130, 34, 196, 153, 131, 161, 113, 163, 172, 114, 48, 207, 32, 151, - 172, 83, 145, 79, 210, 100, 161, 92, 82, 216, 90, 104, 238, 212, 38, 50, 107, 17, 228, - 195, 190, 6, 151, 165, 148, 245, 102, 51, 8, 185, 8, 85, 59, 247, 219, 95, 219, 170, - 155, 233, 123, 27, 64, 251, 56, 24, 200, 16, 181, 212, 146, 61, 116, 106, 215, 214, 62, - 118, 27, 68, 233, 148, 73, 135, 199, 74, 184, 89, 159, 217, 139, 24, 208, 250, 30, 224, - 97, 185, 237, 193, 8, 216, 23, 186, 5, 50, 41, 161, 203, 22, 217, 23, 194, 191, 148, - 124, 10, 212, 171, 209, 210, 145, 184, 171, 74, 35, 220, 43, 145, 241, 23, 43, 92, 171, - 216, 43, 114, 77, 155, 147, 156, 86, 56, 170, 27, 1, 54, 182, 169, 96, 22, 201, 51, - 145, 94, 143, 133, 106, 47, 176, 112, 197, 197, 96, 80, 73, 164, 207, 179, 22, 229, - 171, 201, 223, 219, 13, 219, 1, 91, 224, 252, 171, 199, 217, 25, 60, 128, 135, 9, 71, - 105, 231, 86, 34, 21, 155, 50, 0, 105, 72, 117, 108, 175, 140, 9, 181, 249, 139, 97, 3, - 161, 66, 248, 42, 67, 113, 132, 8, 119, 232, 6, 169, 18, 157, 222, 53, 176, 56, 137, - 120, 18, 115, 199, 187, 112, 48, 223, 211, 206, 152, 252, 108, 179, 129, 20, 227, 248, - 183, 234, 87, 202, 49, 17, 69, 215, 118, 89, 188, 180, 33, 238, 245, 206, 40, 179, 129, - 242, 59, 73, 254, 117, 114, 250, 179, 103, 109, 250, 202, 99, 152, 2, 167, 130, 169, - 35, 71, 89, 211, 140, 71, 103, 154, 121, 108, 147, 191, 186, 73, 10, 73, 203, 23, 55, - 106, 144, 98, 227, 157, 25, 27, 81, 67, 11, 57, 88, 227, 116, 61, 100, 94, 23, 166, - 146, 57, 226, 72, 124, 33, 65, 226, 35, 167, 206, 156, 202, 213, 213, 158, 89, 249, - 181, 19, 113, 109, 217, 71, 168, 142, 180, 122, 30, 5, 54, 170, 155, 73, 56, 170, 124, - 139, 4, 165, 103, 82, 32, 183, 84, 7, 239, 117, 135, 239, 48, 24, 28, 210, 49, 137, 6, - 158, 65, 211, 113, 205, 135, 146, 83, 10, 46, 90, 27, 97, 135, 135, 185, 173, 69, 58, - 34, 247, 141, 150, 6, 158, 117, 23, 198, 139, 65, 81, 179, 187, 194, 247, 203, 127, - 106, 232, 119, 122, 215, 197, 110, 69, 203, 174, 227, 63, 185, 106, 14, 184, 104, 113, - 233, 83, 92, 104, 38, 188, 9, 135, 107, 108, 121, 193, 33, 209, 89, 39, 137, 17, 208, - 26, 21, 238, 169, 86, 181, 193, 153, 82, 8, 151, 53, 39, 88, 91, 252, 3, 33, 75, 127, - 9, 168, 53, 34, 1, 173, 202, 123, 157, 174, 170, 199, 254, 187, 196, 144, 37, 29, 48, - 112, 173, 107, 147, 155, 69, 134, 137, 156, 247, 123, 242, 72, 5, 43, 106, 89, 179, - 204, 41, 15, 60, 48, 78, 214, 180, 26, 170, 67, 71, 66, 146, 113, 220, 159, 153, 201, - 176, 116, 154, 21, 186, 33, 180, 72, 39, 187, 240, 80, 112, 132, 144, 173, 210, 12, 76, - 184, 146, 89, 178, 178, 82, 109, 71, 201, 241, 160, 207, 219, 124, 77, 2, 105, 124, - 178, 71, 3, 38, 64, 41, 83, 170, 137, 82, 242, 144, 76, 102, 82, 7, 25, 149, 141, 169, - 46, 4, 68, 40, 244, 146, 131, 107, 148, 18, 111, 85, 104, 243, 28, 75, 176, 249, 88, - 82, 123, 89, 29, 104, 135, 230, 117, 67, 26, 249, 108, 145, 76, 38, 175, 89, 185, 94, - 106, 128, 201, 150, 151, 194, 133, 21, 81, 213, 231, 15, 117, 44, 61, 86, 223, 162, 56, - 190, 166, 177, 157, 137, 60, 208, 155, 234, 158, 252, 30, + 153, 195, 136, 113, 42, 34, 28, 116, 169, 190, 53, 81, 59, 6, 163, 170, 199, 98, 201, + 54, 24, 171, 26, 191, 144, 248, 95, 63, 199, 113, 27, 67, 97, 49, 210, 66, 190, 74, 54, + 252, 12, 143, 197, 243, 27, 111, 198, 43, 23, 146, 3, 183, 171, 41, 72, 92, 46, 112, + 184, 160, 0, 25, 155, 109, 203, 70, 196, 88, 135, 121, 147, 12, 96, 230, 205, 217, 66, + 55, 78, 215, 196, 6, 8, 160, 186, 73, 56, 218, 76, 10, 31, 148, 182, 170, 131, 26, 101, + 210, 125, 125, 242, 160, 146, 35, 195, 59, 182, 55, 130, 198, 67, 109, 245, 66, 229, + 226, 154, 189, 112, 5, 160, 39, 7, 139, 152, 58, 197, 167, 81, 160, 37, 96, 175, 250, + 78, 232, 181, 152, 131, 41, 92, 45, 33, 184, 142, 25, 157, 62, 166, 111, 148, 137, 143, + 241, 176, 74, 176, 243, 178, 26, 232, 204, 119, 181, 17, 128, 91, 134, 8, 90, 35, 11, + 124, 98, 76, 76, 43, 82, 135, 85, 215, 72, 177, 37, 218, 131, 158, 139, 59, 155, 104, + 13, 13, 150, 34, 192, 54, 115, 90, 44, 84, 132, 51, 174, 155, 64, 26, 42, 246, 28, 30, + 252, 136, 227, 69, 174, 27, 233, 56, 214, 102, 117, 141, 177, 133, 181, 177, 8, 150, + 85, 104, 243, 123, 22, 25, 7, 42, 152, 226, 88, 93, 213, 201, 197, 235, 17, 185, 84, + 57, 161, 179, 37, 251, 129, 62, 214, 48, 161, 247, 124, 147, 120, 180, 197, 9, 83, 63, + 38, 182, 206, 20, 116, 28, 143, 134, 173, 55, 54, 1, 254, 86, 9, 67, 213, 144, 95, 48, + 29, 47, 119, 156, 188, 70, 18, 37, 202, 65, 173, 22, 177, 14, 244, 120, 166, 20, 82, + 203, 162, 24, 56, 240, 154, 249, 11, 31, 182, 55, 71, 171, 196, 3, 20, 151, 112, 159, + 131, 91, 201, 80, 116, 228, 6, 184, 140, 55, 205, 4, 91, 116, 46, 224, 32, 216, 146, + 46, 232, 75, 156, 109, 54, 193, 146, 248, 2, 49, 17, 102, 214, 150, 202, 1, 6, 110, 2, + 202, 34, 162, 107, 205, 241, 123, 83, 108, 152, 145, 231, 219, 158, 24, 171, 48, 121, + 2, 193, 15, 21, 9, 173, 172, 30, 111, 58, 94, 0, 18, 1, 140, 7, 52, 209, 214, 59, 127, + 228, 30, 154, 102, 119, 233, 170, 160, 120, 108, 154, 117, 28, 23, 70, 56, 35, 11, 9, + 39, 198, 86, 79, 119, 213, 88, 193, 5, 15, 214, 139, 199, 236, 243, 159, 171, 185, 22, + 35, 216, 20, 149, 137, 17, 74, 117, 165, 55, 116, 139, 171, 181, 204, 196, 104, 178, + 246, 103, 73, 249, 165, 108, 237, 234, 155, 133, 35, 139, 98, 136, 51, 220, 98, 181, + 40, 151, 80, 58, 242, 84, 206, 242, 199, 145, 156, 160, 61, 116, 100, 77, 89, 184, 213, + 119, 25, 4, 107, 26, 23, 87, 101, 85, 68, 115, 221, 171, 170, 123, 246, 26, 51, 108, + 13, 101, 217, 17, 0, 103, 110, 15, 136, 70, 255, 12, 197, 89, 225, 158, 25, 116, 32, + 53, 169, 122, 4, 32, 108, 30, 181, 180, 142, 250, 141, 217, 8, 33, 111, 89, 48, 196, + 90, 158, 207, 213, 165, 97, 196, 21, 193, 114, 199, 167, 123, 95, 118, 101, 107, 130, + 161, 186, 67, 27, 27, 161, 192, 157, 252, 98, 131, 186, 124, 64, 224, 199, 126, 47, + 230, 66, 92, 97, 38, 0, 35, 197, 178, 219, 121, 240, 149, 197, 18, 165, 39, 3, 137, 58, + 8, 227, 152, 124, 247, 103, 85, 150, 194, 140, 108, 96, 151, 57, 117, 182, 177, 47, + 168, 112, 184, 214, 42, 48, 65, 3, 30, 179, 187, 137, 179, 122, 98, 210, 3, 7, 102, + 235, 3, 145, 107, 200, 72, 4, 153, 135, 43, 202, 155, 170, 174, 148, 161, 37, 43, 219, + 16, 229, 65, 45, 225, 49, 126, 168, 215, 93, 153, 179, 49, 108, 51, 131, 227, 82, 134, + 77, 28, 175, 237, 136, 49, 137, 164, 197, 224, 42, 85, 250, 48, 109, 196, 70, 12, 236, + 65, 121, 254, 3, 37, 183, 201, 124, 246, 130, 174, 89, 7, 81, 235, 147, 148, 40, 147, + 174, 204, 99, 22, 150, 133, 195, 22, 108, 119, 59, 21, 104, 37, 251, 61, 92, 82, 9, + 122, 73, 48, 97, 82, 52, 170, 10, 79, 134, 17, 8, 247, 96, 108, 186, 230, 6, 138, 90, + 16, 36, 56, 45, 179, 121, 179, 17, 1, 174, 106, 4, 170, 81, 185, 60, 47, 201, 26, 67, + 199, 160, 52, 60, 63, 12, 82, 114, 122, 87, 166, 141, 168, 101, 177, 129, 197, 127, + 226, 166, 159, 168, 108, 218, 112, 31, 246, 4, 73, 134, 155, 197, 192, 8, 71, 135, 163, + 9, 122, 183, 127, 156, 60, 178, 124, 135, 129, 7, 185, 105, 232, 250, 199, 173, 80, 18, + 15, 118, 183, 3, 105, 56, 189, 107, 157, 44, 101, 103, 60, 99, 69, 187, 220, 146, 116, + 202, 85, 115, 219, 87, 241, 209, 39, 14, 184, 154, 236, 211, 31, 117, 106, 24, 68, 233, + 161, 49, 229, 105, 179, 58, 171, 215, 194, 178, 6, 243, 155, 2, 148, 202, 45, 145, 118, + 210, 233, 112, 208, 186, 157, 150, 68, 148, 94, 184, 49, 69, 36, 16, 33, 89, 36, 188, + 156, 122, 117, 124, 48, 83, 99, 153, 72, 148, 163, 14, 116, 15, 162, 169, 50, 10, 244, + 155, 136, 105, 76, 118, 209, 144, 130, 88, 102, 85, 234, 137, 222, 195, 37, 93, 169, + 179, 227, 195, 162, 203, 242, 19, 175, 252, 19, 197, 230, 97, 174, 167, 72, 10, 169, + 184, 26, 249, 41, 49, 199, 54, 205, 183, 178, 241, 5, 35, 142, 227, 196, 245, 2, 91, + 201, 6, 41, 62, 214, 158, 48, 39, 106, 189, 134, 27, 86, 2, 83, 27, 170, 30, 140, 103, + 7, 87, 65, 87, 163, 117, 188, 132, 38, 15, 116, 251, 23, 157, 113, 156, 165, 96, 41, + 112, 103, 3, 81, 200, 3, 166, 137, 98, 14, 86, 183, 200, 161, 100, 212, 199, 206, 167, + 114, 15, 235, 131, 103, 77, 103, 188, 254, 5, 30, 172, 183, 174, 176, 252, 9, 29, 90, + 78, 178, 188, 101, 158, 74, 200, 231, 75, 89, 233, 113, 206, 254, 122, 136, 41, 148, + 20, 10, 217, 144, 177, 108, 42, 124, 210, 7, 40, 138, 167, 223, 156, 151, 106, 236, 94, + 84, 183, 48, 39, 81, 77, 54, 129, 140, 133, 183, 107, 9, 8, 193, 99, 90, 185, 41, 229, + 43, 138, 11, 86, 161, 39, 124, 40, 114, 119, 122, 101, 124, 226, 227, 192, 241, 54, + 182, 89, 37, 186, 17, 90, 171, 245, 185, 119, 76, 21, 11, 170, 146, 46, 110, 228, 51, + 213, 162, 5, 193, 211, 153, 112, 148, 208, 43, 3, 254, 16, 173, 51, 88, 252, 219, 69, + 48, 242, 206, 198, 185, 105, 26, ]; assert!(expected_ssk == child_node.value.0.secret_spending_key); assert!(expected_ccc == child_node.ccc); - assert!(expected_nsk == child_node.value.0.private_key_holder.nullifier_secret_key); + assert!( + expected_ask + == child_node + .value + .0 + .private_key_holder + .authorization_secret_key + ); + assert!(expected_nsk == child_node.value.0.private_key_holder.nullifier_secret_key()); assert!(expected_npk == child_node.value.0.nullifier_public_key); assert!(expected_vsk == child_node.value.0.private_key_holder.viewing_secret_key); assert!(expected_vpk == child_node.value.0.viewing_public_key.to_bytes()); diff --git a/lee/key_protocol/src/key_management/secret_holders.rs b/lee/key_protocol/src/key_management/secret_holders.rs index b8225a4b6..088b49ff3 100644 --- a/lee/key_protocol/src/key_management/secret_holders.rs +++ b/lee/key_protocol/src/key_management/secret_holders.rs @@ -1,6 +1,8 @@ use bip39::Mnemonic; use common::HashType; -use lee_core::{NullifierPublicKey, NullifierSecretKey, encryption::ViewingPublicKey}; +use lee_core::{ + AuthorizationSecretKey, NullifierPublicKey, NullifierSecretKey, encryption::ViewingPublicKey, +}; use ml_kem; use rand::{RngCore as _, rngs::OsRng}; use serde::{Deserialize, Serialize}; @@ -36,7 +38,7 @@ impl ViewingSecretKey { /// for recepient. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct PrivateKeyHolder { - pub nullifier_secret_key: NullifierSecretKey, + pub authorization_secret_key: AuthorizationSecretKey, pub viewing_secret_key: ViewingSecretKey, } @@ -87,41 +89,35 @@ impl SeedHolder { impl SecretSpendingKey { #[must_use] #[expect(clippy::big_endian_bytes, reason = "BIP-032 uses big endian")] - pub fn generate_nullifier_secret_key(&self, index: Option) -> NullifierSecretKey { - const PREFIX: &[u8; 8] = b"LEE/keys"; - const SUFFIX_1: &[u8; 1] = &[1]; - const SUFFIX_2: &[u8; 19] = &[0; 19]; + pub fn generate_authorization_secret_key(&self, index: Option) -> AuthorizationSecretKey { + const DOMAIN: &[u8; 35] = b"/LEE/v0.3/Keys/Authorization/Secret"; let index = index.unwrap_or(0); let mut hasher = sha2::Sha256::new(); - hasher.update(PREFIX); + hasher.update(DOMAIN); hasher.update(self.0); - hasher.update(SUFFIX_1); hasher.update(index.to_be_bytes()); - hasher.update(SUFFIX_2); - ::from(hasher.finalize_fixed()) + AuthorizationSecretKey(hasher.finalize_fixed().into()) + } + + #[must_use] + pub fn generate_nullifier_secret_key(&self, index: Option) -> NullifierSecretKey { + ::from(&self.generate_authorization_secret_key(index)) } #[must_use] #[expect(clippy::big_endian_bytes, reason = "BIP-032 uses big endian")] pub fn generate_viewing_secret_seed_key(&self, index: Option) -> ViewingSecretKey { - const PREFIX: &[u8; 8] = b"LEE/keys"; - const SUFFIX_1: &[u8; 1] = &[2]; - const SUFFIX_2: &[u8; 19] = &[0; 19]; + const DOMAIN: &[u8; 29] = b"/LEE/v0.3/Keys/Viewing/Secret"; let index = index.unwrap_or(0); - let mut bytes: Vec = Vec::with_capacity(64); - bytes.extend_from_slice(PREFIX); - bytes.extend_from_slice(&self.0); - bytes.extend_from_slice(SUFFIX_1); - bytes.extend_from_slice(&index.to_be_bytes()); - bytes.extend_from_slice(SUFFIX_2); - let bytes: [u8; 64] = bytes - .try_into() - .expect("`generate_viewing_secret_seed_key`: bytes must be exactly 64"); + let mut bytes = [0_u8; 29 + 32 + 4]; + bytes[..29].copy_from_slice(DOMAIN); + bytes[29..61].copy_from_slice(&self.0); + bytes[61..].copy_from_slice(&index.to_be_bytes()); let full_seed = hmac_sha512::HMAC::mac(bytes, b"LEE_viewing_seed"); @@ -139,7 +135,7 @@ impl SecretSpendingKey { #[must_use] pub fn produce_private_key_holder(&self, index: Option) -> PrivateKeyHolder { PrivateKeyHolder { - nullifier_secret_key: self.generate_nullifier_secret_key(index), + authorization_secret_key: self.generate_authorization_secret_key(index), viewing_secret_key: self.generate_viewing_secret_seed_key(index), } } @@ -158,9 +154,14 @@ impl From<&ViewingSecretKey> for ViewingPublicKey { } impl PrivateKeyHolder { + #[must_use] + pub fn nullifier_secret_key(&self) -> NullifierSecretKey { + (&self.authorization_secret_key).into() + } + #[must_use] pub fn generate_nullifier_public_key(&self) -> NullifierPublicKey { - (&self.nullifier_secret_key).into() + NullifierPublicKey::from(&self.nullifier_secret_key()) } #[must_use] diff --git a/lee/privacy_preserving_circuit/src/output.rs b/lee/privacy_preserving_circuit/src/output.rs index 3ff6d9cb4..a01deef2b 100644 --- a/lee/privacy_preserving_circuit/src/output.rs +++ b/lee/privacy_preserving_circuit/src/output.rs @@ -1,8 +1,8 @@ use lee_core::{ Commitment, CommitmentSetDigest, DummyInput, EncryptedAccountData, EncryptionScheme, - EphemeralSecretKey, InputAccountIdentity, MembershipProof, Nullifier, NullifierSecretKey, - NullifierWitness, PrivacyPreservingCircuitOutput, PrivateAccountKind, PrivateAction, - PrivateWitness, PublicAction, SharedSecretKey, WitnessKind, + EphemeralSecretKey, InputAccountIdentity, MembershipProof, Nullifier, NullifierPublicKey, + NullifierSecretKey, NullifierWitness, PrivacyPreservingCircuitOutput, PrivateAccountKind, + PrivateAction, PrivateWitness, PublicAction, SharedSecretKey, WitnessKind, account::{Account, AccountId, Nonce}, compute_digest_for_path, encryption::{ViewTag, ViewingPublicKey}, @@ -48,7 +48,7 @@ pub fn compute_circuit_output( nullifier, }) => { let account_id = match kind { - WitnessKind::Regular => { + WitnessKind::Regular { .. } => { let derived = AccountId::for_regular_private_account( &nullifier.npk(), vpk, @@ -68,12 +68,31 @@ pub fn compute_circuit_output( match (kind, nullifier) { ( - WitnessKind::Regular, + WitnessKind::Regular { ask }, NullifierWitness::Init { .. } | NullifierWitness::Update { .. }, - ) => assert!( - pre_state.is_authorized, - "Regular private account pre-state must be authorized" - ), + ) => { + if let Some(ask) = ask { + let derived = NullifierSecretKey::from(ask); + match nullifier { + // Check that the authorization key is actually bound to the + // account Id. + NullifierWitness::Update { nsk, .. } => assert_eq!( + derived, *nsk, + "Authorization secret key does not derive this account's nullifier secret key" + ), + NullifierWitness::Init { npk, .. } => assert_eq!( + NullifierPublicKey::from(&derived), + *npk, + "Authorization secret key does not derive this account's nullifier public key" + ), + } + } + assert_eq!( + pre_state.is_authorized, + ask.is_some(), + "Regular private account authorization must match the supplied credential" + ); + } (WitnessKind::Pda { .. }, NullifierWitness::Init { .. }) => assert!( !pre_state.is_authorized, "Private PDA init requires unauthorized pre_state" @@ -126,7 +145,7 @@ pub fn compute_circuit_output( }; let account_kind = match kind { - WitnessKind::Regular => PrivateAccountKind::Regular(*identifier), + WitnessKind::Regular { .. } => PrivateAccountKind::Regular(*identifier), WitnessKind::Pda { .. } => { let (authority_program_id, seed) = pda_seed_by_position .get(&pos) diff --git a/lee/state_machine/core/src/circuit_io.rs b/lee/state_machine/core/src/circuit_io.rs index 1e3b35152..e30162902 100644 --- a/lee/state_machine/core/src/circuit_io.rs +++ b/lee/state_machine/core/src/circuit_io.rs @@ -2,8 +2,8 @@ use borsh::{BorshDeserialize, BorshSerialize}; use serde::{Deserialize, Serialize}; use crate::{ - Commitment, CommitmentSetDigest, Identifier, MembershipProof, Nullifier, NullifierPublicKey, - NullifierSecretKey, + AuthorizationSecretKey, Commitment, CommitmentSetDigest, Identifier, MembershipProof, + Nullifier, NullifierPublicKey, NullifierSecretKey, account::{Account, AccountWithMetadata}, encryption::{EncryptedAccountData, ViewTag, ViewingPublicKey}, program::{BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow}, @@ -48,8 +48,9 @@ pub struct PrivateWitness { pub enum WitnessKind { /// Standalone private account. The `account_id` is derived as /// `AccountId::for_regular_private_account(&npk, vpk, identifier)` and matched against - /// `pre_state.account_id`. - Regular, + /// `pre_state.account_id`. An honest authorized account's `npk` for Id computation gets + /// derived from the supplied `ask`. + Regular { ask: Option }, /// Private PDA. The npk-to-account_id binding is proven upstream via `Claim::Pda(seed)` or a /// caller's `pda_seeds` match. The identifier diversifies the PDA within the /// `(program_id, seed, npk)` family: `AccountId::for_private_pda` uses it as the 4th input. diff --git a/lee/state_machine/core/src/lib.rs b/lee/state_machine/core/src/lib.rs index f6944ec86..ab7b40f36 100644 --- a/lee/state_machine/core/src/lib.rs +++ b/lee/state_machine/core/src/lib.rs @@ -15,7 +15,9 @@ pub use encryption::{ EncryptedAccountData, EncryptionScheme, EphemeralPublicKey, EphemeralSecretKey, ML_KEM_768_CIPHERTEXT_LEN, SharedSecretKey, ViewTag, }; -pub use nullifier::{Identifier, Nullifier, NullifierPublicKey, NullifierSecretKey}; +pub use nullifier::{ + AuthorizationSecretKey, Identifier, Nullifier, NullifierPublicKey, NullifierSecretKey, +}; pub use program::PrivateAccountKind; pub mod account; diff --git a/lee/state_machine/core/src/nullifier.rs b/lee/state_machine/core/src/nullifier.rs index 755a2adce..1c0c5e17d 100644 --- a/lee/state_machine/core/src/nullifier.rs +++ b/lee/state_machine/core/src/nullifier.rs @@ -48,16 +48,29 @@ impl AsRef<[u8]> for NullifierPublicKey { } } +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[cfg_attr(any(feature = "host", test), derive(Hash))] +pub struct AuthorizationSecretKey(pub [u8; 32]); + +impl From<&AuthorizationSecretKey> for NullifierSecretKey { + fn from(value: &AuthorizationSecretKey) -> Self { + const DOMAIN: &[u8; 31] = b"/LEE/v0.3/Keys/Nullifier/Secret"; + let mut bytes = [0_u8; 31 + 32]; + bytes[..31].copy_from_slice(DOMAIN); + bytes[31..].copy_from_slice(&value.0); + Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .expect("hash should be exactly 32 bytes long") + } +} + impl From<&NullifierSecretKey> for NullifierPublicKey { fn from(value: &NullifierSecretKey) -> Self { - const PREFIX: &[u8; 8] = b"LEE/keys"; - const SUFFIX_1: &[u8; 1] = &[7]; - const SUFFIX_2: &[u8; 23] = &[0; 23]; - let mut bytes = Vec::new(); - bytes.extend_from_slice(PREFIX); - bytes.extend_from_slice(value); - bytes.extend_from_slice(SUFFIX_1); - bytes.extend_from_slice(SUFFIX_2); + const DOMAIN: &[u8; 31] = b"/LEE/v0.3/Keys/Nullifier/Public"; + let mut bytes = [0_u8; 31 + 32]; + bytes[..31].copy_from_slice(DOMAIN); + bytes[31..].copy_from_slice(value); Self( Impl::hash_bytes(&bytes) .as_bytes() @@ -154,6 +167,17 @@ mod tests { assert_eq!(nullifier, expected_nullifier); } + #[test] + fn from_authorization_key() { + let ask = AuthorizationSecretKey([0; 32]); + let expected_nsk: NullifierSecretKey = [ + 31, 33, 90, 89, 193, 14, 149, 46, 107, 38, 51, 65, 178, 242, 118, 11, 235, 198, 242, + 144, 192, 64, 39, 205, 244, 122, 210, 55, 11, 245, 117, 29, + ]; + let nsk = NullifierSecretKey::from(&ask); + assert_eq!(nsk, expected_nsk); + } + #[test] fn from_secret_key() { let nsk = [ @@ -161,8 +185,8 @@ mod tests { 196, 134, 22, 224, 211, 237, 120, 136, 225, 188, 220, 249, 28, ]; let expected_npk = NullifierPublicKey([ - 78, 20, 20, 5, 177, 198, 233, 100, 175, 134, 174, 200, 24, 205, 68, 215, 130, 74, 35, - 54, 154, 184, 219, 42, 168, 106, 126, 147, 133, 244, 18, 218, + 58, 181, 207, 24, 227, 133, 192, 231, 242, 216, 230, 219, 31, 227, 236, 94, 99, 245, + 206, 251, 237, 189, 88, 218, 215, 106, 66, 227, 136, 152, 140, 218, ]); let npk = NullifierPublicKey::from(&nsk); assert_eq!(npk, expected_npk); @@ -177,8 +201,8 @@ mod tests { let npk = NullifierPublicKey::from(&nsk); let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]); let expected_account_id = AccountId::new([ - 242, 239, 57, 244, 89, 109, 65, 201, 223, 100, 43, 87, 205, 83, 148, 161, 176, 22, 208, - 220, 68, 135, 10, 171, 182, 80, 54, 74, 228, 244, 236, 7, + 226, 149, 99, 147, 82, 211, 97, 152, 31, 46, 87, 113, 237, 244, 197, 108, 71, 191, 161, + 199, 140, 177, 247, 73, 95, 64, 202, 90, 8, 157, 188, 147, ]); let account_id = AccountId::for_regular_private_account(&npk, &vpk, 0); @@ -195,8 +219,8 @@ mod tests { let npk = NullifierPublicKey::from(&nsk); let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]); let expected_account_id = AccountId::new([ - 149, 125, 157, 109, 119, 81, 9, 163, 231, 181, 214, 43, 57, 113, 221, 72, 180, 149, - 189, 170, 32, 181, 255, 231, 19, 92, 235, 59, 153, 185, 172, 206, + 44, 36, 222, 50, 57, 159, 215, 6, 246, 54, 45, 150, 94, 148, 148, 71, 212, 113, 165, + 10, 187, 162, 184, 70, 96, 35, 42, 230, 251, 72, 237, 80, ]); let account_id = AccountId::for_regular_private_account(&npk, &vpk, 1); @@ -214,8 +238,8 @@ mod tests { let npk = NullifierPublicKey::from(&nsk); let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]); let expected_account_id = AccountId::new([ - 30, 232, 222, 201, 233, 125, 124, 194, 58, 39, 121, 96, 185, 84, 168, 109, 80, 111, - 159, 112, 84, 100, 133, 244, 16, 34, 221, 35, 128, 131, 98, 159, + 50, 122, 67, 122, 59, 36, 150, 204, 128, 54, 55, 152, 14, 220, 163, 211, 246, 221, 197, + 75, 12, 199, 163, 151, 234, 194, 188, 13, 27, 120, 249, 220, ]); let account_id = AccountId::for_regular_private_account(&npk, &vpk, identifier); diff --git a/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs index 9a0bb94ff..299ebcd87 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs @@ -93,7 +93,9 @@ fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts() vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -151,7 +153,7 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { commitment_set.extend(std::slice::from_ref(&commitment_sender)); let expected_new_nullifiers = vec![ ( - Nullifier::for_account_update(&commitment_sender, &sender_keys.nsk), + Nullifier::for_account_update(&commitment_sender, &sender_keys.nsk()), commitment_set.digest(), ), ( @@ -165,7 +167,7 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { let expected_private_account_1 = Account { program_owner: program.id(), balance: 100 - balance_to_move, - nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk), + nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk()), ..Default::default() }; let expected_private_account_2 = Account { @@ -182,7 +184,7 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { let esk_1 = EphemeralSecretKey::new( &sender_account_id, &[0; 32], - &sender_nonce.private_account_nonce_increment(&sender_keys.nsk), + &sender_nonce.private_account_nonce_increment(&sender_keys.nsk()), ); let shared_secret_1 = SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &esk_1).0; @@ -199,10 +201,12 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: commitment_set .get_proof_for(&commitment_sender) .expect("sender's commitment must be in the set"), @@ -212,7 +216,9 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -284,7 +290,9 @@ fn init_note_view_tag_is_derived_from_account_keys() { vpk: keys.vpk(), random_seed: [0; 32], identifier, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(keys.ask), + }, nullifier: NullifierWitness::Init { npk: keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -329,10 +337,12 @@ fn update_note_view_tag_is_the_supplied_value() { vpk: keys.vpk(), random_seed: [0; 32], identifier, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: fed_tag, - nsk: keys.nsk, + nsk: keys.nsk(), membership_proof: commitment_set.get_proof_for(&commitment).unwrap(), }, })], @@ -381,7 +391,9 @@ fn circuit_fails_when_chained_validity_windows_have_empty_intersection() { vpk: account_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(account_keys.ask), + }, nullifier: NullifierWitness::Init { npk: account_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -574,7 +586,9 @@ fn shared_account_receives_via_simple_transfer() { vpk: shared_keys.vpk(), random_seed: [0; 32], identifier: shared_identifier, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(shared_keys.ask), + }, nullifier: NullifierWitness::Init { npk: shared_npk, commitment_root: DUMMY_COMMITMENT_HASH, @@ -613,9 +627,11 @@ fn private_authorized_init_encrypts_regular_kind_with_identifier() { vpk: keys.vpk(), random_seed: [0; 32], identifier, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(keys.ask), + }, nullifier: NullifierWitness::Init { - npk: NullifierPublicKey::from(&keys.nsk), + npk: NullifierPublicKey::from(&keys.nsk()), commitment_root: DUMMY_COMMITMENT_HASH, }, })], @@ -653,7 +669,9 @@ fn private_foreign_init_encrypts_regular_kind_with_identifier() { vpk: keys.vpk(), random_seed: [0; 32], identifier, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(keys.ask), + }, nullifier: NullifierWitness::Init { npk: keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -680,7 +698,7 @@ fn private_authorized_update_encrypts_regular_kind_with_identifier() { let esk = EphemeralSecretKey::new( &account_id, &[0; 32], - &Nonce::default().private_account_nonce_increment(&keys.nsk), + &Nonce::default().private_account_nonce_increment(&keys.nsk()), ); let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0; let account = Account { @@ -701,10 +719,12 @@ fn private_authorized_update_encrypts_regular_kind_with_identifier() { vpk: keys.vpk(), random_seed: [0; 32], identifier, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: keys.nsk, + nsk: keys.nsk(), membership_proof: commitment_set.get_proof_for(&commitment).unwrap(), }, })], @@ -718,6 +738,215 @@ fn private_authorized_update_encrypts_regular_kind_with_identifier() { ); } +/// Builds an on-chain regular private account owned by `program`, returning its id, pre-state +/// and a membership proof for its commitment. +fn seeded_regular_account( + keys: &crate::state::tests::TestPrivateKeys, + program: &Program, + identifier: u128, +) -> (AccountId, AccountWithMetadata, lee_core::MembershipProof) { + let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier); + let account = Account { + program_owner: program.id(), + balance: 1, + ..Account::default() + }; + let commitment = Commitment::new(&account_id, &account); + let mut commitment_set = CommitmentSet::with_capacity(1); + commitment_set.extend(std::slice::from_ref(&commitment)); + let proof = commitment_set.get_proof_for(&commitment).unwrap(); + ( + account_id, + AccountWithMetadata::new(account, false, account_id), + proof, + ) +} + +/// Spending without consenting. The witness carries no `ask`, so the pre-state is unauthorized, +/// and the nullifier is still produced from the `nsk`. +#[test] +fn private_regular_update_without_ask_is_spendable() { + let program = crate::test_methods::noop(); + let keys = test_private_account_keys_1(); + let (_, pre, membership_proof) = seeded_regular_account(&keys, &program, 0); + assert!(!pre.is_authorized); + + execute_and_prove( + vec![pre], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::Private(PrivateWitness { + vpk: keys.vpk(), + random_seed: [0; 32], + identifier: 0, + kind: WitnessKind::Regular { ask: None }, + nullifier: NullifierWitness::Update { + view_tag: 0, + nsk: keys.nsk(), + membership_proof, + }, + })], + &program.into(), + ) + .unwrap(); +} + +/// Claiming authorization without supplying an `ask` is rejected. +#[test] +fn private_regular_witness_without_ask_cannot_assert_authorization() { + let program = crate::test_methods::noop(); + let keys = test_private_account_keys_1(); + let (account_id, pre, membership_proof) = seeded_regular_account(&keys, &program, 0); + let pre = AccountWithMetadata::new(pre.account, true, account_id); + + let result = execute_and_prove( + vec![pre], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::Private(PrivateWitness { + vpk: keys.vpk(), + random_seed: [0; 32], + identifier: 0, + kind: WitnessKind::Regular { ask: None }, + nullifier: NullifierWitness::Update { + view_tag: 0, + nsk: keys.nsk(), + membership_proof, + }, + })], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +/// An `ask` that does not derive this account's `nsk` is not a credential for it. +#[test] +fn regular_update_with_wrong_ask_nsk_is_rejected() { + let program = crate::test_methods::noop(); + let keys = test_private_account_keys_1(); + let foreign = test_private_account_keys_2(); + let (account_id, pre, membership_proof) = seeded_regular_account(&keys, &program, 0); + let pre = AccountWithMetadata::new(pre.account, true, account_id); + + let result = execute_and_prove( + vec![pre], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::Private(PrivateWitness { + vpk: keys.vpk(), + random_seed: [0; 32], + identifier: 0, + kind: WitnessKind::Regular { + ask: Some(foreign.ask), + }, + nullifier: NullifierWitness::Update { + view_tag: 0, + nsk: keys.nsk(), + membership_proof, + }, + })], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +/// An `ask` that does not derive this account's `npk` is not a credential for it. +#[test] +fn regular_init_with_non_chaining_ask_npk_is_rejected() { + let program = crate::test_methods::claimer(); + let keys = test_private_account_keys_1(); + let foreign = test_private_account_keys_2(); + let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), 0); + let pre = AccountWithMetadata::new(Account::default(), true, account_id); + + let result = execute_and_prove( + vec![pre], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::Private(PrivateWitness { + vpk: keys.vpk(), + random_seed: [0; 32], + identifier: 0, + kind: WitnessKind::Regular { + ask: Some(foreign.ask), + }, + nullifier: NullifierWitness::Init { + npk: keys.npk(), + commitment_root: DUMMY_COMMITMENT_HASH, + }, + })], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +#[test] +fn unauthorized_private_init_can_be_claimed() { + let program = crate::test_methods::claimer(); + let program_id = program.id(); + let keys = test_private_account_keys_1(); + let recipient_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), 0); + let recipient = AccountWithMetadata::new(Account::default(), false, recipient_id); + let esk = EphemeralSecretKey::new( + &recipient_id, + &[0; 32], + &Nonce::private_account_nonce_init(&recipient_id), + ); + let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0; + + let (output, _) = execute_and_prove( + vec![recipient], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::Private(PrivateWitness { + vpk: keys.vpk(), + random_seed: [0; 32], + identifier: 0, + kind: WitnessKind::Regular { ask: None }, + nullifier: NullifierWitness::Init { + npk: keys.npk(), + commitment_root: DUMMY_COMMITMENT_HASH, + }, + })], + &program.into(), + ) + .unwrap(); + + let (_, claimed) = EncryptionScheme::decrypt( + &output.private_actions[0].encrypted_post_state.ciphertext, + &ssk, + &output.private_actions[0].nullifier, + ) + .unwrap(); + assert_eq!(claimed.program_owner, program_id); +} + +/// A program that asserts authorization over its pre-states rejects a regular private account +/// whose witness supplied no `ask`. +#[test] +fn auth_asserting_program_rejects_unauthorized_regular_private_account() { + let program = crate::test_methods::auth_asserting_noop(); + let keys = test_private_account_keys_1(); + let (_, pre, membership_proof) = seeded_regular_account(&keys, &program, 0); + + let result = execute_and_prove( + vec![pre], + Program::serialize_instruction(()).unwrap(), + vec![InputAccountIdentity::Private(PrivateWitness { + vpk: keys.vpk(), + random_seed: [0; 32], + identifier: 0, + kind: WitnessKind::Regular { ask: None }, + nullifier: NullifierWitness::Update { + view_tag: 0, + nsk: keys.nsk(), + membership_proof, + }, + })], + &program.into(), + ); + + assert!(matches!(result, Err(LeeError::ProgramProveFailed(_)))); +} + /// A private-PDA update with a non-default identifier produces a ciphertext that decrypts /// to `PrivateAccountKind::Pda` carrying the correct `(program_id, seed, identifier)`. #[test] @@ -733,7 +962,7 @@ fn private_pda_update_encrypts_pda_kind_with_identifier() { let esk = EphemeralSecretKey::new( &pda_id, &[0; 32], - &Nonce::default().private_account_nonce_increment(&keys.nsk), + &Nonce::default().private_account_nonce_increment(&keys.nsk()), ); let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0; let pda_account = Account { @@ -764,7 +993,7 @@ fn private_pda_update_encrypts_pda_kind_with_identifier() { kind: WitnessKind::Pda { binding: None }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: keys.nsk, + nsk: keys.nsk(), membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(), }, }), @@ -847,7 +1076,7 @@ fn private_pda_update_identifier_mismatch_fails() { kind: WitnessKind::Pda { binding: None }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: keys.nsk, + nsk: keys.nsk(), membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(), }, }), diff --git a/lee/state_machine/src/state/tests/changer_claimer.rs b/lee/state_machine/src/state/tests/changer_claimer.rs index 16b3872ee..b422d1199 100644 --- a/lee/state_machine/src/state/tests/changer_claimer.rs +++ b/lee/state_machine/src/state/tests/changer_claimer.rs @@ -75,10 +75,12 @@ fn private_changer_claimer_no_data_change_no_claim_succeeds() { vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (0, vec![]), }, })], @@ -109,10 +111,12 @@ fn private_changer_claimer_data_change_no_claim_fails() { vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (0, vec![]), }, })], diff --git a/lee/state_machine/src/state/tests/circuit.rs b/lee/state_machine/src/state/tests/circuit.rs index f71f9e097..758a7c85f 100644 --- a/lee/state_machine/src/state/tests/circuit.rs +++ b/lee/state_machine/src/state/tests/circuit.rs @@ -65,10 +65,12 @@ fn circuit_fails_if_invalid_auth_keys_are_provided() { vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: recipient_keys.nsk, + nsk: recipient_keys.nsk(), membership_proof: (0, vec![]), }, }), @@ -76,7 +78,9 @@ fn circuit_fails_if_invalid_auth_keys_are_provided() { vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -121,10 +125,12 @@ fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provid vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (0, vec![]), }, }), @@ -132,7 +138,9 @@ fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provid vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -177,10 +185,12 @@ fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_ vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (0, vec![]), }, }), @@ -188,7 +198,9 @@ fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_ vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -233,10 +245,12 @@ fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided( vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (0, vec![]), }, }), @@ -244,7 +258,9 @@ fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided( vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -289,10 +305,12 @@ fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (0, vec![]), }, }), @@ -300,7 +318,9 @@ fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -343,10 +363,12 @@ fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_bu vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (0, vec![]), }, }), @@ -354,7 +376,9 @@ fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_bu vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -749,10 +773,12 @@ fn circuit_should_fail_if_there_are_repeated_ids() { vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (1, vec![]), }, }), @@ -760,10 +786,12 @@ fn circuit_should_fail_if_there_are_repeated_ids() { vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: (1, vec![]), }, }), @@ -802,9 +830,11 @@ fn private_authorized_uninitialized_account() { vpk: private_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(private_keys.ask), + }, nullifier: NullifierWitness::Init { - npk: NullifierPublicKey::from(&private_keys.nsk), + npk: NullifierPublicKey::from(&private_keys.nsk()), commitment_root: DUMMY_COMMITMENT_HASH, }, })], @@ -851,7 +881,9 @@ fn private_unauthorized_uninitialized_account_can_still_be_claimed() { vpk: private_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(private_keys.ask), + }, nullifier: NullifierWitness::Init { npk: private_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -904,9 +936,11 @@ fn private_account_claimed_then_used_without_init_flag_should_fail() { vpk: private_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(private_keys.ask), + }, nullifier: NullifierWitness::Init { - npk: NullifierPublicKey::from(&private_keys.nsk), + npk: NullifierPublicKey::from(&private_keys.nsk()), commitment_root: DUMMY_COMMITMENT_HASH, }, })], @@ -949,9 +983,11 @@ fn private_account_claimed_then_used_without_init_flag_should_fail() { vpk: private_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(private_keys.ask), + }, nullifier: NullifierWitness::Init { - npk: NullifierPublicKey::from(&private_keys.nsk), + npk: NullifierPublicKey::from(&private_keys.nsk()), commitment_root: DUMMY_COMMITMENT_HASH, }, })], @@ -1104,7 +1140,7 @@ fn two_private_pda_family_members_receive_and_spend() { kind: WitnessKind::Pda { binding: None }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: alice_keys.nsk, + nsk: alice_keys.nsk(), membership_proof: state .get_proof_for_commitment(&commitment_pda_0) .expect("pda_0 must be in state"), @@ -1143,7 +1179,7 @@ fn two_private_pda_family_members_receive_and_spend() { kind: WitnessKind::Pda { binding: None }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: alice_keys.nsk, + nsk: alice_keys.nsk(), membership_proof: state .get_proof_for_commitment(&commitment_pda_1) .expect("pda_1 must be in state"), @@ -1174,7 +1210,7 @@ fn two_private_pda_family_members_receive_and_spend() { balance: 0, nonce: alice_pda_1_account .nonce - .private_account_nonce_increment(&alice_keys.nsk), + .private_account_nonce_increment(&alice_keys.nsk()), ..Account::default() }; let commitment_pda_1_after_spend = @@ -1199,7 +1235,7 @@ fn two_private_pda_family_members_receive_and_spend() { }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: alice_keys.nsk, + nsk: alice_keys.nsk(), membership_proof: state .get_proof_for_commitment(&commitment_pda_1_after_spend) .expect("pda_1 after spend must be in state"), diff --git a/lee/state_machine/src/state/tests/claiming.rs b/lee/state_machine/src/state/tests/claiming.rs index 68c3cf5e4..1d63fdb36 100644 --- a/lee/state_machine/src/state/tests/claiming.rs +++ b/lee/state_machine/src/state/tests/claiming.rs @@ -329,10 +329,12 @@ fn authorized_public_account_claiming_succeeds_when_executed_privately() { vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: state .get_proof_for_commitment(&sender_commitment) .expect("sender's commitment must be in state"), @@ -353,7 +355,7 @@ fn authorized_public_account_claiming_succeeds_when_executed_privately() { .transition_from_privacy_preserving_transaction(&tx, 1, 0) .unwrap(); - let nullifier = Nullifier::for_account_update(&sender_commitment, &sender_keys.nsk); + let nullifier = Nullifier::for_account_update(&sender_commitment, &sender_keys.nsk()); assert!(state.private_state.1.contains(&nullifier)); assert_eq!( @@ -420,8 +422,8 @@ fn private_chained_call(number_of_calls: u32) { dependencies.insert(simple_transfers.id(), simple_transfers); let program_with_deps = ProgramWithDependencies::new(chain_caller, dependencies); - let from_new_nonce = Nonce::default().private_account_nonce_increment(&from_keys.nsk); - let to_new_nonce = Nonce::default().private_account_nonce_increment(&to_keys.nsk); + let from_new_nonce = Nonce::default().private_account_nonce_increment(&from_keys.nsk()); + let to_new_nonce = Nonce::default().private_account_nonce_increment(&to_keys.nsk()); let from_expected_post = Account { balance: initial_balance - u128::from(number_of_calls) * amount, @@ -446,10 +448,12 @@ fn private_chained_call(number_of_calls: u32) { vpk: from_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(from_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: from_keys.nsk, + nsk: from_keys.nsk(), membership_proof: state .get_proof_for_commitment(&from_commitment) .expect("from's commitment must be in state"), @@ -459,10 +463,12 @@ fn private_chained_call(number_of_calls: u32) { vpk: to_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(to_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: to_keys.nsk, + nsk: to_keys.nsk(), membership_proof: state .get_proof_for_commitment(&to_commitment) .expect("to's commitment must be in state"), diff --git a/lee/state_machine/src/state/tests/mod.rs b/lee/state_machine/src/state/tests/mod.rs index 878f722c6..b1c67b9b5 100644 --- a/lee/state_machine/src/state/tests/mod.rs +++ b/lee/state_machine/src/state/tests/mod.rs @@ -7,8 +7,8 @@ use std::collections::HashMap; use lee_core::{ - BlockId, Commitment, DUMMY_COMMITMENT_HASH, InputAccountIdentity, Nullifier, - NullifierPublicKey, NullifierSecretKey, NullifierWitness, PrivateWitness, Timestamp, + AuthorizationSecretKey, BlockId, Commitment, DUMMY_COMMITMENT_HASH, InputAccountIdentity, + Nullifier, NullifierPublicKey, NullifierSecretKey, NullifierWitness, PrivateWitness, Timestamp, WitnessKind, account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data}, encryption::ViewingPublicKey, @@ -138,14 +138,18 @@ impl TestPublicKeys { } pub struct TestPrivateKeys { - pub nsk: NullifierSecretKey, + pub ask: AuthorizationSecretKey, pub d: [u8; 32], pub z: [u8; 32], } impl TestPrivateKeys { + pub fn nsk(&self) -> NullifierSecretKey { + (&self.ask).into() + } + pub fn npk(&self) -> NullifierPublicKey { - NullifierPublicKey::from(&self.nsk) + NullifierPublicKey::from(&self.nsk()) } pub fn vpk(&self) -> ViewingPublicKey { @@ -241,7 +245,7 @@ fn test_public_account_keys_2() -> TestPublicKeys { pub fn test_private_account_keys_1() -> TestPrivateKeys { TestPrivateKeys { - nsk: [13; 32], + ask: AuthorizationSecretKey([13; 32]), d: [31; 32], z: [32; 32], } @@ -249,7 +253,7 @@ pub fn test_private_account_keys_1() -> TestPrivateKeys { pub fn test_private_account_keys_2() -> TestPrivateKeys { TestPrivateKeys { - nsk: [38; 32], + ask: AuthorizationSecretKey([38; 32]), d: [83; 32], z: [84; 32], } @@ -284,7 +288,9 @@ fn shielded_balance_transfer_for_tests( vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -331,10 +337,12 @@ fn private_balance_transfer_for_tests( vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: state .get_proof_for_commitment(&sender_commitment) .expect("sender's commitment must be in state"), @@ -344,7 +352,9 @@ fn private_balance_transfer_for_tests( vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Init { npk: recipient_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -392,10 +402,12 @@ fn deshielded_balance_transfer_for_tests( vpk: sender_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(sender_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: sender_keys.nsk, + nsk: sender_keys.nsk(), membership_proof: state .get_proof_for_commitment(&sender_commitment) .expect("sender's commitment must be in state"), diff --git a/lee/state_machine/src/state/tests/privacy_preserving.rs b/lee/state_machine/src/state/tests/privacy_preserving.rs index afc9d88ae..d3262d9b7 100644 --- a/lee/state_machine/src/state/tests/privacy_preserving.rs +++ b/lee/state_machine/src/state/tests/privacy_preserving.rs @@ -76,7 +76,7 @@ fn transition_from_privacy_preserving_transaction_private() { &sender_account_id, &Account { program_owner: crate::test_methods::simple_balance_transfer().id(), - nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk), + nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk()), balance: sender_private_account.balance - balance_to_move, data: Data::default(), }, @@ -84,7 +84,7 @@ fn transition_from_privacy_preserving_transaction_private() { let sender_pre_commitment = Commitment::new(&sender_account_id, &sender_private_account); let expected_new_nullifier = - Nullifier::for_account_update(&sender_pre_commitment, &sender_keys.nsk); + Nullifier::for_account_update(&sender_pre_commitment, &sender_keys.nsk()); let expected_new_commitment_2 = Commitment::new( &recipient_account_id, @@ -211,7 +211,7 @@ fn transition_from_privacy_preserving_transaction_deshielded() { &sender_account_id, &Account { program_owner: crate::test_methods::simple_balance_transfer().id(), - nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk), + nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk()), balance: sender_private_account.balance - balance_to_move, data: Data::default(), }, @@ -219,7 +219,7 @@ fn transition_from_privacy_preserving_transaction_deshielded() { let sender_pre_commitment = Commitment::new(&sender_account_id, &sender_private_account); let expected_new_nullifier = - Nullifier::for_account_update(&sender_pre_commitment, &sender_keys.nsk); + Nullifier::for_account_update(&sender_pre_commitment, &sender_keys.nsk()); assert!(state.private_state.0.contains(&sender_pre_commitment)); assert!(!state.private_state.0.contains(&expected_new_commitment)); @@ -525,10 +525,12 @@ fn malicious_authorization_changer_should_fail_in_privacy_preserving_circuit() { vpk: recipient_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(recipient_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: recipient_keys.nsk, + nsk: recipient_keys.nsk(), membership_proof: state .get_proof_for_commitment(&recipient_commitment) .expect("recipient's commitment must be in state"), diff --git a/lee/state_machine/src/state/tests/validity_window.rs b/lee/state_machine/src/state/tests/validity_window.rs index c39571ea4..7953c671a 100644 --- a/lee/state_machine/src/state/tests/validity_window.rs +++ b/lee/state_machine/src/state/tests/validity_window.rs @@ -142,7 +142,9 @@ fn validity_window_works_in_privacy_preserving_transactions( vpk: account_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(account_keys.ask), + }, nullifier: NullifierWitness::Init { npk: account_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, @@ -210,7 +212,9 @@ fn timestamp_validity_window_works_in_privacy_preserving_transactions( vpk: account_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(account_keys.ask), + }, nullifier: NullifierWitness::Init { npk: account_keys.npk(), commitment_root: DUMMY_COMMITMENT_HASH, diff --git a/lee/state_machine/src/validated_state_diff/tests.rs b/lee/state_machine/src/validated_state_diff/tests.rs index b3db107a6..003b2780b 100644 --- a/lee/state_machine/src/validated_state_diff/tests.rs +++ b/lee/state_machine/src/validated_state_diff/tests.rs @@ -168,10 +168,12 @@ fn privacy_malicious_programs_cannot_drain_public_victim() { vpk: attacker_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(attacker_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: attacker_keys.nsk, + nsk: attacker_keys.nsk(), membership_proof, }, }), @@ -330,10 +332,12 @@ fn privacy_malicious_programs_cannot_drain_private_victim() { vpk: attacker_keys.vpk(), random_seed: [0; 32], identifier: 0, - kind: WitnessKind::Regular, + kind: WitnessKind::Regular { + ask: Some(attacker_keys.ask), + }, nullifier: NullifierWitness::Update { view_tag: 0, - nsk: attacker_keys.nsk, + nsk: attacker_keys.nsk(), membership_proof, }, }), diff --git a/lez/cross_zone/src/lib.rs b/lez/cross_zone/src/lib.rs index 38f35d24c..58d9344dc 100644 --- a/lez/cross_zone/src/lib.rs +++ b/lez/cross_zone/src/lib.rs @@ -9,12 +9,10 @@ //! own block-reading, emission-extraction, delivery-building, and trust model; a //! shared trait is best lifted from that first real adapter, not from this one. -use std::collections::BTreeMap; - pub use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer}; use cross_zone_inbox_core::{ CrossZoneMessage, InboxConfig, Instruction, ZoneId, inbox_config_account_id, - inbox_seen_shard_account_id, + inbox_seen_shard_account_id, inbox_source_marker_account_id, }; use lee_core::{ account::{Account, AccountId, Balance}, @@ -31,6 +29,21 @@ pub struct Emission { pub payload: Vec, } +/// Where a delivery came from on the peer chain. +/// +/// One struct so the watcher and the verifier fill the same field list: their +/// dispatch transactions for one emission must be byte-identical. +/// +/// `src_block_hash` is the recomputed hash on both sides, never the declared +/// `header.hash`, which the signature does not cover. +pub struct EmissionSource { + pub src_zone: ZoneId, + pub src_block_id: u64, + pub src_block_hash: [u8; 32], + pub src_tx_index: u32, + pub src_program_id: ProgramId, +} + /// Whether a program may only be invoked by sequencer-origin transactions. /// /// The cross-zone inbox is injected solely by the watcher; a user-submitted call @@ -49,13 +62,18 @@ pub fn is_sequencer_only_program(program_id: ProgramId) -> bool { #[must_use] pub fn extract_emission(program_id: ProgramId, instruction_data: &[u32]) -> Option { if program_id == programs::ping_sender().id() { - let ping_core::SenderInstruction::Send { + // Not every transaction to an emitter emits: `InitConfig` is one of its + // instructions, so a non-`Send` decode is an ordinary non-emitting tx. + let Ok(ping_core::SenderInstruction::Send { target_zone, target_program_id, target_accounts, payload, .. - } = risc0_zkvm::serde::from_slice(instruction_data).ok()?; + }) = risc0_zkvm::serde::from_slice(instruction_data) + else { + return None; + }; Some(Emission { target_zone, target_program_id, @@ -63,13 +81,16 @@ pub fn extract_emission(program_id: ProgramId, instruction_data: &[u32]) -> Opti payload, }) } else if program_id == programs::bridge_lock().id() { - let bridge_lock_core::Instruction::Lock { + let Ok(bridge_lock_core::Instruction::Lock { target_zone, target_program_id, target_accounts, payload, .. - } = risc0_zkvm::serde::from_slice(instruction_data).ok()?; + }) = risc0_zkvm::serde::from_slice(instruction_data) + else { + return None; + }; Some(Emission { target_zone, target_program_id, @@ -88,13 +109,21 @@ fn build_inbox_dispatch_tx( msg: &CrossZoneMessage, target_account_ids: Vec, ) -> lee::PublicTransaction { - let mut account_ids = Vec::with_capacity(target_account_ids.len().saturating_add(2)); + let mut account_ids = Vec::with_capacity(target_account_ids.len().saturating_add(3)); account_ids.push(inbox_config_account_id(inbox_id)); account_ids.push(inbox_seen_shard_account_id( inbox_id, &msg.src_zone, msg.src_block_id, )); + // Declared here rather than derived by the guest, since a guest cannot + // conjure an account. Both the watcher and the verifier build it through this + // one function, so they cannot disagree about the source a target will see. + account_ids.push(inbox_source_marker_account_id( + inbox_id, + &msg.src_zone, + msg.src_program_id, + )); account_ids.extend(target_account_ids); let message = lee::public_transaction::Message::try_new( @@ -118,19 +147,17 @@ fn build_inbox_dispatch_tx( /// Option B check). #[must_use] pub fn build_dispatch_from_emission( - src_zone: ZoneId, - src_block_id: u64, - src_tx_index: u32, - src_program_id: ProgramId, + source: &EmissionSource, target_program_id: ProgramId, target_accounts: &[[u8; 32]], payload: Vec, ) -> lee::PublicTransaction { let msg = CrossZoneMessage { - src_zone, - src_block_id, - src_tx_index, - src_program_id, + src_zone: source.src_zone, + src_block_id: source.src_block_id, + src_block_hash: source.src_block_hash, + src_tx_index: source.src_tx_index, + src_program_id: source.src_program_id, target_program_id, payload, l1_inclusion_witness: None, @@ -143,33 +170,18 @@ pub fn build_dispatch_from_emission( build_inbox_dispatch_tx(programs::cross_zone_inbox().id(), &msg, target_ids) } -/// The inbox config a zone derives from its cross-zone config: the per-peer -/// delivery routes plus its own zone id. -fn inbox_config(self_zone: ZoneId, cross_zone: &CrossZoneConfig) -> InboxConfig { - let mut allowed_routes = BTreeMap::new(); - for peer in &cross_zone.peers { - allowed_routes.insert(peer.channel_id, peer.allowed_routes.clone()); - } - InboxConfig { - self_zone, - allowed_routes, - } -} - /// The genesis transaction that initializes this zone's inbox config PDA. /// -/// Lets the inbox guest authorize inbound peer messages; replaying it seeds the -/// same account on every node, keeping their state consistent. +/// The operator's per-peer routes no longer live here. They are fanned out into +/// each target program's own config, so all the inbox keeps is its zone id. +/// Replaying this seeds the same account on every node. #[must_use] -pub fn build_inbox_init_config_tx( - self_zone: ZoneId, - cross_zone: &CrossZoneConfig, -) -> lee::PublicTransaction { +pub fn build_inbox_init_config_tx(self_zone: ZoneId) -> lee::PublicTransaction { let inbox_id = programs::cross_zone_inbox().id(); genesis_public_tx( inbox_id, vec![inbox_config_account_id(inbox_id)], - Instruction::InitConfig(inbox_config(self_zone, cross_zone)), + Instruction::InitConfig(InboxConfig { self_zone }), ) } @@ -189,19 +201,104 @@ pub fn build_holding_account(holder: AccountId, amount: Balance) -> (AccountId, } /// The genesis transaction that pins the cross-zone inbox as the wrapped-token -/// minter, without importing the inbox id into the guest. +/// minter and names the peer sources it may mint for, without importing either id +/// into the guest. +/// +/// The sources are the operator's own peer routes aimed at this token, moved from +/// the inbox's allowlist to the token's own config: the same information, enforced +/// by the program that owns the value. A zone with no peers gets an empty list, +/// which authorizes nothing, and the config is still seeded so its PDA cannot be +/// claimed by a first initializer. #[must_use] -pub fn build_wrapped_token_init_config_tx() -> lee::PublicTransaction { +pub fn build_wrapped_token_init_config_tx( + cross_zone: Option<&CrossZoneConfig>, +) -> lee::PublicTransaction { let wrapped_token_id = programs::wrapped_token().id(); + let sources = cross_zone + .map(|cross_zone| { + cross_zone + .peers + .iter() + .flat_map(|peer| { + peer.allowed_routes + .iter() + .filter(|route| route.target_program_id == wrapped_token_id) + .map(|route| (peer.channel_id, route.src_program_id)) + }) + .collect() + }) + .unwrap_or_default(); genesis_public_tx( wrapped_token_id, vec![wrapped_token_core::config_account_id(wrapped_token_id)], - wrapped_token_core::Instruction::InitConfig { + wrapped_token_core::Instruction::InitConfig(wrapped_token_core::WrappedTokenConfig { minter: programs::cross_zone_inbox().id(), + sources, + }), + ) +} + +/// The genesis transaction that pins the outbox `ping_sender` chains into, +/// without importing the outbox id into the guest. +#[must_use] +pub fn build_ping_sender_init_config_tx() -> lee::PublicTransaction { + let ping_sender_id = programs::ping_sender().id(); + genesis_public_tx( + ping_sender_id, + vec![ping_core::sender_config_account_id(ping_sender_id)], + ping_core::SenderInstruction::InitConfig { + outbox_program_id: programs::cross_zone_outbox().id(), }, ) } +/// The genesis transaction that pins the outbox `bridge_lock` chains into and the +/// wrapped token it mints, without importing either id into the guest. +#[must_use] +pub fn build_bridge_lock_init_config_tx() -> lee::PublicTransaction { + let bridge_lock_id = programs::bridge_lock().id(); + genesis_public_tx( + bridge_lock_id, + vec![bridge_lock_core::config_account_id(bridge_lock_id)], + bridge_lock_core::Instruction::InitConfig { + outbox_program_id: programs::cross_zone_outbox().id(), + target_program_id: programs::wrapped_token().id(), + }, + ) +} + +/// The genesis transaction naming the peer sources `ping_receiver` accepts a +/// delivery from, fanned out of the operator's routes exactly as the wrapped +/// token's is. +#[must_use] +pub fn build_ping_receiver_init_config_tx( + cross_zone: Option<&CrossZoneConfig>, +) -> lee::PublicTransaction { + let receiver_id = programs::ping_receiver().id(); + let sources = cross_zone + .map(|cross_zone| { + cross_zone + .peers + .iter() + .flat_map(|peer| { + peer.allowed_routes + .iter() + .filter(|route| route.target_program_id == receiver_id) + .map(|route| (peer.channel_id, route.src_program_id)) + }) + .collect() + }) + .unwrap_or_default(); + genesis_public_tx( + receiver_id, + vec![ping_core::receiver_config_account_id(receiver_id)], + ping_core::ReceiverInstruction::InitConfig(ping_core::ReceiverConfig { + deliverer: programs::cross_zone_inbox().id(), + sources, + }), + ) +} + /// Builds an unsigned, sequencer-origin genesis transaction invoking `instruction` /// on `program_id` over `account_ids`. fn genesis_public_tx( diff --git a/lez/explorer_service/src/api.rs b/lez/explorer_service/src/api.rs index 5984a6360..9614e8435 100644 --- a/lez/explorer_service/src/api.rs +++ b/lez/explorer_service/src/api.rs @@ -151,9 +151,8 @@ pub async fn get_transactions_by_account( #[cfg(feature = "ssr")] pub fn create_indexer_rpc_client(url: &url::Url) -> Result { use jsonrpsee::http_client::HttpClientBuilder; - use log::info; - info!("Connecting to Indexer RPC on URL: {url}"); + log::info!("Connecting to Indexer RPC on URL: {url}"); HttpClientBuilder::default() .build(url.as_str()) diff --git a/lez/indexer/core/src/cross_zone_verifier.rs b/lez/indexer/core/src/cross_zone_verifier.rs index 9dcfe2eb5..be17e6f3c 100644 --- a/lez/indexer/core/src/cross_zone_verifier.rs +++ b/lez/indexer/core/src/cross_zone_verifier.rs @@ -6,13 +6,13 @@ use std::{ use anyhow::anyhow; use common::{block::Block, transaction::LeeTransaction}; -use cross_zone::{build_dispatch_from_emission, extract_emission}; +use cross_zone::{EmissionSource, build_dispatch_from_emission, extract_emission}; use cross_zone_inbox_core::{ CrossZoneMessage, Instruction as InboxInstruction, MessageKey, ZoneId, message_key, }; use futures::{Stream, StreamExt as _}; use lee::{GENESIS_BLOCK_ID, PublicKey}; -use log::{debug, error, info, warn}; +use log::{debug, error, warn}; use logos_blockchain_core::mantle::ops::channel::ChannelId; use logos_blockchain_zone_sdk::{ CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, @@ -63,6 +63,11 @@ pub enum CrossZoneVerifyError { }, } +/// The replay key plus the source block, which is what the inbox treats as one +/// delivery. Skipping re-derivation on the key alone would wave through a +/// dispatch the guest refuses, parking the block and holding ingestion. +type SeenKey = (MessageKey, [u8; 32]); + /// One peer zone's cached blocks, plus how far this reader has read them as an /// unbroken hash-linked run from the peer's genesis. #[derive(Default)] @@ -184,7 +189,7 @@ impl PeerBlocks { ); return false; } - info!( + log::info!( "Peer zone {} block {}: replacing held block {} with {}, which continues the verified run where the held one never could.", hex::encode(zone), block.header.block_id, @@ -278,7 +283,7 @@ pub struct CrossZoneVerifier { /// optional: a peer with no configured key is not signature-checked. peer_pubkeys: HashMap, peers: PeerBlocks, - seen: Arc>>, + seen: Arc>>, } impl CrossZoneVerifier { @@ -330,17 +335,14 @@ impl CrossZoneVerifier { /// forged dispatch reuse it to skip re-derivation while the inbox delivers the /// forgery. A key already seen is a replay the inbox no-ops, so it is accepted /// without re-derivation rather than halting on a legitimate re-delivery. - pub async fn verify_block( - &self, - block: &Block, - ) -> Result, CrossZoneVerifyError> { + pub async fn verify_block(&self, block: &Block) -> Result, CrossZoneVerifyError> { let mut verified = Vec::new(); for tx in &block.body.transactions { let Some(msg) = Self::decode_dispatch(tx) else { continue; }; - let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index); + let key = seen_key(&msg); if self.seen.read().await.contains(&key) { debug!( "Skipping already-seen cross-zone dispatch from zone {} block {} tx {} (replay no-op)", @@ -361,7 +363,7 @@ impl CrossZoneVerifier { ))); } - info!( + log::info!( "Verified cross-zone dispatch from zone {} block {} tx {}", hex::encode(msg.src_zone), msg.src_block_id, @@ -375,7 +377,7 @@ impl CrossZoneVerifier { /// Marks the given dispatch keys seen, so a later replay of them is accepted /// without re-derivation. Call only after the block that carried them has been /// applied on chain (see [`Self::verify_block`]). - pub async fn record_seen(&self, keys: Vec) { + pub async fn record_seen(&self, keys: Vec) { if keys.is_empty() { return; } @@ -455,11 +457,16 @@ impl CrossZoneVerifier { ))); } + // Recomputed rather than read from `msg`, which would make the field + // attest to itself. Ok(build_dispatch_from_emission( - msg.src_zone, - msg.src_block_id, - msg.src_tx_index, - message.program_id, + &EmissionSource { + src_zone: msg.src_zone, + src_block_id: msg.src_block_id, + src_block_hash: peer_block.recompute_hash().0, + src_tx_index: msg.src_tx_index, + src_program_id: message.program_id, + }, emission.target_program_id, &emission.target_accounts, emission.payload, @@ -506,7 +513,7 @@ impl CrossZoneVerifier { }); } if !waited.is_zero() && waited.as_secs().is_multiple_of(LAG_LOG_INTERVAL.as_secs()) { - info!( + log::info!( "Waiting for peer zone {} to finalize block {} ({}s); reader is behind", hex::encode(zone), block_id, @@ -528,6 +535,13 @@ struct PeerPass { stalled_at: Option, } +fn seen_key(msg: &CrossZoneMessage) -> SeenKey { + ( + message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index), + msg.src_block_hash, + ) +} + /// Whether a block read off a peer's channel may enter the cache. The channel /// authorizes who may write, not what they may claim. /// @@ -577,7 +591,7 @@ async fn read_peer( peers: PeerBlocks, poll_interval: Duration, ) { - info!( + log::info!( "Cross-zone peer reader started for {}", hex::encode(peer_zone) ); @@ -704,7 +718,7 @@ mod tests { }; use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription}; use logos_blockchain_zone_sdk::ZoneBlock; - use ping_core::{SenderInstruction, ping_record_pda}; + use ping_core::{SenderInstruction, ping_record_pda, receiver_config_account_id}; use super::*; @@ -732,10 +746,12 @@ mod tests { fn emission(payload: &[u8]) -> LeeTransaction { let receiver_id = programs::ping_receiver().id(); let send = SenderInstruction::Send { - outbox_program_id: programs::cross_zone_outbox().id(), target_zone: SELF_ZONE, target_program_id: receiver_id, - target_accounts: vec![ping_record_pda(receiver_id).into_value()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload: payload.to_vec(), ordinal: 0, }; @@ -808,14 +824,34 @@ mod tests { /// The dispatch a watcher would inject for a `PEER_BLOCK_ID` emission of `payload`. fn dispatch(payload: &[u8]) -> LeeTransaction { + dispatch_naming_block_hash(payload, source_block_hash(payload)) + } + + /// The recomputed hash of the `PEER_BLOCK_ID` block carrying `payload`, + /// which is what an honest watcher puts in the dispatch. + fn source_block_hash(payload: &[u8]) -> [u8; 32] { + peer_chain(payload) + .last() + .expect("chain reaches PEER_BLOCK_ID") + .recompute_hash() + .0 + } + + fn dispatch_naming_block_hash(payload: &[u8], src_block_hash: [u8; 32]) -> LeeTransaction { let receiver_id = programs::ping_receiver().id(); LeeTransaction::Public(build_dispatch_from_emission( - PEER_ZONE, - PEER_BLOCK_ID, - 0, - programs::ping_sender().id(), + &EmissionSource { + src_zone: PEER_ZONE, + src_block_id: PEER_BLOCK_ID, + src_block_hash, + src_tx_index: 0, + src_program_id: programs::ping_sender().id(), + }, receiver_id, - &[ping_record_pda(receiver_id).into_value()], + &[ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload.to_vec(), )) } @@ -832,6 +868,24 @@ mod tests { .expect("dispatch matching the peer emission verifies"); } + #[tokio::test] + async fn rejects_dispatch_naming_the_wrong_source_block_hash() { + let verifier = verifier(); + cache_chain(&verifier, peer_chain(b"hi")).await; + + // Only the claimed source hash is wrong. Detectable because the verifier + // recomputes it from the resolved block instead of reading the field. + let block = + produce_dummy_block(9, None, vec![dispatch_naming_block_hash(b"hi", [0xab; 32])]); + assert!( + matches!( + verifier.verify_block(&block).await, + Err(CrossZoneVerifyError::Forged(_)) + ), + "a delivery claiming a source block hash the peer block does not have is forged" + ); + } + #[tokio::test] async fn rejects_dispatch_with_no_matching_emission() { let verifier = verifier(); @@ -892,18 +946,52 @@ mod tests { // Mark the delivery seen, as the ingest loop does once the block applies. verifier.record_seen(keys).await; - // A payload that cannot re-derive, under the key just recorded. Accepted - // only by the seen-key short circuit, since the inbox no-ops it on - // chain; `unaccepted_dispatch_does_not_poison_seen` asserts the same - // input is rejected when the key was never recorded, which is what makes - // this one about the short circuit rather than re-derivation. - let replay = produce_dummy_block(10, None, vec![dispatch(b"forged")]); + // A payload that cannot re-derive, under the key just recorded, which + // now names the source block as well as the coordinates. Accepted only + // by the seen-key short circuit, since the inbox no-ops it on chain; + // `unaccepted_dispatch_does_not_poison_seen` asserts the same input is + // rejected when the key was never recorded, which is what makes this one + // about the short circuit rather than re-derivation. + let replay = produce_dummy_block( + 10, + None, + vec![dispatch_naming_block_hash( + b"forged", + source_block_hash(b"hi"), + )], + ); verifier .verify_block(&replay) .await .expect("a replay is accepted as an on-chain no-op"); } + #[tokio::test] + async fn a_seen_coordinate_does_not_excuse_a_different_source_block() { + let verifier = verifier(); + cache_chain(&verifier, peer_chain(b"hi")).await; + + let first = produce_dummy_block(9, None, vec![dispatch(b"hi")]); + let keys = verifier.verify_block(&first).await.expect("first verifies"); + verifier.record_seen(keys).await; + + // Same coordinates as the delivery just seen, different source block. + // The inbox refuses rather than no-ops it, so skipping re-derivation + // would wave through a dispatch that parks the block. + let other = produce_dummy_block( + 10, + None, + vec![dispatch_naming_block_hash(b"hi", [0xab; 32])], + ); + assert!( + matches!( + verifier.verify_block(&other).await, + Err(CrossZoneVerifyError::Forged(_)) + ), + "the seen set must agree with the guest on what counts as a replay" + ); + } + #[tokio::test] async fn unaccepted_dispatch_does_not_poison_seen() { // A dispatch verified in a block that never applies (e.g. one that parks) diff --git a/lez/indexer/core/src/lib.rs b/lez/indexer/core/src/lib.rs index ccf8dbe7b..994203219 100644 --- a/lez/indexer/core/src/lib.rs +++ b/lez/indexer/core/src/lib.rs @@ -7,7 +7,7 @@ use chain_state::{Anchor, ChainConsistency}; use common::block::Block; // TODO: Remove after testnet use futures::StreamExt as _; -use log::{error, info, warn}; +use log::{error, warn}; use logos_blockchain_zone_sdk::{ CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, }; @@ -270,9 +270,9 @@ impl IndexerCore { let mut retry_gate = ApplyRetryGate::new(); if let Some(slot) = &cursor { - info!("Resuming indexer from cursor {slot:?}"); + log::info!("Resuming indexer from cursor {slot:?}"); } else { - info!("Starting indexer from beginning of channel"); + log::info!("Starting indexer from beginning of channel"); } loop { @@ -372,12 +372,12 @@ impl IndexerCore { verifier.record_seen(verified_keys).await; } retry_gate.reset(); - info!("Indexed L2 block {}", block.header.block_id); + log::info!("Indexed L2 block {} at channel {}", block.header.block_id, self.config.channel_id); self.set_status(IndexerSyncStatus::syncing()); yield Ok(block); } Ok(AcceptOutcome::AlreadyApplied) => { - info!( + log::info!( "Skipping already-applied block {}", block.header.block_id ); diff --git a/lez/indexer/service/src/lib.rs b/lez/indexer/service/src/lib.rs index aa142b386..24c056464 100644 --- a/lez/indexer/service/src/lib.rs +++ b/lez/indexer/service/src/lib.rs @@ -4,7 +4,7 @@ use anyhow::{Context as _, Result}; pub use indexer_core::config::*; use indexer_service_rpc::RpcServer as _; use jsonrpsee::server::{Server, ServerHandle}; -use log::{error, info}; +use log::error; use tokio_util::sync::CancellationToken; pub mod service; @@ -84,7 +84,7 @@ pub async fn run_server( .local_addr() .context("Failed to get local address of RPC server")?; - info!("Starting Indexer Service RPC server on {addr}"); + log::info!("Starting Indexer Service RPC server on {addr}"); #[cfg(not(feature = "mock-responses"))] let handle = { diff --git a/lez/indexer/service/src/main.rs b/lez/indexer/service/src/main.rs index 52f195e99..e1734e3b6 100644 --- a/lez/indexer/service/src/main.rs +++ b/lez/indexer/service/src/main.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use anyhow::Result; use clap::Parser; -use log::{error, info}; +use log::error; use tokio_util::sync::CancellationToken; #[derive(Debug, Parser)] @@ -40,14 +40,14 @@ async fn main() -> Result<()> { tokio::select! { () = cancellation_token.cancelled() => { - info!("Shutting down server..."); + log::info!("Shutting down server..."); } () = indexer_handle.stopped() => { error!("Server stopped unexpectedly"); } } - info!("Server shutdown complete"); + log::info!("Server shutdown complete"); Ok(()) } @@ -61,7 +61,7 @@ fn listen_for_shutdown_signal() -> CancellationToken { error!("Failed to listen for Ctrl-C signal: {err}"); return; } - info!("Received Ctrl-C signal"); + log::info!("Received Ctrl-C signal"); cancellation_token_clone.cancel(); }); diff --git a/lez/indexer/service/src/service.rs b/lez/indexer/service/src/service.rs index 097593624..78dcea153 100644 --- a/lez/indexer/service/src/service.rs +++ b/lez/indexer/service/src/service.rs @@ -12,7 +12,7 @@ use jsonrpsee::{ core::{Serialize, SubscriptionResult, async_trait}, types::{ErrorCode, ErrorObject, ErrorObjectOwned}, }; -use log::{debug, error, info, warn}; +use log::{debug, error, warn}; use tokio::sync::mpsc::UnboundedSender; use tokio_util::sync::CancellationToken; @@ -44,7 +44,7 @@ impl indexer_service_rpc::RpcServer for IndexerService { subscription_sink: jsonrpsee::PendingSubscriptionSink, ) -> SubscriptionResult { let sink = subscription_sink.accept().await?; - info!( + log::info!( "Accepted new subscription to finalized blocks with ID {:?}", sink.subscription_id() ); @@ -250,14 +250,14 @@ impl SubscriptionService { loop { tokio::select! { () = shutdown.cancelled() => { - info!("Shutdown requested; stopping block ingestion"); + log::info!("Shutdown requested; stopping block ingestion"); return Ok(()); } sub = sub_receiver.recv() => { let Some(subscription) = sub else { bail!("Subscription receiver closed unexpectedly"); }; - info!("Added new subscription with ID {:?}", subscription.sink.subscription_id()); + log::info!("Added new subscription with ID {:?}", subscription.sink.subscription_id()); subscribers.push(subscription); } block_opt = block_stream.next() => { @@ -332,7 +332,7 @@ impl Subscription { impl Drop for Subscription { fn drop(&mut self) { - info!( + log::info!( "Subscription with ID {:?} is being dropped", self.sink.subscription_id() ); diff --git a/lez/programs/bridge_lock/core/Cargo.toml b/lez/programs/bridge_lock/core/Cargo.toml index 190fc4f26..1251d459e 100644 --- a/lez/programs/bridge_lock/core/Cargo.toml +++ b/lez/programs/bridge_lock/core/Cargo.toml @@ -10,3 +10,6 @@ workspace = true [dependencies] lee_core.workspace = true serde = { workspace = true, features = ["alloc"] } + +[dev-dependencies] +risc0-zkvm.workspace = true diff --git a/lez/programs/bridge_lock/core/src/lib.rs b/lez/programs/bridge_lock/core/src/lib.rs index 6d2aaf495..77836140b 100644 --- a/lez/programs/bridge_lock/core/src/lib.rs +++ b/lez/programs/bridge_lock/core/src/lib.rs @@ -9,23 +9,41 @@ use lee_core::{ use serde::{Deserialize, Serialize}; const ESCROW_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockEscrow/0000/"; +const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/BridgeLockCfg/0000000/"; +/// Variants are append-only. risc0 serde encodes the variant as a bare leading +/// tag word, so inserting one ahead of `Lock` shifts every existing encoding. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Instruction { /// Lock `amount` of the holder's balance and emit a cross-zone message - /// minting the wrapped token on `target_zone`. The emission fields mirror - /// `cross_zone_outbox::Instruction::Emit` so the watcher reads them directly. + /// minting the wrapped token on `target_zone`. /// - /// Required accounts (3): holder holding (authorized), escrow PDA, outbox PDA. + /// `target_program_id` and `target_accounts` are supplied though the guest + /// accepts one value for each: `cross_zone::extract_emission` reads them off + /// the transaction, decoding every emitter through one shape. + /// + /// `target_zone` is the caller's, so a lock to a zone that will not route it + /// escrows and never mints. TODO: bound it source-side. + /// + /// Required accounts (4): config PDA, holder holding (authorized), escrow + /// PDA, outbox PDA. Lock { amount: u128, target_zone: [u8; 32], target_program_id: ProgramId, target_accounts: Vec<[u8; 32]>, payload: Vec, - outbox_program_id: ProgramId, ordinal: u32, }, + /// Pins the outbox program and the mint target, written once into a default + /// config PDA at genesis. A re-run naming different programs is refused; an + /// identical one is a no-op, which is what genesis replay does. + /// + /// Required accounts (1): the config PDA. + InitConfig { + outbox_program_id: ProgramId, + target_program_id: ProgramId, + }, } /// PDA accumulating all locked balance on this zone. @@ -39,6 +57,49 @@ pub const fn escrow_seed() -> PdaSeed { PdaSeed::new(ESCROW_SEED_DOMAIN) } +/// PDA holding the outbox program id and the mint target, seeded at genesis so +/// the guest can pin both without importing their image ids. +#[must_use] +pub fn config_account_id(bridge_lock_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&bridge_lock_id, &config_seed()) +} + +#[must_use] +pub const fn config_seed() -> PdaSeed { + PdaSeed::new(CONFIG_SEED_DOMAIN) +} + +/// Encodes the pinned outbox and mint target for the config account's data. +#[must_use] +pub fn config_bytes(outbox_program_id: ProgramId, target_program_id: ProgramId) -> [u8; 64] { + let mut bytes = [0_u8; 64]; + for (word, chunk) in outbox_program_id + .iter() + .chain(target_program_id.iter()) + .zip(bytes.chunks_exact_mut(4)) + { + chunk.copy_from_slice(&word.to_le_bytes()); + } + bytes +} + +/// Decodes the pinned outbox and mint target from the config account's data. +#[must_use] +pub fn read_config(data: &[u8]) -> Option<(ProgramId, ProgramId)> { + if data.len() < 64 { + return None; + } + let mut ids = [0_u32; 16]; + for (word, chunk) in ids.iter_mut().zip(data[..64].chunks_exact(4)) { + *word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!())); + } + let (outbox, target) = ids.split_at(8); + Some(( + outbox.try_into().unwrap_or_else(|_| unreachable!()), + target.try_into().unwrap_or_else(|_| unreachable!()), + )) +} + #[cfg(test)] mod tests { use super::*; @@ -48,4 +109,31 @@ mod tests { let id: ProgramId = [4; 8]; assert_eq!(escrow_account_id(id), escrow_account_id(id)); } + + #[test] + fn config_ids_round_trip() { + let outbox: ProgramId = [3; 8]; + let target: ProgramId = [5; 8]; + assert_eq!( + read_config(&config_bytes(outbox, target)), + Some((outbox, target)) + ); + } + + /// `extract_emission` decodes `Lock` off peer transactions, so its tag word is + /// wire format: a variant inserted ahead of it would silently shift every + /// existing encoding. + #[test] + fn lock_is_the_first_variant() { + let lock = Instruction::Lock { + amount: 1, + target_zone: [7; 32], + target_program_id: [1; 8], + target_accounts: vec![], + payload: vec![], + ordinal: 0, + }; + let words = risc0_zkvm::serde::to_vec(&lock).expect("Lock serializes"); + assert_eq!(words[0], 0); + } } diff --git a/lez/programs/bridge_lock/src/main.rs b/lez/programs/bridge_lock/src/main.rs index 8b176ee56..b7c5997e5 100644 --- a/lez/programs/bridge_lock/src/main.rs +++ b/lez/programs/bridge_lock/src/main.rs @@ -1,10 +1,16 @@ -use bridge_lock_core::{Instruction, escrow_account_id, escrow_seed}; +use bridge_lock_core::{ + Instruction, config_account_id, config_bytes, config_seed, escrow_account_id, escrow_seed, + read_config, +}; use cross_zone_outbox_core::Instruction as OutboxInstruction; use lee_core::{ - account::AccountWithMetadata, - program::{AccountPostState, ChainedCall, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, + account::{Account, AccountWithMetadata}, + program::{ + AccountPostState, ChainedCall, Claim, ProgramId, ProgramInput, ProgramOutput, + read_lee_inputs, + }, }; -use wrapped_token_core::Instruction as WrappedInstruction; +use wrapped_token_core::{Instruction as WrappedInstruction, MAX_MINT_AMOUNT}; fn main() { let ( @@ -22,20 +28,74 @@ fn main() { "bridge_lock is only invoked as a top-level user transaction" ); - let Instruction::Lock { - amount, - target_zone, - target_program_id, - target_accounts, - payload, - outbox_program_id, - ordinal, - } = instruction; + match instruction { + Instruction::Lock { + amount, + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + } => lock( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + amount, + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + ), + Instruction::InitConfig { + outbox_program_id, + target_program_id, + } => init_config( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + outbox_program_id, + target_program_id, + ), + } +} + +#[expect( + clippy::too_many_arguments, + reason = "the emission fields are passed through verbatim" +)] +fn lock( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + amount: u128, + target_zone: [u8; 32], + target_program_id: ProgramId, + target_accounts: Vec<[u8; 32]>, + payload: Vec, + ordinal: u32, +) { + // pre_states: [config PDA, holder holding (authorized), escrow PDA, outbox PDA]. + let [config, holder, escrow, outbox] = <[AccountWithMetadata; 4]>::try_from(pre_states) + .expect("Lock requires config, holder, escrow, and outbox accounts"); + + // Pinned rather than caller-named: chaining elsewhere would debit the escrow + // and leave no record of what it was for. + assert_eq!( + config.account_id, + config_account_id(self_program_id), + "first account must be the bridge-lock config PDA" + ); + let (outbox_program_id, pinned_target) = read_config(&config.account.data.clone().into_inner()) + .expect("config account holds an outbox and a mint target"); // Value conservation: the forwarded payload must mint exactly what is locked. let WrappedInstruction::Mint { + recipient, amount: mint_amount, - .. } = decode_mint(&payload) else { panic!("bridge_lock payload must be a wrapped-token mint"); @@ -45,9 +105,25 @@ fn main() { "locked amount must equal the wrapped mint amount" ); - // pre_states: [holder holding (authorized), escrow PDA, outbox PDA]. - let [holder, escrow, outbox] = <[AccountWithMetadata; 3]>::try_from(pre_states) - .expect("Lock requires holder, escrow, and outbox accounts"); + // All before the debit: nothing releases an escrow, so a message the + // destination refuses is a burn. `target_zone` is not checkable here, so a + // lock aimed at a zone that will not route it still burns. + assert_eq!( + target_program_id, pinned_target, + "bridge_lock only mints through the wrapped token it is pinned to" + ); + assert_eq!( + target_accounts, + vec![ + wrapped_token_core::config_account_id(pinned_target).into_value(), + wrapped_token_core::holding_account_id(pinned_target, &recipient).into_value(), + ], + "target accounts must be the mint's config and the recipient's holding" + ); + assert!( + amount <= MAX_MINT_AMOUNT, + "locked amount exceeds what the wrapped token will mint" + ); assert!(holder.is_authorized, "holder must authorize the lock"); // The holder holding is bridge_lock-owned, so bridge_lock may debit its native @@ -61,7 +137,7 @@ fn main() { assert_eq!( escrow.account_id, escrow_account_id(self_program_id), - "second account must be the escrow PDA" + "third account must be the escrow PDA" ); // Move the real native balance holder -> escrow. bridge_lock owns both accounts, @@ -99,12 +175,15 @@ fn main() { }, ); + let config_post = AccountPostState::new(config.account.clone()); + ProgramOutput::new( self_program_id, caller_program_id, instruction_words, - vec![holder, escrow, outbox.clone()], + vec![config, holder, escrow, outbox.clone()], vec![ + config_post, holder_post, escrow_post, AccountPostState::new(outbox.account), @@ -114,6 +193,58 @@ fn main() { .write(); } +/// Writes the outbox program and the mint target into the config PDA exactly once +/// at genesis. +fn init_config( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + outbox_program_id: ProgramId, + target_program_id: ProgramId, +) { + // pre_states: [config PDA]. + let [config] = <[AccountWithMetadata; 1]>::try_from(pre_states) + .expect("InitConfig requires the config account"); + assert_eq!( + config.account_id, + config_account_id(self_program_id), + "account must be the bridge-lock config PDA" + ); + // Init-once, idempotent under genesis replay: a `default` config is a first + // init; an already-owned one must already pin exactly these programs, since + // genesis is replayed onto seeded state during multi-sequencer reconstruction. + // `new_claimed_if_default` alone would not stop a later self-owned rewrite. + if config.account != Account::default() { + assert_eq!( + config.account.program_owner, self_program_id, + "bridge-lock config PDA is owned by another program" + ); + assert_eq!( + config.account.data.clone().into_inner(), + config_bytes(outbox_program_id, target_program_id).to_vec(), + "bridge-lock config already pins a different outbox or mint target" + ); + } + + let mut config_account = config.account.clone(); + config_account.data = config_bytes(outbox_program_id, target_program_id) + .to_vec() + .try_into() + .expect("pinned ids fit in account data"); + let config_post = + AccountPostState::new_claimed_if_default(config_account, Claim::Pda(config_seed())); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![config], + vec![config_post], + ) + .write(); +} + /// Decodes the cross-zone payload (risc0 words, little-endian bytes) into the /// wrapped-token instruction it carries. fn decode_mint(payload: &[u8]) -> WrappedInstruction { diff --git a/lez/programs/cross_zone_inbox/core/src/lib.rs b/lez/programs/cross_zone_inbox/core/src/lib.rs index b3ea8d596..9184e0342 100644 --- a/lez/programs/cross_zone_inbox/core/src/lib.rs +++ b/lez/programs/cross_zone_inbox/core/src/lib.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ @@ -7,12 +7,13 @@ use lee_core::{ }; use serde::{Deserialize, Serialize}; -/// Source blocks per seen-set shard, so no single seen account grows without bound. -pub const EPOCH_BLOCKS: u64 = 10_000; - const MESSAGE_KEY_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneMsgKey/00000/"; const INBOX_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxCfg/000/"; -const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/00/"; +/// `/01/` because `/00/` keyed shards by epoch: an epoch and a block id are +/// indistinguishable under one domain. Belt and braces, since the image id +/// already relocates every PDA in this crate whenever the crate changes. +const INBOX_SEEN_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneInboxSeen/01/"; +const SOURCE_MARKER_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneSource/00000/"; /// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. pub type ZoneId = [u8; 32]; @@ -69,6 +70,13 @@ pub struct CrossZoneConfig { pub struct CrossZoneMessage { pub src_zone: ZoneId, pub src_block_id: u64, + /// The source block's recomputed hash, never the `header.hash` it declares. + /// + /// The signature does not cover that field, so a correctly signed block can + /// carry a bogus one. Both the watcher and the verifier hash the block's + /// contents themselves and fill this from that, so the two agree on it + /// without either trusting what the peer wrote. + pub src_block_hash: [u8; 32], pub src_tx_index: u32, pub src_program_id: ProgramId, pub target_program_id: ProgramId, @@ -77,32 +85,20 @@ pub struct CrossZoneMessage { pub l1_inclusion_witness: Option>, } -/// Per-peer delivery routes, plus this inbox's own zone id. +/// This inbox's own zone id. +/// +/// It no longer decides who may deliver what. Each target program authorizes its +/// own sources against the marker the inbox passes, so the only thing the inbox +/// still needs to know is which zone it is, to refuse a message addressed to +/// itself. #[derive( Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize, )] pub struct InboxConfig { pub self_zone: ZoneId, - /// Which deliveries each peer may make. A peer absent from this map may - /// deliver nothing. - pub allowed_routes: BTreeMap>, } impl InboxConfig { - /// Whether `src_zone` may deliver from `src_program_id` to - /// `target_program_id`. A peer with no routes may deliver nothing. - #[must_use] - pub fn permits( - &self, - src_zone: &ZoneId, - src_program_id: ProgramId, - target_program_id: ProgramId, - ) -> bool { - self.allowed_routes - .get(src_zone) - .is_some_and(|routes| routes_permit(routes, src_program_id, target_program_id)) - } - /// Borsh-encoded form stored in the inbox config account. #[must_use] pub fn to_bytes(&self) -> Vec { @@ -115,12 +111,37 @@ impl InboxConfig { } } -/// The replay keys seen for one `(src_zone, epoch)` shard. +/// What one peer block has already delivered. +/// +/// Indices, not message keys: the shard's address already binds +/// `(src_zone, src_block_id)`, so a key stored inside it adds nothing. +/// +/// A shard costs an account plus a 36-byte header and breaks even against a +/// shared shard at about five deliveries. What that buys is saturation +/// resistance: at 32 bytes per delivery one peer block could overflow the +/// account, and the guest's only answer is a panic that costs the message. #[derive(Clone, Debug, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)] -pub struct SeenShard(pub BTreeSet); +pub struct SeenShard { + /// Recomputed hash of the peer block this shard records deliveries from. + /// All-zero until the first delivery claims it. + pub src_block_hash: [u8; 32], + /// Indices of that block's transactions already delivered. + pub delivered: BTreeSet, +} impl SeenShard { - /// Decodes a shard from account data; empty data is an empty shard. + /// Deliveries one shard can hold before it exceeds `DATA_MAX_LENGTH`. + /// + /// Borsh is 32 bytes of hash, a 4-byte count, then 4 bytes per index, so + /// this is exactly the 100 KiB an account may carry. + /// + /// Out of reach only because of the L1 inscription cap: a block inscribes as + /// one op near 1.75 MiB and a minimal emitting transaction is about 257 + /// bytes, capping a peer block near 7,100 deliveries. Raising that L1 cap + /// past roughly 6.3 MiB puts this back in reach. + pub const MAX_DELIVERIES: usize = 25_591; + + /// Decodes a shard from account data; empty data is an unclaimed shard. pub fn from_bytes(bytes: &[u8]) -> borsh::io::Result { if bytes.is_empty() { return Ok(Self::default()); @@ -133,14 +154,32 @@ impl SeenShard { borsh::to_vec(self).expect("SeenShard serializes") } + /// Whether a delivery from the block with this hash may be recorded here. + /// + /// An unclaimed shard binds to its first claimant. Unclaimed is the whole + /// value being default, not the hash being zero, so a shard holding any + /// delivery can never read as unclaimed. #[must_use] - pub fn contains(&self, key: &MessageKey) -> bool { - self.0.contains(key) + pub fn binds(&self, src_block_hash: &[u8; 32]) -> bool { + *self == Self::default() || self.src_block_hash == *src_block_hash } - /// Inserts a key; returns true if it was newly inserted. - pub fn insert(&mut self, key: MessageKey) -> bool { - self.0.insert(key) + #[must_use] + pub fn contains(&self, src_tx_index: u32) -> bool { + self.delivered.contains(&src_tx_index) + } + + /// Binds the shard if unclaimed and records the delivery; true if new. + /// + /// A non-binding hash records nothing. The guest already asserts + /// [`Self::binds`], so this is a backstop against a future caller rebinding + /// a claimed shard and erasing which peer block delivered what. + pub fn insert(&mut self, src_block_hash: [u8; 32], src_tx_index: u32) -> bool { + if !self.binds(&src_block_hash) { + return false; + } + self.src_block_hash = src_block_hash; + self.delivered.insert(src_tx_index) } } @@ -154,25 +193,6 @@ pub enum Instruction { InitConfig(InboxConfig), } -/// Whether `routes` authorize a delivery from `src_program_id` to -/// `target_program_id`. -/// -/// The one place the rule lives. The inbox guest decides with it and the -/// sequencer's watcher drops unroutable messages with it, and those two must -/// agree: a watcher stricter than the guest loses messages silently, and one -/// looser records deliveries the guest will refuse, which production then feeds -/// in and gives up on. -#[must_use] -pub fn routes_permit( - routes: &[CrossZoneRoute], - src_program_id: ProgramId, - target_program_id: ProgramId, -) -> bool { - routes.iter().any(|route| { - route.src_program_id == src_program_id && route.target_program_id == target_program_id - }) -} - /// Content-addressed replay key for a delivered message. /// /// Hashes `(src_zone, src_block_id, src_tx_index)` under a domain separator. @@ -207,7 +227,7 @@ pub const fn inbox_config_seed() -> PdaSeed { PdaSeed::new(INBOX_CONFIG_SEED) } -/// The seen-set shard for the `(src_zone, epoch)` the message falls in. +/// The seen-set shard for the peer block the message came from. #[must_use] pub fn inbox_seen_shard_account_id( inbox_id: ProgramId, @@ -218,15 +238,59 @@ pub fn inbox_seen_shard_account_id( } /// Seed of the seen-shard PDA, exposed so the guest can claim the account. +/// +/// One shard per peer block, so a peer cannot accumulate deliveries from many +/// blocks into one account. #[must_use] pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed { use risc0_zkvm::sha::{Impl, Sha256 as _}; - let src_epoch = src_block_id.wrapping_div(EPOCH_BLOCKS); let mut bytes = [0_u8; 72]; bytes[..32].copy_from_slice(&INBOX_SEEN_SEED_DOMAIN); bytes[32..64].copy_from_slice(src_zone); - bytes[64..].copy_from_slice(&src_epoch.to_le_bytes()); + bytes[64..].copy_from_slice(&src_block_id.to_le_bytes()); + + let seed: [u8; 32] = Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .unwrap_or_else(|_| unreachable!()); + PdaSeed::new(seed) +} + +/// The account naming who sent a delivery, which the inbox passes at position 0 +/// of the chained call so the target can authenticate its own sources. +/// +/// Nothing writes or claims it, so the state machine's uninitialized-account rule +/// skips it for being unchanged rather than for being default: anyone may send it +/// balance, and the inbox and the targets all round-trip it untouched. +/// +/// The address is derivable by anyone, so it is not a secret and not a +/// capability. What makes it mean something is that a target checks it only after +/// pinning its caller to the inbox, and only the inbox can be that caller. +#[must_use] +pub fn inbox_source_marker_account_id( + inbox_id: ProgramId, + src_zone: &ZoneId, + src_program_id: ProgramId, +) -> AccountId { + AccountId::for_public_pda( + &inbox_id, + &inbox_source_marker_seed(src_zone, src_program_id), + ) +} + +/// Seed of the source marker, exposed so a target can re-derive the address of +/// the one source it accepts and compare. +#[must_use] +pub fn inbox_source_marker_seed(src_zone: &ZoneId, src_program_id: ProgramId) -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut bytes = [0_u8; 96]; + bytes[..32].copy_from_slice(&SOURCE_MARKER_SEED_DOMAIN); + bytes[32..64].copy_from_slice(src_zone); + for (word, chunk) in src_program_id.iter().zip(bytes[64..].chunks_exact_mut(4)) { + chunk.copy_from_slice(&word.to_le_bytes()); + } let seed: [u8; 32] = Impl::hash_bytes(&bytes) .as_bytes() @@ -236,69 +300,14 @@ pub fn inbox_seen_shard_seed(src_zone: &ZoneId, src_block_id: u64) -> PdaSeed { } #[cfg(test)] mod tests { + use lee_core::account::data::DATA_MAX_LENGTH; + use super::*; fn zone(b: u8) -> ZoneId { [b; 32] } - fn program(n: u32) -> ProgramId { - [n; 8] - } - - /// The route is the pair. Two entries that are each reasonable on their own, - /// a lock program that may mint and a ping emitter that may reach a - /// receiver, must not compose into the lock program's target being - /// reachable from the ping emitter: that emitter lets its caller choose the - /// target, so it would mint with nothing locked behind it. - #[test] - fn a_route_authorizes_one_pair_and_does_not_compose() { - let lock = program(1); - let wrapped_token = program(2); - let ping_sender = program(3); - let ping_receiver = program(4); - - let mut allowed_routes = BTreeMap::new(); - allowed_routes.insert( - zone(9), - vec![ - CrossZoneRoute { - src_program_id: lock, - target_program_id: wrapped_token, - }, - CrossZoneRoute { - src_program_id: ping_sender, - target_program_id: ping_receiver, - }, - ], - ); - let config = InboxConfig { - self_zone: zone(1), - allowed_routes, - }; - - assert!(config.permits(&zone(9), lock, wrapped_token)); - assert!(config.permits(&zone(9), ping_sender, ping_receiver)); - - assert!( - !config.permits(&zone(9), ping_sender, wrapped_token), - "an emitter whose caller picks the target must not reach the bridge's target" - ); - assert!( - !config.permits(&zone(9), lock, ping_receiver), - "a route grants its own target, not every target the peer has" - ); - } - - #[test] - fn a_peer_with_no_routes_may_deliver_nothing() { - let config = InboxConfig { - self_zone: zone(1), - allowed_routes: BTreeMap::new(), - }; - assert!(!config.permits(&zone(9), program(1), program(2))); - } - #[test] fn message_key_is_stable_and_content_addressed() { assert_eq!(message_key(&zone(1), 7, 3), message_key(&zone(1), 7, 3)); @@ -308,15 +317,86 @@ mod tests { } #[test] - fn seen_shards_split_on_epoch_boundary() { + fn every_peer_block_gets_its_own_seen_shard() { let id: ProgramId = [9; 8]; assert_eq!( - inbox_seen_shard_account_id(id, &zone(1), 0), - inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1), + inbox_seen_shard_account_id(id, &zone(1), 7), + inbox_seen_shard_account_id(id, &zone(1), 7), ); assert_ne!( - inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS - 1), - inbox_seen_shard_account_id(id, &zone(1), EPOCH_BLOCKS), + inbox_seen_shard_account_id(id, &zone(1), 7), + inbox_seen_shard_account_id(id, &zone(1), 8), + ); + assert_ne!( + inbox_seen_shard_account_id(id, &zone(1), 7), + inbox_seen_shard_account_id(id, &zone(2), 7), + ); + } + + #[test] + fn a_shard_binds_to_the_first_block_that_claims_it() { + let mut shard = SeenShard::default(); + assert!(shard.binds(&[1; 32]), "an unclaimed shard binds to anyone"); + assert!(shard.binds(&[2; 32])); + + shard.insert([1; 32], 0); + assert!(shard.binds(&[1; 32]), "and to that block thereafter"); + assert!( + !shard.binds(&[2; 32]), + "a second block claiming the same block id cannot share this shard" + ); + } + + #[test] + fn a_shard_records_deliveries_by_transaction_index() { + let mut shard = SeenShard::default(); + assert!(!shard.contains(3)); + assert!(shard.insert([1; 32], 3)); + assert!(shard.contains(3)); + assert!( + !shard.insert([1; 32], 3), + "a replay of the same delivery records nothing new" + ); + assert!(shard.insert([1; 32], 4)); + } + + #[test] + fn an_unclaimed_shard_reads_as_empty_and_round_trips() { + assert_eq!( + SeenShard::from_bytes(&[]).expect("empty data decodes"), + SeenShard::default(), + "an absent account is an unclaimed shard, not a decode failure" + ); + + let mut shard = SeenShard::default(); + shard.insert([5; 32], 1); + shard.insert([5; 32], 9); + assert_eq!( + SeenShard::from_bytes(&shard.to_bytes()).expect("shard decodes"), + shard + ); + } + + #[test] + fn a_full_shard_fits_in_account_data() { + let mut shard = SeenShard::default(); + for index in 0..SeenShard::MAX_DELIVERIES { + shard.insert([5; 32], u32::try_from(index).expect("index fits")); + } + let max = usize::try_from(DATA_MAX_LENGTH.as_u64()).expect("cap fits in usize"); + assert_eq!( + shard.to_bytes().len(), + max, + "MAX_DELIVERIES is exactly what an account can carry" + ); + + shard.insert( + [5; 32], + u32::try_from(SeenShard::MAX_DELIVERIES).expect("index fits"), + ); + assert!( + shard.to_bytes().len() > max, + "and one more does not fit, so the guest would fail rather than truncate" ); } } diff --git a/lez/programs/cross_zone_inbox/src/main.rs b/lez/programs/cross_zone_inbox/src/main.rs index 4dec4478b..ff61a8bc9 100644 --- a/lez/programs/cross_zone_inbox/src/main.rs +++ b/lez/programs/cross_zone_inbox/src/main.rs @@ -1,6 +1,7 @@ use cross_zone_inbox_core::{ CrossZoneMessage, InboxConfig, Instruction, SeenShard, inbox_config_account_id, - inbox_config_seed, inbox_seen_shard_account_id, inbox_seen_shard_seed, message_key, + inbox_config_seed, inbox_seen_shard_account_id, inbox_seen_shard_seed, + inbox_source_marker_account_id, }; use lee_core::{ account::{Account, AccountWithMetadata}, @@ -61,10 +62,11 @@ fn dispatch( "l1_inclusion_witness must be None in v1" ); - // pre_states layout: [config, seen_shard, then the target accounts]. + // pre_states layout: [config, seen_shard, source marker, then the target accounts]. let mut accounts = pre_states.into_iter(); let config = accounts.next().expect("config account required"); let seen = accounts.next().expect("seen shard account required"); + let marker = accounts.next().expect("source marker account required"); let target_accounts: Vec = accounts.collect(); assert_eq!( @@ -77,6 +79,14 @@ fn dispatch( inbox_seen_shard_account_id(self_program_id, &msg.src_zone, msg.src_block_id), "Second account must be the seen-shard PDA" ); + // The one value the chained call carries about where the message came from. + // The target re-derives this address from the source it accepts, so binding it + // here is what makes a target's own check meaningful. + assert_eq!( + marker.account_id, + inbox_source_marker_account_id(self_program_id, &msg.src_zone, msg.src_program_id), + "Third account must be the source marker PDA for this message" + ); let cfg = InboxConfig::from_bytes(&config.account.data.clone().into_inner()) .expect("inbox config decodes"); @@ -85,25 +95,28 @@ fn dispatch( msg.src_zone != cfg.self_zone, "Source zone must not be this zone" ); - // Checked as a pair. The emitting program is as much a part of the - // authorization as the target: an emitter whose caller chooses the target - // reaches everything the peer may reach, so a target allowlist on its own - // lets any such emitter stand in for every other one. - assert!( - cfg.permits(&msg.src_zone, msg.src_program_id, msg.target_program_id), - "No route from this source program to this target program for this peer" - ); - - let key = message_key(&msg.src_zone, msg.src_block_id, msg.src_tx_index); let mut shard = SeenShard::from_bytes(&seen.account.data.clone().into_inner()).expect("seen shard decodes"); - let already_seen = shard.contains(&key); + + // One block id, one delivering block. The address binds the zone and block + // id but not which block claimed them, so an equivocating peer's two blocks + // at one id land here; the first binds the shard and the second aborts. + // + // Before the replay check, not after: reaching the replay branch first would + // turn a wrong-block delivery into a silent no-op, which the indexer's + // already-seen short circuit would then wave through. + assert!( + shard.binds(&msg.src_block_hash), + "Seen shard is bound to a different peer block at this block id" + ); + + let already_seen = shard.contains(msg.src_tx_index); // On replay this is a no-op: the seen shard is untouched and no call is made. let (seen_post, chained_calls) = if already_seen { (unchanged(&seen), vec![]) } else { - shard.insert(key); + shard.insert(msg.src_block_hash, msg.src_tx_index); let mut seen_account = seen.account.clone(); seen_account.data = shard .to_bytes() @@ -125,19 +138,23 @@ fn dispatch( .map(|c| u32::from_le_bytes(c.try_into().unwrap_or_else(|_| unreachable!()))) .collect(); + // The marker leads, so a target reads its source at a fixed position + // without knowing anything about the accounts that follow it. + let mut call_pre_states = vec![marker.clone()]; + call_pre_states.extend(target_accounts.clone()); let call = ChainedCall { program_id: msg.target_program_id, - pre_states: target_accounts.clone(), + pre_states: call_pre_states, instruction_data, pda_seeds: vec![], }; (seen_post, vec![call]) }; - let mut post_states = vec![unchanged(&config), seen_post]; + let mut post_states = vec![unchanged(&config), seen_post, unchanged(&marker)]; post_states.extend(target_accounts.iter().map(unchanged)); - let mut output_pre_states = vec![config, seen]; + let mut output_pre_states = vec![config, seen, marker]; output_pre_states.extend(target_accounts); ProgramOutput::new( @@ -151,8 +168,7 @@ fn dispatch( .write(); } -/// Writes the inbox config (peer + target allowlists) into the config PDA exactly -/// once at genesis. +/// Writes the inbox config into the config PDA exactly once at genesis. fn init_config( self_program_id: ProgramId, caller_program_id: Option, @@ -169,9 +185,8 @@ fn init_config( "account must be the inbox config PDA" ); // Init-once, idempotent under genesis replay: a `default` config is a first - // init; an already-owned config must already hold exactly these allowlists (the - // genesis block is replayed onto seeded state during multi-sequencer - // reconstruction), otherwise reject a post-genesis attempt to change them. + // init; an already-owned config must already hold exactly this, since genesis + // is replayed onto seeded state during multi-sequencer reconstruction. // `new_claimed_if_default` alone would not stop the owning program from // rewriting its own config data on a later call. if config_meta.account != Account::default() { @@ -182,7 +197,7 @@ fn init_config( assert_eq!( config_meta.account.data.clone().into_inner(), config.to_bytes(), - "inbox config already initialized with different allowlists" + "inbox config already initialized differently" ); } diff --git a/lez/programs/cross_zone_outbox/core/src/lib.rs b/lez/programs/cross_zone_outbox/core/src/lib.rs index 736b5fa23..66f5dc111 100644 --- a/lez/programs/cross_zone_outbox/core/src/lib.rs +++ b/lez/programs/cross_zone_outbox/core/src/lib.rs @@ -5,7 +5,11 @@ use lee_core::{ }; use serde::{Deserialize, Serialize}; -const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00000/"; +/// Versions the seed layout: bump on any change to its field list or offsets, +/// so slots under an old layout can never be re-derived. Redundant with the +/// image id, which relocates every PDA in this crate whenever the crate changes, +/// but the two version different things. +const OUTBOX_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/CrossZoneOutbox/00001/"; /// Raw 32-byte zone (channel) id; the host maps it to the zone-sdk `ChannelId`. pub type ZoneId = [u8; 32]; @@ -14,6 +18,10 @@ pub type ZoneId = [u8; 32]; pub enum Instruction { /// Records an outbound cross-zone message as a write to a self-owned PDA. /// + /// The slot is written once: a second `Emit` at the same + /// `(emitter, target_zone, ordinal)` fails the transaction rather than + /// replacing the record. + /// /// Required accounts (1): /// - Outbox PDA account Emit { @@ -28,12 +36,21 @@ pub enum Instruction { }, } -/// The message as stored in an outbox PDA. The destination zone's watcher reads -/// this from the inscribed block; the source coordinates are filled by the -/// watcher, not stored here. +/// One emitted message, as stored in its outbox PDA. +/// +/// Carries the slot it occupies as well as the message, so a reader holding the +/// bytes knows who wrote them and where without inverting the address, which is +/// a hash. The source zone and block coordinates are filled by the destination's +/// watcher and are not stored here. #[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct OutboxRecord { + /// The program that called `Emit`, which is the immediate chained caller. + /// Cross-zone discovery names the top-level program instead, so joining a + /// record against a delivery is only sound while every emitter refuses to be + /// called by another program. + pub emitter: ProgramId, pub target_zone: ZoneId, + pub ordinal: u32, pub target_program_id: ProgramId, pub target_accounts: Vec<[u8; 32]>, pub payload: Vec, @@ -52,22 +69,34 @@ impl OutboxRecord { } } -/// PDA holding one emitted message, keyed by destination zone and a per-zone -/// ordinal. +/// PDA holding one emitted message, keyed by the emitting program, the +/// destination zone, and a per-emitter per-zone ordinal. +/// +/// `emitter` is the program that called `Emit`, which the guest takes from +/// `caller_program_id` rather than from the instruction. Without it in the +/// address two programs share a slot and one overwrites the other. #[must_use] -pub fn outbox_pda(outbox_id: ProgramId, target_zone: &ZoneId, ordinal: u32) -> AccountId { - AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(target_zone, ordinal)) +pub fn outbox_pda( + outbox_id: ProgramId, + emitter: ProgramId, + target_zone: &ZoneId, + ordinal: u32, +) -> AccountId { + AccountId::for_public_pda(&outbox_id, &outbox_pda_seed(emitter, target_zone, ordinal)) } /// Seed of an outbox message PDA, exposed so the guest can claim the account. #[must_use] -pub fn outbox_pda_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed { +pub fn outbox_pda_seed(emitter: ProgramId, target_zone: &ZoneId, ordinal: u32) -> PdaSeed { use risc0_zkvm::sha::{Impl, Sha256 as _}; - let mut bytes = [0_u8; 68]; + let mut bytes = [0_u8; 100]; bytes[..32].copy_from_slice(&OUTBOX_SEED_DOMAIN); - bytes[32..64].copy_from_slice(target_zone); - bytes[64..].copy_from_slice(&ordinal.to_le_bytes()); + for (word, chunk) in emitter.iter().zip(bytes[32..64].chunks_exact_mut(4)) { + chunk.copy_from_slice(&word.to_le_bytes()); + } + bytes[64..96].copy_from_slice(target_zone); + bytes[96..].copy_from_slice(&ordinal.to_le_bytes()); let seed: [u8; 32] = Impl::hash_bytes(&bytes) .as_bytes() @@ -80,14 +109,55 @@ pub fn outbox_pda_seed(target_zone: &ZoneId, ordinal: u32) -> PdaSeed { mod tests { use super::*; + const OUTBOX: ProgramId = [3; 8]; + const EMITTER: ProgramId = [4; 8]; + #[test] fn outbox_pda_is_unique_per_zone_and_ordinal() { - let id: ProgramId = [3; 8]; let zone_a = [1; 32]; let zone_b = [2; 32]; - assert_eq!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 0)); - assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_a, 1)); - assert_ne!(outbox_pda(id, &zone_a, 0), outbox_pda(id, &zone_b, 0)); + assert_eq!( + outbox_pda(OUTBOX, EMITTER, &zone_a, 0), + outbox_pda(OUTBOX, EMITTER, &zone_a, 0) + ); + assert_ne!( + outbox_pda(OUTBOX, EMITTER, &zone_a, 0), + outbox_pda(OUTBOX, EMITTER, &zone_a, 1) + ); + assert_ne!( + outbox_pda(OUTBOX, EMITTER, &zone_a, 0), + outbox_pda(OUTBOX, EMITTER, &zone_b, 0) + ); + } + + /// Two programs emitting to the same zone and ordinal must not share a slot, + /// or the second silently overwrites the first. + #[test] + fn outbox_pda_is_unique_per_emitter() { + let zone = [1; 32]; + let other: ProgramId = [5; 8]; + + assert_ne!( + outbox_pda(OUTBOX, EMITTER, &zone, 0), + outbox_pda(OUTBOX, other, &zone, 0) + ); + } + + #[test] + fn outbox_record_round_trips() { + let record = OutboxRecord { + emitter: EMITTER, + target_zone: [1; 32], + ordinal: 7, + target_program_id: [6; 8], + target_accounts: vec![[9; 32]], + payload: b"payload".to_vec(), + }; + + assert_eq!( + OutboxRecord::from_bytes(&record.to_bytes()).expect("record decodes"), + record + ); } } diff --git a/lez/programs/cross_zone_outbox/src/main.rs b/lez/programs/cross_zone_outbox/src/main.rs index 432e8d9c9..a4b674df1 100644 --- a/lez/programs/cross_zone_outbox/src/main.rs +++ b/lez/programs/cross_zone_outbox/src/main.rs @@ -1,6 +1,6 @@ use cross_zone_outbox_core::{Instruction, OutboxRecord, outbox_pda, outbox_pda_seed}; use lee_core::{ - account::AccountWithMetadata, + account::{Account, AccountWithMetadata}, program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, }; @@ -15,10 +15,14 @@ fn main() { instruction_words, ) = read_lee_inputs::(); - assert!( - caller_program_id.is_some(), - "Outbox is only callable through a chain call from a user program" - ); + // The emitter, and the only identity here the state machine verifies: it + // checks a guest's claimed caller against the real one. Note this is the + // immediate chained caller, not the top-level program that cross-zone + // discovery names; the two coincide only while every emitter refuses to be + // called by another program, which both do today. + let Some(emitter) = caller_program_id else { + panic!("Outbox is only callable through a chain call from a user program"); + }; let (target_zone, target_program_id, target_accounts, payload, ordinal) = match instruction { Instruction::Emit { @@ -41,13 +45,32 @@ fn main() { assert_eq!( outbox.account_id, - outbox_pda(self_program_id, &target_zone, ordinal), - "Account must be the outbox PDA for (target_zone, ordinal)" + outbox_pda(self_program_id, emitter, &target_zone, ordinal), + "Account must be the outbox PDA for (emitter, target_zone, ordinal)" + ); + + // A slot holds one message for ever. Identity first, so a wrong account that + // happens to be free is reported as the wrong account rather than as a used + // slot. + // + // This is the same predicate the state machine already requires of a first + // write, so guest and host agree by construction rather than by coincidence. + // + // It also means a slot can be denied to its intended writer: the ordinal is + // caller-chosen in a namespace every user of an emitter shares, and an + // emission needs no signature, so anyone can occupy one. A client must pick + // an ordinal the chain does not already hold rather than counting from zero. + assert_eq!( + outbox.account, + Account::default(), + "Outbox slot already written: one Emit per (emitter, target_zone, ordinal)" ); let mut post_account = outbox.account.clone(); post_account.data = OutboxRecord { + emitter, target_zone, + ordinal, target_program_id, target_accounts, payload, @@ -56,9 +79,10 @@ fn main() { .try_into() .expect("OutboxRecord fits in account data"); - let post = AccountPostState::new_claimed_if_default( + // Unconditional, since the pre-state is provably default by the assert above. + let post = AccountPostState::new_claimed( post_account, - Claim::Pda(outbox_pda_seed(&target_zone, ordinal)), + Claim::Pda(outbox_pda_seed(emitter, &target_zone, ordinal)), ); ProgramOutput::new( diff --git a/lez/programs/ping_core/Cargo.toml b/lez/programs/ping_core/Cargo.toml index 29870630f..554962ca4 100644 --- a/lez/programs/ping_core/Cargo.toml +++ b/lez/programs/ping_core/Cargo.toml @@ -8,5 +8,9 @@ license = { workspace = true } workspace = true [dependencies] +borsh.workspace = true lee_core.workspace = true serde = { workspace = true, features = ["alloc"] } + +[dev-dependencies] +risc0-zkvm.workspace = true diff --git a/lez/programs/ping_core/src/lib.rs b/lez/programs/ping_core/src/lib.rs index 80b272479..672560b60 100644 --- a/lez/programs/ping_core/src/lib.rs +++ b/lez/programs/ping_core/src/lib.rs @@ -1,3 +1,4 @@ +use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ account::AccountId, program::{PdaSeed, ProgramId}, @@ -5,24 +6,79 @@ use lee_core::{ use serde::{Deserialize, Serialize}; const PING_RECORD_SEED: [u8; 32] = *b"/LEZ/v0.3/PingRecord/0000000000/"; +const SENDER_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/PingSenderCfg/0000000/"; +const RECEIVER_CONFIG_SEED: [u8; 32] = *b"/LEZ/v0.3/PingReceiverCfg/00000/"; +/// Raw 32-byte zone (channel) id, matching the inbox's. +pub type ZoneId = [u8; 32]; -/// Instruction delivered to `ping_receiver` by the inbox: record the payload. +/// Instruction to `ping_receiver`. +/// +/// Variants are append-only, for the same reason `SenderInstruction`'s are. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum ReceiverInstruction { + /// Record the payload, delivered by the inbox on behalf of a peer source + /// this receiver authorizes. + /// + /// Required accounts (3): the source marker, the receiver config PDA, then + /// the record PDA. Record { payload: Vec }, + /// Pins the deliverer and the peer sources it may deliver from, written once + /// into a default config PDA at genesis. A re-run holding anything different + /// is refused; an identical one is a no-op, which is what genesis replay does. + /// + /// Required accounts (1): the receiver config PDA. + InitConfig(ReceiverConfig), } -/// Instruction to `ping_sender`: forwarded verbatim into `cross_zone_outbox::Instruction::Emit`. +/// Who may deliver to this receiver, and which peer sources they may deliver from. +/// +/// `ping_receiver` holds nothing worth stealing, so this is not about value. It is +/// about the record meaning something: without it any program on any configured +/// peer can overwrite the record, and a delivery proves only that some peer sent +/// it. +#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize)] +pub struct ReceiverConfig { + /// The program allowed to call `Record`: the cross-zone inbox. + pub deliverer: ProgramId, + /// The `(src_zone, src_program_id)` pairs a delivery may originate from. + pub sources: Vec<(ZoneId, ProgramId)>, +} + +impl ReceiverConfig { + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("receiver config serializes") + } + + #[must_use] + pub fn from_bytes(bytes: &[u8]) -> Option { + borsh::from_slice(bytes).ok() + } +} + +/// Instruction to `ping_sender`. `Send`'s emission fields are forwarded verbatim +/// into `cross_zone_outbox::Instruction::Emit`. +/// +/// Variants are append-only. risc0 serde encodes the variant as a bare leading +/// tag word, so inserting one ahead of `Send` shifts every existing encoding. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum SenderInstruction { + /// Emit a cross-zone message through the pinned outbox. + /// + /// Required accounts (2): the sender config PDA, then the outbox PDA. Send { - outbox_program_id: ProgramId, target_zone: [u8; 32], target_program_id: ProgramId, target_accounts: Vec<[u8; 32]>, payload: Vec, ordinal: u32, }, + /// Pins the outbox program, written once into a default config PDA at + /// genesis. A re-run naming a different outbox is refused; an identical one + /// is a no-op, which is what genesis replay does. + /// + /// Required accounts (1): the sender config PDA. + InitConfig { outbox_program_id: ProgramId }, } /// The account a `ping_receiver` records the latest delivered payload into. @@ -36,3 +92,99 @@ pub fn ping_record_pda(receiver_id: ProgramId) -> AccountId { pub const fn ping_record_seed() -> PdaSeed { PdaSeed::new(PING_RECORD_SEED) } + +/// PDA holding the outbox program id, seeded at genesis so the guest can pin the +/// program it chains into without importing the outbox image id. +#[must_use] +pub fn sender_config_account_id(sender_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&sender_id, &sender_config_seed()) +} + +#[must_use] +pub const fn sender_config_seed() -> PdaSeed { + PdaSeed::new(SENDER_CONFIG_SEED) +} + +/// PDA holding the sources `ping_receiver` accepts a delivery from. +#[must_use] +pub fn receiver_config_account_id(receiver_id: ProgramId) -> AccountId { + AccountId::for_public_pda(&receiver_id, &receiver_config_seed()) +} + +#[must_use] +pub const fn receiver_config_seed() -> PdaSeed { + PdaSeed::new(RECEIVER_CONFIG_SEED) +} + +/// Encodes the pinned outbox program id for the config account's data. +#[must_use] +pub fn outbox_bytes(outbox_program_id: ProgramId) -> [u8; 32] { + let mut bytes = [0_u8; 32]; + for (word, chunk) in outbox_program_id.iter().zip(bytes.chunks_exact_mut(4)) { + chunk.copy_from_slice(&word.to_le_bytes()); + } + bytes +} + +/// Decodes the pinned outbox program id from the config account's data. +#[must_use] +pub fn read_outbox(data: &[u8]) -> Option { + if data.len() < 32 { + return None; + } + let mut outbox_program_id = [0_u32; 8]; + for (word, chunk) in outbox_program_id.iter_mut().zip(data[..32].chunks_exact(4)) { + *word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!())); + } + Some(outbox_program_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `extract_emission` decodes `Send` off peer transactions, so its tag word is + /// wire format: a variant inserted ahead of it would silently shift every + /// existing encoding. + #[test] + fn send_is_the_first_variant() { + let send = SenderInstruction::Send { + target_zone: [7; 32], + target_program_id: [1; 8], + target_accounts: vec![], + payload: vec![], + ordinal: 0, + }; + let words = risc0_zkvm::serde::to_vec(&send).expect("Send serializes"); + assert_eq!(words[0], 0); + } + + /// `Record` is serialized by the source zone into the emission payload and + /// decoded by the destination, so its tag word is wire format. + #[test] + fn record_is_the_first_variant() { + let record = ReceiverInstruction::Record { payload: vec![] }; + let words = risc0_zkvm::serde::to_vec(&record).expect("Record serializes"); + assert_eq!(words[0], 0); + } + + #[test] + fn an_empty_receiver_config_does_not_decode() { + assert_eq!(ReceiverConfig::from_bytes(&[]), None); + } + + #[test] + fn receiver_config_round_trips() { + let config = ReceiverConfig { + deliverer: [1; 8], + sources: vec![([7; 32], [9; 8])], + }; + assert_eq!(ReceiverConfig::from_bytes(&config.to_bytes()), Some(config)); + } + + #[test] + fn outbox_id_round_trips() { + let outbox: ProgramId = [9; 8]; + assert_eq!(read_outbox(&outbox_bytes(outbox)), Some(outbox)); + } +} diff --git a/lez/programs/ping_receiver/Cargo.toml b/lez/programs/ping_receiver/Cargo.toml index a1d88f399..73bbddc44 100644 --- a/lez/programs/ping_receiver/Cargo.toml +++ b/lez/programs/ping_receiver/Cargo.toml @@ -8,5 +8,6 @@ license = { workspace = true } workspace = true [dependencies] +cross_zone_inbox_core.workspace = true lee_core.workspace = true ping_core.workspace = true diff --git a/lez/programs/ping_receiver/src/main.rs b/lez/programs/ping_receiver/src/main.rs index 4fd9679fb..2c74baf25 100644 --- a/lez/programs/ping_receiver/src/main.rs +++ b/lez/programs/ping_receiver/src/main.rs @@ -1,8 +1,12 @@ +use cross_zone_inbox_core::inbox_source_marker_account_id; use lee_core::{ - account::AccountWithMetadata, - program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, + account::{Account, AccountWithMetadata}, + program::{AccountPostState, Claim, ProgramId, ProgramInput, ProgramOutput, read_lee_inputs}, +}; +use ping_core::{ + ReceiverConfig, ReceiverInstruction, ping_record_pda, ping_record_seed, + receiver_config_account_id, receiver_config_seed, }; -use ping_core::{ReceiverInstruction, ping_record_pda, ping_record_seed}; fn main() { let ( @@ -15,21 +19,61 @@ fn main() { instruction_words, ) = read_lee_inputs::(); + match instruction { + ReceiverInstruction::Record { payload } => record( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + payload, + ), + ReceiverInstruction::InitConfig(config) => init_config( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + &config, + ), + } +} + +fn record( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + payload: Vec, +) { + // pre_states: [source marker, config PDA, record PDA]. + let [marker, config, record] = <[AccountWithMetadata; 3]>::try_from(pre_states) + .expect("Record requires the source marker, config, and record accounts"); + + assert_eq!( + config.account_id, + receiver_config_account_id(self_program_id), + "Second account must be the receiver config PDA" + ); + let cfg = ReceiverConfig::from_bytes(&config.account.data.clone().into_inner()) + .expect("config account holds a receiver config"); + assert_eq!( + caller_program_id, + Some(cfg.deliverer), + "Record is only callable by the authorized deliverer (the cross-zone inbox)" + ); + // Which peer sent it is this program's own business. Without this the record + // says only that some program on some configured peer wrote it. assert!( - caller_program_id.is_some(), - "ping_receiver is only callable through a chained call" + cfg.sources.iter().any(|(src_zone, src_program_id)| { + marker.account_id + == inbox_source_marker_account_id(cfg.deliverer, src_zone, *src_program_id) + }), + "Record is only callable for a peer source this receiver authorizes" ); - let payload = match instruction { - ReceiverInstruction::Record { payload } => payload, - }; - - let [record] = <[AccountWithMetadata; 1]>::try_from(pre_states) - .expect("Record requires exactly 1 account"); assert_eq!( record.account_id, ping_record_pda(self_program_id), - "Account must be the ping record PDA" + "Third account must be the ping record PDA" ); let mut post_account = record.account.clone(); @@ -41,8 +85,70 @@ fn main() { self_program_id, caller_program_id, instruction_words, - vec![record], - vec![post], + vec![marker.clone(), config.clone(), record], + vec![ + AccountPostState::new(marker.account), + AccountPostState::new(config.account), + post, + ], + ) + .write(); +} + +/// Writes the deliverer and the authorized peer sources into the config PDA +/// exactly once at genesis. +fn init_config( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + config_value: &ReceiverConfig, +) { + assert!( + caller_program_id.is_none(), + "InitConfig is a top-level genesis transaction" + ); + + // pre_states: [config PDA]. + let [config] = <[AccountWithMetadata; 1]>::try_from(pre_states) + .expect("InitConfig requires the config account"); + assert_eq!( + config.account_id, + receiver_config_account_id(self_program_id), + "account must be the receiver config PDA" + ); + // Init-once, idempotent under genesis replay: a `default` config is a first + // init; an already-owned one must already hold exactly this, since genesis is + // replayed onto seeded state during multi-sequencer reconstruction. + // `new_claimed_if_default` alone would not stop a later self-owned rewrite. + if config.account != Account::default() { + assert_eq!( + config.account.program_owner, self_program_id, + "receiver config PDA is owned by another program" + ); + assert_eq!( + config.account.data.clone().into_inner(), + config_value.to_bytes(), + "receiver config already initialized differently" + ); + } + + let mut config_account = config.account.clone(); + config_account.data = config_value + .to_bytes() + .try_into() + .expect("receiver config fits in account data"); + let config_post = AccountPostState::new_claimed_if_default( + config_account, + Claim::Pda(receiver_config_seed()), + ); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![config], + vec![config_post], ) .write(); } diff --git a/lez/programs/ping_sender/src/main.rs b/lez/programs/ping_sender/src/main.rs index d0ad04f56..7ae0ea038 100644 --- a/lez/programs/ping_sender/src/main.rs +++ b/lez/programs/ping_sender/src/main.rs @@ -1,9 +1,14 @@ use cross_zone_outbox_core::Instruction as OutboxInstruction; use lee_core::{ - account::AccountWithMetadata, - program::{AccountPostState, ChainedCall, ProgramInput, ProgramOutput, read_lee_inputs}, + account::{Account, AccountWithMetadata}, + program::{ + AccountPostState, ChainedCall, Claim, ProgramId, ProgramInput, ProgramOutput, + read_lee_inputs, + }, +}; +use ping_core::{ + SenderInstruction, outbox_bytes, read_outbox, sender_config_account_id, sender_config_seed, }; -use ping_core::SenderInstruction; fn main() { let ( @@ -21,19 +26,63 @@ fn main() { "ping_sender is only invoked as a top-level user transaction" ); - let SenderInstruction::Send { - outbox_program_id, - target_zone, - target_program_id, - target_accounts, - payload, - ordinal, - } = instruction; + match instruction { + SenderInstruction::Send { + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + } => send( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + target_zone, + target_program_id, + target_accounts, + payload, + ordinal, + ), + SenderInstruction::InitConfig { outbox_program_id } => init_config( + self_program_id, + caller_program_id, + pre_states, + instruction_words, + outbox_program_id, + ), + } +} - // The single account is the outbox PDA the chained call writes into; the - // outbox claims it, so ping_sender forwards it unchanged. - let [outbox] = - <[AccountWithMetadata; 1]>::try_from(pre_states).expect("Send requires exactly 1 account"); +#[expect( + clippy::too_many_arguments, + reason = "the emission fields are passed through verbatim" +)] +fn send( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + target_zone: [u8; 32], + target_program_id: ProgramId, + target_accounts: Vec<[u8; 32]>, + payload: Vec, + ordinal: u32, +) { + // pre_states: [config PDA, outbox PDA]. The outbox claims its own slot, so + // ping_sender forwards it unchanged. + let [config, outbox] = <[AccountWithMetadata; 2]>::try_from(pre_states) + .expect("Send requires the config and outbox accounts"); + + // Pinned rather than caller-named: chaining elsewhere would let an emission + // skip the real outbox and leave no record of itself. + assert_eq!( + config.account_id, + sender_config_account_id(self_program_id), + "first account must be the ping-sender config PDA" + ); + let outbox_program_id = read_outbox(&config.account.data.clone().into_inner()) + .expect("config account holds an outbox program id"); let call = ChainedCall::new( outbox_program_id, @@ -47,13 +96,65 @@ fn main() { }, ); + let config_post = AccountPostState::new(config.account.clone()); + ProgramOutput::new( self_program_id, caller_program_id, instruction_words, - vec![outbox.clone()], - vec![AccountPostState::new(outbox.account)], + vec![config, outbox.clone()], + vec![config_post, AccountPostState::new(outbox.account)], ) .with_chained_calls(vec![call]) .write(); } + +/// Writes the outbox program id into the config PDA exactly once at genesis. +fn init_config( + self_program_id: ProgramId, + caller_program_id: Option, + pre_states: Vec, + instruction_words: Vec, + outbox_program_id: ProgramId, +) { + // pre_states: [config PDA]. + let [config] = <[AccountWithMetadata; 1]>::try_from(pre_states) + .expect("InitConfig requires the config account"); + assert_eq!( + config.account_id, + sender_config_account_id(self_program_id), + "account must be the ping-sender config PDA" + ); + // Init-once, idempotent under genesis replay: a `default` config is a first + // init; an already-owned one must already pin exactly this outbox, since + // genesis is replayed onto seeded state during multi-sequencer reconstruction. + // `new_claimed_if_default` alone would not stop a later self-owned rewrite. + if config.account != Account::default() { + assert_eq!( + config.account.program_owner, self_program_id, + "ping-sender config PDA is owned by another program" + ); + assert_eq!( + config.account.data.clone().into_inner(), + outbox_bytes(outbox_program_id).to_vec(), + "ping-sender config already pins a different outbox" + ); + } + + let mut config_account = config.account.clone(); + config_account.data = outbox_bytes(outbox_program_id) + .to_vec() + .try_into() + .expect("outbox id fits in account data"); + let config_post = + AccountPostState::new_claimed_if_default(config_account, Claim::Pda(sender_config_seed())); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![config], + vec![config_post], + ) + .write(); +} diff --git a/lez/programs/wrapped_token/Cargo.toml b/lez/programs/wrapped_token/Cargo.toml index a80f60fff..3ec0022d6 100644 --- a/lez/programs/wrapped_token/Cargo.toml +++ b/lez/programs/wrapped_token/Cargo.toml @@ -8,5 +8,6 @@ license = { workspace = true } workspace = true [dependencies] +cross_zone_inbox_core.workspace = true lee_core.workspace = true wrapped_token_core.workspace = true diff --git a/lez/programs/wrapped_token/core/Cargo.toml b/lez/programs/wrapped_token/core/Cargo.toml index ef0aabbc8..1791a2d3e 100644 --- a/lez/programs/wrapped_token/core/Cargo.toml +++ b/lez/programs/wrapped_token/core/Cargo.toml @@ -8,6 +8,7 @@ license = { workspace = true } workspace = true [dependencies] +borsh.workspace = true lee_core.workspace = true serde = { workspace = true, features = ["alloc"] } risc0-zkvm.workspace = true diff --git a/lez/programs/wrapped_token/core/src/lib.rs b/lez/programs/wrapped_token/core/src/lib.rs index a95a5ca0a..2fba37c88 100644 --- a/lez/programs/wrapped_token/core/src/lib.rs +++ b/lez/programs/wrapped_token/core/src/lib.rs @@ -2,29 +2,70 @@ //! cross-zone bridge. Only the cross-zone inbox may mint; the guest enforces //! this by reading the authorized minter from a genesis-seeded config account. +use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ account::AccountId, program::{PdaSeed, ProgramId}, }; use serde::{Deserialize, Serialize}; +/// The most one mint may credit. +/// +/// The peer zone chooses the amount and the balance is a `u128`, so unbounded +/// one delivery pins a holding near the maximum, every later honest mint +/// overflows into a guest panic, and the holding is bricked for inbound +/// transfers at a cost of one message. The cap does not remove that ceiling, it +/// makes reaching it cost 2^64 deliveries instead of one. +/// +/// `u64::MAX` is the bridge's bound, not one native balances obey. `bridge_lock` +/// refuses a larger amount at the source so it fails before escrowing. +pub const MAX_MINT_AMOUNT: u128 = 0xFFFF_FFFF_FFFF_FFFF; + const CONFIG_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenConfig/00/"; const HOLDING_SEED_DOMAIN: [u8; 32] = *b"/LEZ/v0.3/WrappedTokenHold/00000"; +/// Raw 32-byte zone (channel) id, matching the inbox's. +pub type ZoneId = [u8; 32]; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Instruction { /// Credit `amount` wrapped tokens to `recipient`'s holding. Delivered only by - /// the cross-zone inbox. + /// the cross-zone inbox, and only for a peer source this token authorizes. /// - /// Required accounts (2): the wrapped-token config PDA, then the recipient's - /// holding PDA. + /// Required accounts (3): the source marker, the wrapped-token config PDA, + /// then the recipient's holding PDA. Mint { recipient: [u8; 32], amount: u128 }, - /// Pins `minter` (the cross-zone inbox) as the authorized minter, written once - /// into a default config PDA at genesis. The guest refuses a non-default - /// pre-state, so it cannot be re-run to hijack the minter. + /// Pins the minter and the peer sources it may mint for, written once into a + /// default config PDA at genesis. A re-run holding anything different is + /// refused; an identical one is a no-op, which is what genesis replay does. /// /// Required accounts (1): the wrapped-token config PDA. - InitConfig { minter: ProgramId }, + InitConfig(WrappedTokenConfig), +} + +/// Who may mint, and which peer sources they may mint for. +/// +/// The source list is what makes this token authorize its own inbound value +/// rather than trusting a central route table to have done it. Borsh because the +/// list is variable length. +#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize)] +pub struct WrappedTokenConfig { + /// The program allowed to call `Mint`: the cross-zone inbox. + pub minter: ProgramId, + /// The `(src_zone, src_program_id)` pairs a mint may originate from. Empty on + /// a zone with no peers, which authorizes nothing. + pub sources: Vec<(ZoneId, ProgramId)>, +} + +impl WrappedTokenConfig { + #[must_use] + pub fn to_bytes(&self) -> Vec { + borsh::to_vec(self).expect("wrapped-token config serializes") + } + + #[must_use] + pub fn from_bytes(bytes: &[u8]) -> Option { + borsh::from_slice(bytes).ok() + } } /// PDA holding the authorized minter program id (the cross-zone inbox), seeded at @@ -59,29 +100,6 @@ pub fn holding_seed(recipient: &[u8; 32]) -> PdaSeed { PdaSeed::new(seed) } -/// Encodes the authorized minter program id for the config account's data. -#[must_use] -pub fn minter_bytes(minter: ProgramId) -> [u8; 32] { - let mut bytes = [0_u8; 32]; - for (word, chunk) in minter.iter().zip(bytes.chunks_exact_mut(4)) { - chunk.copy_from_slice(&word.to_le_bytes()); - } - bytes -} - -/// Decodes the authorized minter program id from the config account's data. -#[must_use] -pub fn read_minter(data: &[u8]) -> Option { - if data.len() < 32 { - return None; - } - let mut minter = [0_u32; 8]; - for (word, chunk) in minter.iter_mut().zip(data[..32].chunks_exact(4)) { - *word = u32::from_le_bytes(chunk.try_into().unwrap_or_else(|_| unreachable!())); - } - Some(minter) -} - /// Reads a wrapped-token balance from account data; empty data is a zero balance. #[must_use] pub fn read_balance(data: &[u8]) -> u128 { @@ -101,9 +119,34 @@ mod tests { use super::*; #[test] - fn minter_round_trips() { - let minter: ProgramId = [1, 2, 3, 4, 5, 6, 7, 8]; - assert_eq!(read_minter(&minter_bytes(minter)), Some(minter)); + fn config_round_trips() { + let config = WrappedTokenConfig { + minter: [1, 2, 3, 4, 5, 6, 7, 8], + sources: vec![([7; 32], [9; 8]), ([8; 32], [4; 8])], + }; + assert_eq!( + WrappedTokenConfig::from_bytes(&config.to_bytes()), + Some(config) + ); + } + + /// An unclaimed config reads as empty, which must not decode to a config that + /// authorizes anything. + #[test] + fn an_empty_config_does_not_decode() { + assert_eq!(WrappedTokenConfig::from_bytes(&[]), None); + } + + /// The peer's `bridge_lock` serializes `Mint` into the emission payload, so + /// its tag word is wire format. + #[test] + fn mint_is_the_first_variant() { + let mint = Instruction::Mint { + recipient: [3; 32], + amount: 1, + }; + let words = risc0_zkvm::serde::to_vec(&mint).expect("Mint serializes"); + assert_eq!(words[0], 0); } #[test] diff --git a/lez/programs/wrapped_token/src/main.rs b/lez/programs/wrapped_token/src/main.rs index 19095e393..52097b57b 100644 --- a/lez/programs/wrapped_token/src/main.rs +++ b/lez/programs/wrapped_token/src/main.rs @@ -1,10 +1,11 @@ +use cross_zone_inbox_core::inbox_source_marker_account_id; use lee_core::{ account::{Account, AccountWithMetadata}, program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}, }; use wrapped_token_core::{ - Instruction, balance_bytes, config_account_id, config_seed, holding_account_id, holding_seed, - minter_bytes, read_balance, read_minter, + Instruction, MAX_MINT_AMOUNT, WrappedTokenConfig, balance_bytes, config_account_id, + config_seed, holding_account_id, holding_seed, read_balance, }; fn main() { @@ -27,12 +28,12 @@ fn main() { recipient, amount, ), - Instruction::InitConfig { minter } => init_config( + Instruction::InitConfig(config) => init_config( self_program_id, caller_program_id, pre_states, instruction_words, - minter, + &config, ), } } @@ -45,31 +46,47 @@ fn mint( recipient: [u8; 32], amount: u128, ) { - // pre_states: [config PDA, recipient holding PDA]. - let [config, holding] = <[AccountWithMetadata; 2]>::try_from(pre_states) - .expect("Mint requires the config and recipient holding accounts"); + // pre_states: [source marker, config PDA, recipient holding PDA]. + let [marker, config, holding] = <[AccountWithMetadata; 3]>::try_from(pre_states) + .expect("Mint requires the source marker, config, and recipient holding accounts"); // The config PDA is genesis-seeded with the authorized minter (the cross-zone // inbox). Pin the caller to it, since the guest cannot import the inbox id. assert_eq!( config.account_id, config_account_id(self_program_id), - "first account must be the wrapped-token config PDA" + "second account must be the wrapped-token config PDA" ); - let minter = read_minter(&config.account.data.clone().into_inner()) - .expect("config account holds an authorized minter id"); + let cfg = WrappedTokenConfig::from_bytes(&config.account.data.clone().into_inner()) + .expect("config account holds a wrapped-token config"); assert_eq!( caller_program_id, - Some(minter), + Some(cfg.minter), "Mint is only callable by the authorized minter (the cross-zone inbox)" ); + // The inbox vouches only that the message arrived; which peer sent it is this + // token's own business, and unbacked value is what gets minted if it takes + // anyone's word for it. The marker's address is the source, so re-deriving it + // from an authorized pair is the whole check. + assert!( + cfg.sources.iter().any(|(src_zone, src_program_id)| { + marker.account_id + == inbox_source_marker_account_id(cfg.minter, src_zone, *src_program_id) + }), + "Mint is only callable for a peer source this token authorizes" + ); assert_eq!( holding.account_id, holding_account_id(self_program_id, &recipient), - "second account must be the recipient holding PDA" + "third account must be the recipient holding PDA" ); + assert!( + amount <= MAX_MINT_AMOUNT, + "mint amount exceeds the per-mint cap" + ); + // The backstop against accumulation, which the per-mint cap does not bound. let new_balance = read_balance(&holding.account.data.clone().into_inner()) .checked_add(amount) .expect("wrapped-token balance overflow"); @@ -88,19 +105,24 @@ fn mint( self_program_id, caller_program_id, instruction_words, - vec![config, holding], - vec![config_post, holding_post], + vec![marker.clone(), config, holding], + vec![ + AccountPostState::new(marker.account), + config_post, + holding_post, + ], ) .write(); } -/// Writes the authorized minter into the config PDA exactly once at genesis. +/// Writes the minter and the authorized peer sources into the config PDA exactly +/// once at genesis. fn init_config( self_program_id: lee_core::program::ProgramId, caller_program_id: Option, pre_states: Vec, instruction_words: Vec, - minter: lee_core::program::ProgramId, + config_value: &WrappedTokenConfig, ) { assert!( caller_program_id.is_none(), @@ -128,16 +150,16 @@ fn init_config( ); assert_eq!( config.account.data.clone().into_inner(), - minter_bytes(minter).to_vec(), - "wrapped-token config already initialized with a different minter" + config_value.to_bytes(), + "wrapped-token config already initialized differently" ); } let mut config_account = config.account.clone(); - config_account.data = minter_bytes(minter) - .to_vec() + config_account.data = config_value + .to_bytes() .try_into() - .expect("minter id fits in account data"); + .expect("wrapped-token config fits in account data"); let config_post = AccountPostState::new_claimed_if_default(config_account, Claim::Pda(config_seed())); diff --git a/lez/sequencer/actors/executor/Cargo.toml b/lez/sequencer/actors/executor/Cargo.toml new file mode 100644 index 000000000..969cefb99 --- /dev/null +++ b/lez/sequencer/actors/executor/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "sequencer_executor_actor" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +sequencer_core.workspace = true +common.workspace = true +lee_core.workspace = true +mempool.workspace = true +storage.workspace = true + +kameo.workspace = true +tokio.workspace = true +tokio-util.workspace = true +log.workspace = true +anyhow.workspace = true +thiserror.workspace = true +hex.workspace = true + +[dev-dependencies] +lee.workspace = true +sequencer_core = { workspace = true, features = ["mock"] } +test_programs.workspace = true + +env_logger.workspace = true +tempfile.workspace = true +bytesize.workspace = true +num-bigint.workspace = true diff --git a/lez/sequencer/actors/executor/src/actor.rs b/lez/sequencer/actors/executor/src/actor.rs new file mode 100644 index 000000000..e05c1869a --- /dev/null +++ b/lez/sequencer/actors/executor/src/actor.rs @@ -0,0 +1,336 @@ +use common::{block::Block, transaction::LeeTransaction}; +use kameo::{ + Actor, + actor::{ActorRef, WeakActorRef}, + error::ActorStopReason, + mailbox::{MailboxReceiver, Signal}, + message::{Context, Message}, +}; +use lee_core::{ + BlockId, + account::{Balance, Nonce}, +}; +use log::{info, warn}; +use mempool::MemPoolHandle; +use sequencer_core::{ + SequencerCore, TransactionOrigin, + block_publisher::{BlockPublisherTrait, Ed25519Key}, + config::SequencerConfig, + task_group::TaskGroup, +}; +use tokio::select; +use tokio_util::sync::CancellationToken; + +use crate::{ + Result, + error::Error, + protocol::{ + GetAccount, GetAccountBalance, GetAccountNonces, GetAccountReply, GetBlock, GetBlockRange, + GetChannelId, GetChannelIdReply, GetCrossZoneDeadLetters, GetCrossZoneDeadLettersReply, + GetLastBlockId, GetProofsAndRoot, GetTransaction, ProduceBlock, Transaction, + }, +}; + +// TODO: Remove `BP` once this part is moved to a separate actor +pub struct ExecutorActor { + sequencer: SequencerCore, + mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, + + // --- TODO: Remove these fields below --- + /// Cancelled when the publisher's drive task terminates (e.g. a panicked + /// persist sink); no channel events are processed past that point. + driver_cancellation: CancellationToken, + /// The core's background tasks, taken before the core was shared. This + /// handle owns no reference to the core itself, so without these there is + /// nothing to wait on: aborting the main loop only starts the teardown. + background_tasks: Vec, +} + +impl ExecutorActor { + pub async fn new(config: SequencerConfig) -> Self { + let (sequencer, mempool_handle) = SequencerCore::::start_from_config(config).await; + + let driver_cancellation = sequencer.block_publisher().driver_cancellation(); + let background_tasks = sequencer.background_tasks(); + + Self { + sequencer, + mempool_handle, + driver_cancellation, + background_tasks, + } + } + + /// Handle to the sequencer's mempool, for feeding externally-received + /// (e.g. gossiped) transactions in. + #[must_use] + pub fn mempool_handle(&self) -> MemPoolHandle<(TransactionOrigin, LeeTransaction)> { + self.mempool_handle.clone() + } +} + +impl Actor for ExecutorActor { + type Args = Self; + type Error = Error; + + async fn on_start(args: Self::Args, _actor_ref: ActorRef) -> Result { + Ok(args) + } + + #[expect( + clippy::integer_division_remainder_used, + reason = "Generated by select! macro, can't be easily rewritten to avoid this lint" + )] + async fn next( + &mut self, + _actor_ref: WeakActorRef, + mailbox_rx: &mut MailboxReceiver, + ) -> Result>> { + // TODO: Remove this please + for task in &self.background_tasks { + if task.any_finished() { + return Err(Error::BackgroundTaskFinishedUnexpectedly); + } + } + + select! { + signal = mailbox_rx.recv() => { + Ok(signal) + } + () = self.driver_cancellation.cancelled() => { + Err(Error::BlockPublisherFinishedUnexpectedly) + } + } + } + + async fn on_stop( + &mut self, + _actor_ref: WeakActorRef, + _reason: ActorStopReason, + ) -> Result<()> { + for tasks in &self.background_tasks { + tasks.shutdown().await; + } + + Ok(()) + } +} + +impl Message for ExecutorActor { + type Reply = Result<()>; + + async fn handle( + &mut self, + ProduceBlock: ProduceBlock, + _ctx: &mut Context, + ) -> Self::Reply { + // Only produce on our turn. + if !self.sequencer.is_our_turn() { + info!("Not our turn to produce a block, skipping"); + return Ok(()); + } + + // Never inscribe a second block at a height we already published: the + // channel would carry two chains from there and nothing resolves that. + // The head rewinds under us when the sdk orphans our own unfinalized + // blocks, and recovers once they finalize, so this is a wait. + if let Some(high_water) = self.sequencer.rewound_below_published() { + warn!( + "Skipping turn: head rewound to {} but block {high_water} is already inscribed; \ + waiting for the channel to restore it", + self.sequencer.next_block_height().saturating_sub(1), + ); + return Ok(()); + } + + info!("Our turn: collecting transactions from mempool, creating block"); + let id = self + .sequencer + .produce_new_block() + .await + .map_err(Error::BlockProductionFailed)?; + + let author_identity = hex::encode( + Ed25519Key::from_bytes(&self.sequencer.sequencer_config().signing_key) + .public_key() + .as_bytes(), + ); + log::info!("Block with id {id} created by {author_identity:?}"); + + Ok(()) + } +} + +impl Message for ExecutorActor { + type Reply = Result<()>; + + async fn handle( + &mut self, + Transaction { transaction }: Transaction, + _ctx: &mut Context, + ) -> Self::Reply { + self.mempool_handle + .try_push((TransactionOrigin::User, transaction)) + .map_err(|_err| Error::MempoolIsFull) + } +} + +impl Message for ExecutorActor { + type Reply = Result>; + + async fn handle( + &mut self, + GetBlock { block_id }: GetBlock, + _ctx: &mut Context, + ) -> Self::Reply { + self.sequencer + .block_store() + .get_block_at_id(block_id) + .map_err(Into::into) + } +} + +impl Message for ExecutorActor { + type Reply = Result>; + + async fn handle( + &mut self, + GetBlockRange { range }: GetBlockRange, + _ctx: &mut Context, + ) -> Self::Reply { + range + .map_while(|block_id| { + self.sequencer + .block_store() + .get_block_at_id(block_id) + .map_err(Into::into) + .transpose() + }) + .collect::>>() + } +} + +impl Message for ExecutorActor { + type Reply = Result; + + async fn handle( + &mut self, + GetLastBlockId: GetLastBlockId, + _ctx: &mut Context, + ) -> Self::Reply { + Ok(self.sequencer.chain_height()) + } +} + +impl Message for ExecutorActor { + type Reply = Balance; + + async fn handle( + &mut self, + GetAccountBalance { account_id }: GetAccountBalance, + _ctx: &mut Context, + ) -> Self::Reply { + self.sequencer + .with_state(|state| state.get_account_by_id(account_id).balance) + } +} + +impl Message for ExecutorActor { + type Reply = Option<(LeeTransaction, BlockId)>; + + async fn handle( + &mut self, + GetTransaction { tx_hash }: GetTransaction, + _ctx: &mut Context, + ) -> Self::Reply { + self.sequencer + .block_store() + .get_transaction_by_hash(tx_hash) + } +} + +impl Message for ExecutorActor { + type Reply = Vec; + + async fn handle( + &mut self, + GetAccountNonces { account_ids }: GetAccountNonces, + _ctx: &mut Context, + ) -> Self::Reply { + self.sequencer.with_state(|state| { + account_ids + .into_iter() + .map(|account_id| state.get_account_by_id(account_id).nonce) + .collect() + }) + } +} + +impl Message for ExecutorActor { + type Reply = ( + Vec>, + lee_core::CommitmentSetDigest, + ); + + async fn handle( + &mut self, + GetProofsAndRoot { commitments }: GetProofsAndRoot, + _ctx: &mut Context, + ) -> Self::Reply { + self.sequencer.with_state(|state| { + let proofs = commitments + .iter() + .map(|commitment| state.get_proof_for_commitment(commitment)) + .collect(); + (proofs, state.commitment_root()) + }) + } +} + +impl Message for ExecutorActor { + type Reply = GetAccountReply; + + async fn handle( + &mut self, + GetAccount { account_id }: GetAccount, + _ctx: &mut Context, + ) -> Self::Reply { + GetAccountReply { + account: self + .sequencer + .with_state(|state| state.get_account_by_id(account_id)), + } + } +} + +impl Message for ExecutorActor { + type Reply = GetChannelIdReply; + + async fn handle( + &mut self, + GetChannelId: GetChannelId, + _ctx: &mut Context, + ) -> Self::Reply { + GetChannelIdReply { + channel_id: *self.sequencer.block_publisher().channel_id().as_ref(), + } + } +} + +impl Message + for ExecutorActor +{ + type Reply = Result; + + async fn handle( + &mut self, + GetCrossZoneDeadLetters: GetCrossZoneDeadLetters, + _ctx: &mut Context, + ) -> Self::Reply { + let (total_retired, retained) = self.sequencer.cross_zone_dead_letters()?; + Ok(GetCrossZoneDeadLettersReply { + total_retired, + retained, + }) + } +} diff --git a/lez/sequencer/actors/executor/src/error.rs b/lez/sequencer/actors/executor/src/error.rs new file mode 100644 index 000000000..e35bda128 --- /dev/null +++ b/lez/sequencer/actors/executor/src/error.rs @@ -0,0 +1,17 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("One of the sequencer's background tasks has finished unexpectedly")] + BackgroundTaskFinishedUnexpectedly, + + #[error("The sequencer's block publisher has finished unexpectedly")] + BlockPublisherFinishedUnexpectedly, + + #[error("The mempool is full")] + MempoolIsFull, + + #[error("Storage error")] + StorageError(#[from] storage::error::DbError), + + #[error(transparent)] + BlockProductionFailed(anyhow::Error), +} diff --git a/lez/sequencer/actors/executor/src/lib.rs b/lez/sequencer/actors/executor/src/lib.rs new file mode 100644 index 000000000..c72fb5ff7 --- /dev/null +++ b/lez/sequencer/actors/executor/src/lib.rs @@ -0,0 +1,11 @@ +//! Executor Actor performs the main logic of the Sequencer. + +pub use actor::ExecutorActor; + +pub mod actor; +pub mod error; +pub mod protocol; +#[cfg(test)] +mod tests; + +pub type Result = std::result::Result; diff --git a/lez/sequencer/actors/executor/src/protocol.rs b/lez/sequencer/actors/executor/src/protocol.rs new file mode 100644 index 000000000..e4dc73ee5 --- /dev/null +++ b/lez/sequencer/actors/executor/src/protocol.rs @@ -0,0 +1,66 @@ +use std::ops::RangeInclusive; + +use common::{HashType, transaction::LeeTransaction}; +use kameo::Reply; +use lee_core::{ + BlockId, Commitment, + account::{Account, AccountId}, +}; +use sequencer_core::DeadLetterDispatchRecord; + +#[derive(Copy, Clone)] +pub struct ProduceBlock; + +pub struct Transaction { + pub transaction: LeeTransaction, +} + +pub struct GetBlock { + pub block_id: BlockId, +} + +pub struct GetBlockRange { + pub range: RangeInclusive, +} + +pub struct GetLastBlockId; + +pub struct GetAccountBalance { + pub account_id: AccountId, +} + +pub struct GetTransaction { + pub tx_hash: HashType, +} + +pub struct GetAccountNonces { + pub account_ids: Vec, +} + +pub struct GetProofsAndRoot { + pub commitments: Vec, +} + +pub struct GetAccount { + pub account_id: AccountId, +} + +#[derive(Reply)] +pub struct GetAccountReply { + pub account: Account, +} + +pub struct GetChannelId; + +#[derive(Reply)] +pub struct GetChannelIdReply { + pub channel_id: [u8; 32], +} + +pub struct GetCrossZoneDeadLetters; + +#[derive(Reply)] +pub struct GetCrossZoneDeadLettersReply { + pub total_retired: u64, + pub retained: Vec, +} diff --git a/lez/sequencer/actors/executor/src/tests.rs b/lez/sequencer/actors/executor/src/tests.rs new file mode 100644 index 000000000..07bf61292 --- /dev/null +++ b/lez/sequencer/actors/executor/src/tests.rs @@ -0,0 +1,91 @@ +use anyhow::Result; +use bytesize::ByteSize; +use common::transaction::LeeTransaction; +use kameo::{actor::Spawn as _, error::SendError}; +use lee::{ + AccountId, PrivateKey, PublicKey, PublicTransaction, + public_transaction::{Message, WitnessSet}, +}; +use num_bigint::BigUint; +use sequencer_core::{ + config::{BedrockConfig, SequencerConfig}, + mock::MockBlockPublisher, +}; +use tokio::test; + +use crate::{ExecutorActor, protocol}; + +fn sequencer_config() -> (SequencerConfig, tempfile::TempDir) { + let home = tempfile::tempdir().expect("Failed to create tmp home dir"); + + let config = SequencerConfig { + home: home.path().to_path_buf(), + max_num_tx_in_block: 10, + max_block_size: ByteSize::kib(1024), + mempool_max_size: 10, + block_create_timeout: std::time::Duration::from_secs(5), + retry_pending_blocks_timeout: std::time::Duration::from_secs(5), + signing_key: [37; 32], + bedrock_config: BedrockConfig { + channel_id: [0; 32].into(), + node_url: "http://not-used".parse().expect("Failed to parse URL"), + auth: None, + funding_key: BigUint::default().into(), + priority_fee: sequencer_core::config::default_priority_fee(), + }, + genesis: Vec::new(), + cross_zone: None, + metrics_address: None, + }; + + (config, home) +} + +fn test_transaction() -> LeeTransaction { + let key1 = PrivateKey::new_os_random(); + let key2 = PrivateKey::new_os_random(); + let acc1 = AccountId::from(&PublicKey::new_from_private_key(&key1)); + let acc2 = AccountId::from(&PublicKey::new_from_private_key(&key2)); + + let nonces = vec![0_u128.into(), 0_u128.into()]; + let instruction = 1337; + let message = Message::try_new( + test_programs::simple_balance_transfer().id(), + vec![acc1, acc2], + nonces, + instruction, + ) + .unwrap(); + + let witness_set = WitnessSet::for_message(&message, &[&key1, &key2]); + PublicTransaction::new(message, witness_set).into() +} + +#[test] +async fn handle_transaction_fails_on_full_mempool() -> Result<()> { + let _res = env_logger::try_init(); + + let (config, _home) = sequencer_config(); + let mempool_max_size = config.mempool_max_size; + let executor = ExecutorActor::spawn(ExecutorActor::::new(config).await); + + // Fill mempool + for _ in 0..mempool_max_size { + let tx = test_transaction(); + executor + .ask(protocol::Transaction { transaction: tx }) + .await?; + } + + // Now the mempool is full, the next transaction should fail + let tx = test_transaction(); + assert!(matches!( + executor + .ask(protocol::Transaction { transaction: tx }) + .await + .map_err(SendError::err), + Err(Some(crate::error::Error::MempoolIsFull)) + )); + + Ok(()) +} diff --git a/lez/sequencer/actors/rpc_server/Cargo.toml b/lez/sequencer/actors/rpc_server/Cargo.toml new file mode 100644 index 000000000..02bf9dfb3 --- /dev/null +++ b/lez/sequencer/actors/rpc_server/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "sequencer_rpc_server_actor" +version = "0.1.0" +edition = "2024" +license = { workspace = true } + +[lints] +workspace = true + +[dependencies] +lee.workspace = true +common.workspace = true +programs.workspace = true +sequencer_core.workspace = true +sequencer_service_protocol.workspace = true +sequencer_service_rpc = { workspace = true, features = ["server"] } +sequencer_rpc_server_actor_metrics = { workspace = true, features = ["record"] } +sequencer_executor_actor.workspace = true + +kameo.workspace = true +tokio.workspace = true +log.workspace = true +thiserror.workspace = true +jsonrpsee.workspace = true +borsh.workspace = true +bytesize.workspace = true diff --git a/lez/sequencer/service/metrics/Cargo.toml b/lez/sequencer/actors/rpc_server/metrics/Cargo.toml similarity index 84% rename from lez/sequencer/service/metrics/Cargo.toml rename to lez/sequencer/actors/rpc_server/metrics/Cargo.toml index 46dd2d5ac..ada599472 100644 --- a/lez/sequencer/service/metrics/Cargo.toml +++ b/lez/sequencer/actors/rpc_server/metrics/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "sequencer_service_metrics" +name = "sequencer_rpc_server_actor_metrics" version = "0.1.0" edition = "2024" license = { workspace = true } diff --git a/lez/sequencer/service/metrics/src/lib.rs b/lez/sequencer/actors/rpc_server/metrics/src/lib.rs similarity index 58% rename from lez/sequencer/service/metrics/src/lib.rs rename to lez/sequencer/actors/rpc_server/metrics/src/lib.rs index f375ff1b5..35cd7cb3b 100644 --- a/lez/sequencer/service/metrics/src/lib.rs +++ b/lez/sequencer/actors/rpc_server/metrics/src/lib.rs @@ -1,4 +1,4 @@ -//! This crate provides all metrics exposed by the sequencer service crate. +//! This crate provides all metrics exposed by RPC Server Actor. #[cfg(feature = "record")] pub use record::*; diff --git a/lez/sequencer/service/metrics/src/names.rs b/lez/sequencer/actors/rpc_server/metrics/src/names.rs similarity index 100% rename from lez/sequencer/service/metrics/src/names.rs rename to lez/sequencer/actors/rpc_server/metrics/src/names.rs diff --git a/lez/sequencer/service/metrics/src/record.rs b/lez/sequencer/actors/rpc_server/metrics/src/record.rs similarity index 100% rename from lez/sequencer/service/metrics/src/record.rs rename to lez/sequencer/actors/rpc_server/metrics/src/record.rs diff --git a/lez/sequencer/actors/rpc_server/src/actor.rs b/lez/sequencer/actors/rpc_server/src/actor.rs new file mode 100644 index 000000000..dfc8a551d --- /dev/null +++ b/lez/sequencer/actors/rpc_server/src/actor.rs @@ -0,0 +1,106 @@ +use std::net::SocketAddr; + +use bytesize::ByteSize; +use jsonrpsee::server::ServerHandle; +use kameo::{Actor, actor::ActorRef, mailbox::Signal}; +use log::info; +use sequencer_core::{block_publisher::BlockPublisherTrait, gossip::GossipTxPublisher}; +use sequencer_service_rpc::RpcServer as _; +use tokio::select; + +use crate::{Result, error::Error}; + +mod service; + +const REQUEST_BODY_MAX_SIZE: ByteSize = ByteSize::mib(10); + +pub struct RpcServerActor { + server_handle: Option, + addr: SocketAddr, +} + +impl RpcServerActor { + pub async fn new( + executor_ref: ActorRef>, + listen_addr: SocketAddr, + max_block_size: ByteSize, + gossip_tx_publisher: Option, + ) -> Result { + let server = jsonrpsee::server::ServerBuilder::with_config( + jsonrpsee::server::ServerConfigBuilder::new() + .max_request_body_size( + u32::try_from(REQUEST_BODY_MAX_SIZE.as_u64()) + .expect("REQUEST_BODY_MAX_SIZE should be less than u32::MAX"), + ) + .build(), + ) + .build(listen_addr) + .await + .map_err(Error::RpcServerSetupFailed)?; + + let addr = server + .local_addr() + .map_err(Error::LocalAddrRetrievingFailed)?; + + info!("Starting RPC Server on {addr}"); + + let service = service::Service::new(executor_ref, max_block_size, gossip_tx_publisher); + let server_handle = server.start(service.into_rpc()); + + Ok(Self { + server_handle: Some(server_handle), + addr, + }) + } + + #[must_use] + pub const fn addr(&self) -> SocketAddr { + self.addr + } +} + +impl Actor for RpcServerActor { + type Args = Self; + type Error = Error; + + async fn on_start(args: Self::Args, _actor_ref: ActorRef) -> Result { + Ok(args) + } + + #[expect( + clippy::integer_division_remainder_used, + reason = "Generated by select! macro, can't be easily rewritten to avoid this lint" + )] + async fn next( + &mut self, + _actor_ref: kameo::prelude::WeakActorRef, + mailbox_rx: &mut kameo::prelude::MailboxReceiver, + ) -> Result>> { + let handle = self + .server_handle + .clone() + .expect("Server handle should be present while actor is running"); + + select! { + signal = mailbox_rx.recv() => { + Ok(signal) + } + () = handle.stopped() => { + Err(Error::RpcServerStoppedUnexpectedly) + } + } + } + + async fn on_stop( + &mut self, + _actor_ref: kameo::prelude::WeakActorRef, + _reason: kameo::prelude::ActorStopReason, + ) -> Result<()> { + if let Some(server_handle) = self.server_handle.take() { + server_handle.stop()?; + server_handle.stopped().await; + } + + Ok(()) + } +} diff --git a/lez/sequencer/service/src/service.rs b/lez/sequencer/actors/rpc_server/src/actor/service.rs similarity index 54% rename from lez/sequencer/service/src/service.rs rename to lez/sequencer/actors/rpc_server/src/actor/service.rs index 6b83d0bab..f087c5523 100644 --- a/lez/sequencer/service/src/service.rs +++ b/lez/sequencer/actors/rpc_server/src/actor/service.rs @@ -1,42 +1,35 @@ -use std::{collections::BTreeMap, sync::Arc}; +use std::collections::BTreeMap; +use bytesize::ByteSize; use common::transaction::LeeTransaction; use jsonrpsee::{ core::async_trait, types::{ErrorCode, ErrorObjectOwned}, }; -use lee; +use kameo::actor::ActorRef; use log::{error, warn}; -use mempool::MemPoolHandle; -use sequencer_core::{ - DbError, SequencerCore, TransactionOrigin, block_publisher::BlockPublisherTrait, - gossip::network::GossipTxPublisher, -}; +use sequencer_core::{block_publisher::BlockPublisherTrait, gossip::GossipTxPublisher}; use sequencer_service_protocol::{ - Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, HashType, - MembershipProof, Nonce, ProgramId, + Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, + CrossZoneDeadLetter, CrossZoneDeadLetterReport, HashType, MembershipProof, Nonce, ProgramId, }; -use tokio::sync::Mutex; -const NOT_FOUND_ERROR_CODE: i32 = -31999; - -pub struct SequencerService { - sequencer: Arc>>, - mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, - max_block_size: u64, +pub struct Service { + executor_ref: ActorRef>, + max_block_size: ByteSize, gossip_tx_publisher: Option, } -impl SequencerService { - pub const fn new( - sequencer: Arc>>, - mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, - max_block_size: u64, +impl Service { + pub fn new( + executor_ref: ActorRef>, + max_block_size: ByteSize, gossip_tx_publisher: Option, ) -> Self { + sequencer_rpc_server_actor_metrics::init(); + Self { - sequencer, - mempool_handle, + executor_ref, max_block_size, gossip_tx_publisher, } @@ -44,11 +37,9 @@ impl SequencerService { } #[async_trait] -impl sequencer_service_rpc::RpcServer - for SequencerService -{ +impl sequencer_service_rpc::RpcServer for Service { async fn send_transaction(&self, tx: LeeTransaction) -> Result { - sequencer_service_metrics::increment_submitted_transactions_total(); + sequencer_rpc_server_actor_metrics::increment_submitted_transactions_total(); let tx_hash = tx.hash(); @@ -61,7 +52,10 @@ impl sequencer_service_rpc::Rpc let tx_size = u64::try_from(encoded_tx.len()).expect("Transaction size should fit in u64"); - let max_tx_size = self.max_block_size.saturating_sub(BLOCK_HEADER_OVERHEAD); + let max_tx_size = self + .max_block_size + .as_u64() + .saturating_sub(BLOCK_HEADER_OVERHEAD); if tx_size > max_tx_size { return Err(ErrorObjectOwned::owned( @@ -101,22 +95,23 @@ impl sequencer_service_rpc::Rpc }; let authenticated_tx = res.await.inspect_err(|err| { - sequencer_service_metrics::increment_before_mempool_failed_transactions_total(); + sequencer_rpc_server_actor_metrics::increment_before_mempool_failed_transactions_total( + ); error!("Transaction failed before reaching mempool: {err:#?}"); })?; - // Publish to the gossip mesh before the (blocking) local mempool push so - // a full mempool doesn't delay propagation. - // - // TODO: may change with actor-based mempool + // Publish to the gossip mesh before the local mempool admission so a + // full mempool doesn't delay propagation. if let Some(publisher) = &self.gossip_tx_publisher { publisher.publish(authenticated_tx.clone()); } - self.mempool_handle - .push((TransactionOrigin::User, authenticated_tx)) + self.executor_ref + .ask(sequencer_executor_actor::protocol::Transaction { + transaction: authenticated_tx, + }) .await - .expect("Mempool is closed, this is a bug"); + .map_err(internal_error)?; Ok(tx_hash) } @@ -126,11 +121,10 @@ impl sequencer_service_rpc::Rpc } async fn get_block(&self, block_id: BlockId) -> Result, ErrorObjectOwned> { - let sequencer = self.sequencer.lock().await; - sequencer - .block_store() - .get_block_at_id(block_id) - .map_err(|err| internal_error(&err)) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetBlock { block_id }) + .await + .map_err(internal_error) } async fn get_block_range( @@ -138,74 +132,64 @@ impl sequencer_service_rpc::Rpc start_block_id: BlockId, end_block_id: BlockId, ) -> Result, ErrorObjectOwned> { - let sequencer = self.sequencer.lock().await; - (start_block_id..=end_block_id) - .map(|block_id| { - let block = sequencer - .block_store() - .get_block_at_id(block_id) - .map_err(|err| internal_error(&err))?; - block.ok_or_else(|| { - ErrorObjectOwned::owned( - NOT_FOUND_ERROR_CODE, - format!("Block with id {block_id} not found"), - None::<()>, - ) - }) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetBlockRange { + range: (start_block_id..=end_block_id), }) - .collect::, _>>() + .await + .map_err(internal_error) } async fn get_last_block_id(&self) -> Result { - let sequencer = self.sequencer.lock().await; - Ok(sequencer.chain_height()) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetLastBlockId) + .await + .map_err(internal_error) } async fn get_account_balance(&self, account_id: AccountId) -> Result { - let sequencer = self.sequencer.lock().await; - let balance = sequencer.with_state(|state| state.get_account_by_id(account_id).balance); - Ok(balance) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetAccountBalance { account_id }) + .await + .map_err(internal_error) } async fn get_transaction( &self, tx_hash: HashType, ) -> Result, ErrorObjectOwned> { - let sequencer = self.sequencer.lock().await; - Ok(sequencer.block_store().get_transaction_by_hash(tx_hash)) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetTransaction { tx_hash }) + .await + .map_err(internal_error) } async fn get_accounts_nonces( &self, account_ids: Vec, ) -> Result, ErrorObjectOwned> { - let sequencer = self.sequencer.lock().await; - let nonces = sequencer.with_state(|state| { - account_ids - .into_iter() - .map(|account_id| state.get_account_by_id(account_id).nonce) - .collect() - }); - Ok(nonces) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetAccountNonces { account_ids }) + .await + .map_err(internal_error) } async fn get_proofs_and_root( &self, commitments: Vec, ) -> Result<(Vec>, CommitmentSetDigest), ErrorObjectOwned> { - let sequencer = self.sequencer.lock().await; - Ok(sequencer.with_state(|state| { - let proofs = commitments - .iter() - .map(|commitment| state.get_proof_for_commitment(commitment)) - .collect(); - (proofs, state.commitment_root()) - })) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetProofsAndRoot { commitments }) + .await + .map_err(internal_error) } async fn get_account(&self, account_id: AccountId) -> Result { - let sequencer = self.sequencer.lock().await; - Ok(sequencer.with_state(|state| state.get_account_by_id(account_id))) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetAccount { account_id }) + .await + .map(|reply| reply.account) + .map_err(internal_error) } async fn get_program_ids(&self) -> Result, ErrorObjectOwned> { @@ -226,11 +210,42 @@ impl sequencer_service_rpc::Rpc } async fn get_channel_id(&self) -> Result { - let channel_id = self.sequencer.lock().await.block_publisher().channel_id(); - Ok(ChannelId(*channel_id.as_ref())) + self.executor_ref + .ask(sequencer_executor_actor::protocol::GetChannelId) + .await + .map(|reply| ChannelId(reply.channel_id)) + .map_err(internal_error) + } + + async fn get_cross_zone_dead_letters( + &self, + ) -> Result { + let sequencer_executor_actor::protocol::GetCrossZoneDeadLettersReply { + total_retired, + retained, + } = self + .executor_ref + .ask(sequencer_executor_actor::protocol::GetCrossZoneDeadLetters) + .await + .map_err(internal_error)?; + + Ok(CrossZoneDeadLetterReport { + total_retired, + retained: retained + .into_iter() + .map(|record| CrossZoneDeadLetter { + message_key: HashType(record.message_key), + src_zone: ChannelId(record.origin.src_zone), + src_block_id: record.origin.src_block_id, + src_tx_index: record.origin.src_tx_index, + failed_attempts: record.failed_attempts, + transaction_bytes: record.transaction_bytes, + }) + .collect(), + }) } } -fn internal_error(err: &DbError) -> ErrorObjectOwned { +fn internal_error(err: impl std::fmt::Display) -> ErrorObjectOwned { ErrorObjectOwned::owned(ErrorCode::InternalError.code(), err.to_string(), None::<()>) } diff --git a/lez/sequencer/actors/rpc_server/src/error.rs b/lez/sequencer/actors/rpc_server/src/error.rs new file mode 100644 index 000000000..ceb483b89 --- /dev/null +++ b/lez/sequencer/actors/rpc_server/src/error.rs @@ -0,0 +1,14 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Failed to setup RPC server")] + RpcServerSetupFailed(#[source] std::io::Error), + + #[error("Failed to retrieve local address")] + LocalAddrRetrievingFailed(#[source] std::io::Error), + + #[error("RPC server has stopped unexpectedly")] + RpcServerStoppedUnexpectedly, + + #[error("RPC server has already been stopped")] + RpcServerAlreadyStopped(#[from] jsonrpsee::server::AlreadyStoppedError), +} diff --git a/lez/sequencer/actors/rpc_server/src/lib.rs b/lez/sequencer/actors/rpc_server/src/lib.rs new file mode 100644 index 000000000..651f66c02 --- /dev/null +++ b/lez/sequencer/actors/rpc_server/src/lib.rs @@ -0,0 +1,8 @@ +//! RPC Server Actor serves RPC queries and forwards them to Executor. + +pub use actor::RpcServerActor; + +pub mod actor; +pub mod error; + +pub type Result = std::result::Result; diff --git a/lez/sequencer/core/metrics/src/names.rs b/lez/sequencer/core/metrics/src/names.rs index 92b1595eb..980f2c8ce 100644 --- a/lez/sequencer/core/metrics/src/names.rs +++ b/lez/sequencer/core/metrics/src/names.rs @@ -7,3 +7,5 @@ pub const MEMPOOL_TRANSACTION_APPLICATION_TIME: &str = "mempool_transaction_application_time_seconds"; pub const TRANSACTIONS_PER_BLOCK: &str = "transactions_per_block"; pub const MEMPOOL_FAILED_TRANSACTIONS_TOTAL: &str = "mempool_failed_transactions_total"; +pub const CROSS_ZONE_DISPATCHES_RETIRED_TOTAL: &str = "cross_zone_dispatches_retired_total"; +pub const CROSS_ZONE_DEAD_LETTER_DISPATCHES: &str = "cross_zone_dead_letter_dispatches"; diff --git a/lez/sequencer/core/metrics/src/record.rs b/lez/sequencer/core/metrics/src/record.rs index b3848e389..2807b4564 100644 --- a/lez/sequencer/core/metrics/src/record.rs +++ b/lez/sequencer/core/metrics/src/record.rs @@ -49,6 +49,8 @@ impl From for TxKind { pub fn init() { blocks_produced_total_counter().increment(0); mempool_failed_transactions_total_counter().increment(0); + cross_zone_dispatches_retired_total_counter().increment(0); + record_cross_zone_dead_letter_dispatches(0); record_mempool_size(0); record_chain_height(0); @@ -166,3 +168,26 @@ fn mempool_failed_transactions_total_counter() -> Counter { pub fn increment_mempool_failed_transactions_total() { mempool_failed_transactions_total_counter().increment(1); } + +fn cross_zone_dispatches_retired_total_counter() -> Counter { + counter!( + description: "Cross-zone deliveries this sequencer gave up on after repeated execution failures", + unit: Unit::Count, + names::CROSS_ZONE_DISPATCHES_RETIRED_TOTAL + ) +} + +pub fn increment_cross_zone_dispatches_retired_total() { + cross_zone_dispatches_retired_total_counter().increment(1); +} + +/// Retained dead letters. A gauge, not a counter: eviction and reconciliation +/// make this fall as well as rise. +pub fn record_cross_zone_dead_letter_dispatches(count: usize) { + gauge!( + description: "Given-up-on cross-zone deliveries currently retained for inspection", + unit: Unit::Count, + names::CROSS_ZONE_DEAD_LETTER_DISPATCHES + ) + .set(u64::try_from(count).expect("Dead letter count should fit into u64") as f64); +} diff --git a/lez/sequencer/core/src/block_publisher.rs b/lez/sequencer/core/src/block_publisher.rs index ace601608..0a3591170 100644 --- a/lez/sequencer/core/src/block_publisher.rs +++ b/lez/sequencer/core/src/block_publisher.rs @@ -3,7 +3,7 @@ use std::time::Duration; use anyhow::{Context as _, Result, anyhow, ensure}; use common::block::Block; use futures::Stream; -use log::{info, warn}; +use log::warn; pub use logos_blockchain_core::mantle::{ ledger::NoteId, ops::channel::{Ed25519PublicKey, MsgId}, @@ -122,11 +122,11 @@ pub trait BlockPublisherTrait: Sized { /// /// The checkpoint must be persisted with the block — restoring an older one /// drops the inscription from the pending set, and it is never resubmitted. - async fn publish_block( - &self, - block: &Block, + fn publish_block<'blk, 'pbl: 'blk>( + &'pbl self, + block: &'blk Block, withdrawals: Vec, - ) -> Result; + ) -> impl Future> + Send + 'blk; fn channel_id(&self) -> ChannelId; @@ -251,10 +251,10 @@ impl BlockPublisherTrait for ZoneSdkPublisher { }); match &msg_result { Ok(_) if withdraw_count == 0 => { - info!("Published block with the size of {data_byte_size} bytes"); + log::info!("Published block with the size of {data_byte_size} bytes"); } Ok(_) => { - info!( + log::info!( "Published block with the size of {data_byte_size} bytes and {withdraw_count} bridge withdrawals", ); } @@ -317,7 +317,7 @@ impl BlockPublisherTrait for ZoneSdkPublisher { } Event::Ready => {} Event::TurnNotification { notification } => { - info!( + log::info!( "Turn update: our_turn={}, starting_slot={:?}, ends_at_slot={:?}", notification.our_turn_to_write, notification.starting_slot, @@ -350,9 +350,9 @@ impl BlockPublisherTrait for ZoneSdkPublisher { }) } - async fn publish_block( - &self, - block: &Block, + async fn publish_block<'blk, 'pbl: 'blk>( + &'pbl self, + block: &'blk Block, withdrawals: Vec, ) -> Result { let data = borsh::to_vec(block).context("Failed to serialize block")?; diff --git a/lez/sequencer/core/src/block_store.rs b/lez/sequencer/core/src/block_store.rs index b11ed3e46..76e3fcfe0 100644 --- a/lez/sequencer/core/src/block_store.rs +++ b/lez/sequencer/core/src/block_store.rs @@ -8,7 +8,6 @@ use common::{ }; use lee::V03State; use lee_core::BlockId; -use log::info; use logos_blockchain_zone_sdk::{Slot, sequencer::SequencerCheckpoint}; use storage::sequencer::{ RocksDBIO, @@ -75,7 +74,7 @@ impl SequencerStore { let mut tx_hash_to_block_map = HashMap::new(); if let Some(last_id) = last_id { - info!("Preparing block cache"); + log::info!("Preparing block cache"); for i in genesis_id..=last_id { let block = dbio .get_block(i)? @@ -83,7 +82,7 @@ impl SequencerStore { tx_hash_to_block_map.extend(block_to_transactions_map(&block)); } - info!( + log::info!( "Block cache prepared. Total blocks in cache: {}", tx_hash_to_block_map.len() ); diff --git a/lez/sequencer/core/src/config.rs b/lez/sequencer/core/src/config.rs index a71540adc..c32fcebe4 100644 --- a/lez/sequencer/core/src/config.rs +++ b/lez/sequencer/core/src/config.rs @@ -37,7 +37,7 @@ pub enum GenesisAction { /// Sequencer p2p gossip configuration. Absent (`None`) disables gossip /// entirely: no sockets, no background tasks. -#[derive(Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct GossipConfig { /// Multiaddr to listen on. #[serde(default = "default_gossip_listen_addr")] @@ -48,7 +48,7 @@ pub struct GossipConfig { } // TODO: Provide default values -#[derive(Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct SequencerConfig { /// Home dir of sequencer storage. pub home: PathBuf, @@ -84,7 +84,7 @@ pub struct SequencerConfig { pub gossip: Option, } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct BedrockConfig { /// Bedrock channel ID. pub channel_id: ChannelId, @@ -108,6 +108,16 @@ impl SequencerConfig { Ok(serde_json::from_reader(reader)?) } + + /// Where this sequencer's database lives, suffixed with the channel id like + /// the indexer's, so several sequencers can share a home directory. Only the + /// database is per-channel; `bedrock_signing_key` stays unsuffixed, so + /// sequencers sharing a home share one Bedrock identity. + #[must_use] + pub fn db_path(&self) -> PathBuf { + self.home + .join(format!("rocksdb-{}", self.bedrock_config.channel_id)) + } } const fn default_max_block_size() -> ByteSize { diff --git a/lez/sequencer/core/src/cross_zone_watcher.rs b/lez/sequencer/core/src/cross_zone_watcher.rs index e21641a6a..9a910df10 100644 --- a/lez/sequencer/core/src/cross_zone_watcher.rs +++ b/lez/sequencer/core/src/cross_zone_watcher.rs @@ -1,11 +1,13 @@ use std::{sync::Arc, time::Duration}; use common::{HashType, block::Block, transaction::LeeTransaction}; -use cross_zone::{build_dispatch_from_emission, extract_emission}; -use cross_zone_inbox_core::{CrossZoneRoute, message_key, routes_permit}; +use cross_zone::{ + EmissionSource, build_dispatch_from_emission, extract_emission, is_sequencer_only_program, +}; +use cross_zone_inbox_core::message_key; use futures::{Stream, StreamExt as _}; use lee::{GENESIS_BLOCK_ID, PublicKey}; -use log::{debug, error, info, warn}; +use log::{debug, error, warn}; use logos_blockchain_core::mantle::ops::channel::ChannelId; use logos_blockchain_zone_sdk::{ CommonHttpClient, Slot, ZoneMessage, adapter::NodeHttpClient, indexer::ZoneIndexer, @@ -36,7 +38,6 @@ const STUCK_SLOT_ALERT_PASSES: u32 = 20; struct PeerContext { peer_zone: [u8; 32], self_zone: [u8; 32], - allowed_routes: Vec, expected_pubkey: Option, } @@ -280,7 +281,6 @@ pub fn spawn_watchers( PeerContext { peer_zone: peer.channel_id, self_zone, - allowed_routes: peer.allowed_routes, expected_pubkey, }, poll_interval, @@ -302,7 +302,7 @@ async fn watch_peer( dbio: Arc, ) { let peer_zone = peer.peer_zone; - info!( + log::info!( "Cross-zone watcher started for peer {}", hex::encode(peer_zone) ); @@ -356,7 +356,7 @@ async fn watch_peer( } let mut cursor = resume.cursor; if let Some(slot) = cursor { - info!( + log::info!( "Resuming watcher for peer {} from slot {slot:?}", hex::encode(peer_zone) ); @@ -468,7 +468,7 @@ where ); } Link::Next(block_hash) => { - if !record_block_deliveries(&block, peer, dbio) { + if !record_block_deliveries(&block, block_hash, peer, dbio) { // Recording a delivery is what makes it survive the // mempool. Letting the pass finish here would move // the floor past this slot on a store that just @@ -545,10 +545,17 @@ fn advance_cursor(dbio: &RocksDBIO, peer_zone: [u8; 32], cursor: &mut Option bool { +/// +/// `block_hash` is the value [`link_against`] recomputed from the block's own +/// contents, not `block.header.hash`, which the signature does not cover. +fn record_block_deliveries( + block: &Block, + block_hash: HashType, + peer: &PeerContext, + dbio: &RocksDBIO, +) -> bool { let peer_zone = peer.peer_zone; let self_zone = peer.self_zone; - let allowed_routes = peer.allowed_routes.as_slice(); // Collected and written once. The pending list is a single value, so a write // per delivery would rewrite the whole list once per message, which is // quadratic in a peer block that carries many of them, on a task holding the @@ -566,16 +573,15 @@ fn record_block_deliveries(block: &Block, peer: &PeerContext, dbio: &RocksDBIO) if emission.target_zone != self_zone { continue; } - // Mirrors the inbox guest, which is the authority. Dropping here keeps - // an unroutable message from becoming a record that production would - // feed in and give up on three blocks later. - if !routes_permit( - allowed_routes, - message.program_id, - emission.target_program_id, - ) { + // Targets authorize their own sources now, so this is not authorization, + // it is hygiene: a delivery the zone will certainly refuse still costs a + // pending-list slot and three execution attempts before it is dead + // lettered. Kept host-side only, never in `extract_emission` or the + // verifier's re-derivation, where a check that depends on this build would + // make the two disagree and halt ingestion. + if is_sequencer_only_program(emission.target_program_id) { warn!( - "Watcher dropping message from peer {}: no route from that source program to that target", + "Watcher dropping message from peer {}: a peer may not dispatch into a sequencer-only program", hex::encode(peer_zone) ); continue; @@ -583,10 +589,13 @@ fn record_block_deliveries(block: &Block, peer: &PeerContext, dbio: &RocksDBIO) let src_tx_index = u32::try_from(index).unwrap_or(u32::MAX); let dispatch = build_dispatch_from_emission( - peer_zone, - block.header.block_id, - src_tx_index, - message.program_id, + &EmissionSource { + src_zone: peer_zone, + src_block_id: block.header.block_id, + src_block_hash: block_hash.0, + src_tx_index, + src_program_id: message.program_id, + }, emission.target_program_id, &emission.target_accounts, emission.payload, @@ -618,7 +627,7 @@ fn record_block_deliveries(block: &Block, peer: &PeerContext, dbio: &RocksDBIO) // the slot stays stuck. Ok(accepted) => { if accepted > 0 { - info!( + log::info!( "Watcher recorded {accepted} of {offered} cross-zone deliveries from peer {} block {}", hex::encode(peer_zone), block.header.block_id @@ -656,7 +665,7 @@ mod tests { }; use logos_blockchain_core::mantle::ops::channel::{MsgId, inscribe::Inscription}; use logos_blockchain_zone_sdk::ZoneBlock; - use ping_core::{SenderInstruction, ping_record_pda}; + use ping_core::{SenderInstruction, ping_record_pda, receiver_config_account_id}; use storage::sequencer::{DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY, RocksDBIO}; use tempfile::TempDir; @@ -669,10 +678,6 @@ mod tests { PeerContext { peer_zone: PEER_ZONE, self_zone: SELF_ZONE, - allowed_routes: vec![CrossZoneRoute { - src_program_id: programs::ping_sender().id(), - target_program_id: programs::ping_receiver().id(), - }], expected_pubkey: None, } } @@ -696,10 +701,12 @@ mod tests { fn emission_to(target_program_id: lee_core::program::ProgramId) -> LeeTransaction { let receiver_id = programs::ping_receiver().id(); let send = SenderInstruction::Send { - outbox_program_id: programs::cross_zone_outbox().id(), target_zone: SELF_ZONE, target_program_id, - target_accounts: vec![ping_record_pda(receiver_id).into_value()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload: b"hi".to_vec(), ordinal: 0, }; @@ -1069,13 +1076,49 @@ mod tests { } #[tokio::test] - async fn a_delivery_with_no_route_is_never_recorded() { - // The peer is routed to ping_receiver only. A bridging zone would also - // route its lock program to wrapped_token, and `ping_sender` lets its - // caller name wrapped_token as the target, so without the pair check - // this emission would be recorded and delivered, minting with nothing - // locked behind it. The guest rejects it too; dropping here keeps it - // from becoming a record production feeds in and gives up on. + async fn a_delivery_into_a_sequencer_only_program_is_never_recorded() { + // Targets authorize their own sources, so the watcher no longer decides + // who may reach what. It still refuses to queue a delivery the zone will + // certainly refuse: the inbox is injected by this node alone, so a peer + // naming it as a target is junk that would cost a pending slot and three + // execution attempts. + let (_dir, dbio) = store(); + let mut cursor = None; + let mut tip = None; + + let outcome = consume_peer_stream( + stream::iter(vec![peer_block_msg_to( + 1, + 0, + programs::cross_zone_inbox().id(), + )]), + &peer_context(), + &dbio, + &mut cursor, + &mut tip, + ) + .await; + + assert_eq!( + outcome, + PassOutcome::Drained, + "a message the watcher drops is not a failure" + ); + assert!( + recorded_keys(&dbio).is_empty(), + "a message aimed at a sequencer-only program must not be recorded" + ); + assert_eq!( + get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(), + Some(Slot::from(0)), + "the slot was fully read, so the floor still advances" + ); + } + + #[tokio::test] + async fn a_delivery_to_an_unrelated_target_is_still_recorded() { + // The watcher is not the authorization point any more. A target it knows + // nothing about is recorded and delivered, and that target decides. let (_dir, dbio) = store(); let mut cursor = None; let mut tip = None; @@ -1093,19 +1136,11 @@ mod tests { ) .await; + assert_eq!(outcome, PassOutcome::Drained); assert_eq!( - outcome, - PassOutcome::Drained, - "an unroutable message is not a failure" - ); - assert!( - recorded_keys(&dbio).is_empty(), - "a message with no route must not be recorded" - ); - assert_eq!( - get_cross_zone_peer_floor(&dbio, PEER_ZONE).unwrap(), - Some(Slot::from(0)), - "the slot was fully read, so the floor still advances" + recorded_keys(&dbio).len(), + 1, + "the watcher records it and lets the target refuse it" ); } @@ -1145,6 +1180,42 @@ mod tests { ); } + #[tokio::test] + async fn a_recorded_delivery_names_the_hash_the_watcher_validated() { + let (_dir, dbio) = store(); + let mut cursor = None; + let mut tip = None; + + consume_peer_stream( + stream::iter(vec![peer_block_msg(1, 0)]), + &peer_context(), + &dbio, + &mut cursor, + &mut tip, + ) + .await; + + let records = dbio.get_pending_cross_zone_dispatches().unwrap(); + assert_eq!(records.len(), 1, "the delivery must be recorded"); + let tx = borsh::from_slice::(&records[0].transaction).unwrap(); + let LeeTransaction::Public(public_tx) = tx else { + panic!("a dispatch is a public transaction"); + }; + let Ok(cross_zone_inbox_core::Instruction::Dispatch(msg)) = + risc0_zkvm::serde::from_slice(&public_tx.message().instruction_data) + else { + panic!("the recorded transaction is an inbox dispatch"); + }; + + // The indexer recomputes this independently when it re-derives the same + // transaction; a different block here is what makes the two disagree. + assert_eq!( + msg.src_block_hash, + chain_block(1).recompute_hash().0, + "the delivery names the block the watcher read it from" + ); + } + #[tokio::test] async fn a_delivery_that_cannot_be_recorded_holds_the_floor() { let (_dir, dbio) = store(); diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index 627634502..6d479ef04 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -21,7 +21,7 @@ use futures::StreamExt as _; use itertools::Itertools as _; use lee::{AccountId, PublicTransaction, public_transaction::Message}; use lee_core::GENESIS_BLOCK_ID; -use log::{debug, error, info, warn}; +use log::{debug, error, warn}; use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SIZE, Ed25519Key}; use logos_blockchain_zone_sdk::{ Slot, ZoneMessage, @@ -32,18 +32,21 @@ use mempool::{MemPool, MemPoolHandle}; pub use mock::SequencerCoreWithMockClients; use num_bigint::BigUint; pub use storage::error::DbError; +// Re-exported because `cross_zone_dead_letters` returns it and the service +// crate does not depend on `storage`, so it could not otherwise name the type. +pub use storage::sequencer::sequencer_cells::DeadLetterDispatchRecord; use storage::sequencer::{ - RocksDBIO, StoreUpdate, + DispatchFailure, RocksDBIO, StoreUpdate, sequencer_cells::{ - PendingCrossZoneDispatchRecord, PendingDepositEventRecord, WithdrawalReconciliationKey, - ZoneAnchorRecord, + DispatchOrigin, PendingCrossZoneDispatchRecord, PendingDepositEventRecord, + WithdrawalReconciliationKey, ZoneAnchorRecord, }, }; use crate::{ block_publisher::{BlockPublisherTrait, MsgId, NoteId, ZoneSdkPublisher}, block_store::SequencerStore, - task_group::{StoreRelease, TaskGroup}, + task_group::TaskGroup, }; pub mod block_publisher; @@ -120,7 +123,7 @@ impl SequencerCore { /// initializing its state with the accounts defined in the configuration file. fn open_or_create_store(config: &SequencerConfig) -> (SequencerStore, lee::V03State) { let signing_key = lee::PrivateKey::try_new(config.signing_key).unwrap(); - let db_path = config.home.join("rocksdb"); + let db_path = config.db_path(); if db_path.exists() { let store = SequencerStore::open_db(&db_path, signing_key).unwrap_or_else(|err| { @@ -134,6 +137,14 @@ impl SequencerCore { .expect("Failed to read state from store"); (store, state) } else { + let legacy = config.home.join("rocksdb"); + if legacy.exists() { + warn!( + "Ignoring pre-channel-suffix database at {}; rename it to {} to resume it", + legacy.display(), + db_path.display() + ); + } warn!( "Database not found at {}, starting from genesis", db_path.display() @@ -216,7 +227,7 @@ impl SequencerCore { let bedrock_signing_key = load_or_create_signing_key(&config.home.join("bedrock_signing_key")) .expect("Failed to load or create bedrock signing key"); - info!( + log::info!( "Bedrock signing public key: {}", hex::encode(bedrock_signing_key.public_key().to_bytes()) ); @@ -337,6 +348,7 @@ impl SequencerCore { }; sequencer_core_metrics::record_chain_height(sequencer_core.chain_height()); + record_dead_letter_gauge(&sequencer_core.store.dbio()); (sequencer_core, mempool_handle) } @@ -737,7 +749,7 @@ impl SequencerCore { } } - info!("Validated transaction with hash {tx_hash}, including it in block"); + log::info!("Validated transaction with hash {tx_hash}, including it in block"); true } @@ -811,18 +823,22 @@ impl SequencerCore { (prev, height, chain.head_state().clone(), pending) }; - if !settled.is_empty() - && let Err(err) = self + if !settled.is_empty() { + if let Err(err) = self .store .dbio() .drop_settled_cross_zone_dispatches(&settled) - { - // Only bookkeeping: the deliveries themselves are irreversible, and - // the next turn tries again. - warn!( - "Failed to drop {} settled delivery record(s): {err:#}", - settled.len() - ); + { + // Only bookkeeping: the deliveries themselves are irreversible, + // and the next turn tries again. + warn!( + "Failed to drop {} settled delivery record(s): {err:#}", + settled.len() + ); + } + // A settled delivery may be one this node had given up on, which + // takes its dead letter with it. + record_dead_letter_gauge(&self.store.dbio()); } let mut valid_transactions = Vec::new(); @@ -1034,7 +1050,7 @@ impl SequencerCore { // TODO: Delete blocks instead of marking them as finalized. Current // approach is used because we still have `GetBlockDataRequest`. pub fn clean_finalized_blocks_from_db(&self, last_finalized_block_id: u64) -> Result<()> { - info!("Clearing pending blocks up to id: {last_finalized_block_id}"); + log::info!("Clearing pending blocks up to id: {last_finalized_block_id}"); self.store .dbio() .clean_pending_blocks_up_to(last_finalized_block_id)?; @@ -1091,8 +1107,8 @@ impl SequencerCore { /// A delivery's payload and target accounts are chosen on the peer zone and /// validated by nobody in between, so one can fail for good; but a failure /// can equally be a property of the moment, so give up only after several. - /// Giving up drops the record, which is also what keeps a peer from growing - /// the pending list with deliveries that can never execute. + /// Giving up moves the record to the dead letter: a peer cannot grow the + /// pending list with deliveries that never execute, and it stays findable. fn count_dispatch_failure(&self, tx: &LeeTransaction) { let Some(message) = extract_cross_zone_dispatch(tx) else { return; @@ -1102,17 +1118,37 @@ impl SequencerCore { message.src_block_id, message.src_tx_index, ); + let origin = DispatchOrigin { + src_zone: message.src_zone, + src_block_id: message.src_block_id, + src_tx_index: message.src_tx_index, + }; match self .store .dbio() - .record_dispatch_failure(key, RETIRE_DISPATCH_AFTER_FAILURES) + .record_dispatch_failure(key, RETIRE_DISPATCH_AFTER_FAILURES, origin) { - Ok(true) => error!( - "Giving up on cross-zone delivery {} after {RETIRE_DISPATCH_AFTER_FAILURES} failed attempts; it will not be retried", + Ok(DispatchFailure::Retired(record)) => { + sequencer_core_metrics::increment_cross_zone_dispatches_retired_total(); + record_dead_letter_gauge(&self.store.dbio()); + error!( + "Giving up on cross-zone delivery {} from peer zone {} block {} transaction {} ({} bytes) after {} failed attempts. This node will not retry it; unless another sequencer carries it, the message is not delivered. Kept in the dead letter.", + hex::encode(key), + hex::encode(origin.src_zone), + origin.src_block_id, + origin.src_tx_index, + record.transaction_bytes, + record.failed_attempts + ); + } + Ok(DispatchFailure::Retried { failed_attempts }) => warn!( + "Cross-zone delivery {} failed to execute ({failed_attempts} of {RETIRE_DISPATCH_AFTER_FAILURES} attempts), will retry next block", hex::encode(key) ), - Ok(false) => warn!( - "Cross-zone delivery {} failed to execute, will retry next block", + // Not a give-up: the ordinary case is a delivery that already + // settled, so its record is gone and there is nothing left to lose. + Ok(DispatchFailure::Absent) => debug!( + "Cross-zone delivery {} failed to execute but has no pending record; nothing to count", hex::encode(key) ), Err(err) => error!( @@ -1122,11 +1158,16 @@ impl SequencerCore { } } - /// A weak reference to this sequencer's store, for a shutdown path that - /// needs to observe the database actually closing rather than infer it. - #[must_use] - pub fn store_release(&self) -> StoreRelease { - StoreRelease::new(&self.store.dbio()) + /// The deliveries this node has given up on, and how many times it has. + /// + /// Retained is read first so the pair can only skew towards a total that + /// leads its list, an ordinary evicted or settled state. The other order + /// would report entries against a total of zero. + pub fn cross_zone_dead_letters(&self) -> Result<(u64, Vec), DbError> { + let dbio = self.store.dbio(); + let retained = dbio.get_dead_letter_cross_zone_dispatches()?; + let total = dbio.get_dead_letter_cross_zone_dispatch_count()?; + Ok((total, retained)) } /// Every background task that holds this sequencer's store handle. @@ -1205,10 +1246,14 @@ fn deposit_already_minted(state: &lee::V03State, deposit_op_id: HashType) -> boo /// Whether a cross-zone delivery is already on the chain we are building on. /// -/// The inbox records every delivered message key in a seen shard and no-ops a -/// replay, so that shard is the same kind of answer the deposit receipt gives: -/// state, not bookkeeping. An orphan reverts the entry with the block, so the -/// next turn re-delivers with nothing of ours to unwind. +/// The inbox records each peer block's delivered indices in that block's seen +/// shard and no-ops a replay, so the shard is the same kind of answer the +/// deposit receipt gives: state, not bookkeeping. An orphan reverts the entry +/// with the block, so the next turn re-delivers with nothing to unwind. +/// +/// Both halves matter. A shard bound to a different peer block is not this +/// delivery's replay record, it is what will make it abort, and calling that +/// delivered would drop the record instead of dead-lettering it. fn dispatch_already_delivered(state: &lee::V03State, message: &CrossZoneMessage) -> bool { let shard_id = cross_zone_inbox_core::inbox_seen_shard_account_id( programs::cross_zone_inbox().id(), @@ -1217,15 +1262,27 @@ fn dispatch_already_delivered(state: &lee::V03State, message: &CrossZoneMessage) ); state.get_account_by_id_ref(shard_id).is_some_and(|shard| { cross_zone_inbox_core::SeenShard::from_bytes(shard.data.as_ref()).is_ok_and(|seen| { - seen.contains(&cross_zone_inbox_core::message_key( - &message.src_zone, - message.src_block_id, - message.src_tx_index, - )) + seen.binds(&message.src_block_hash) && seen.contains(message.src_tx_index) }) }) } +/// Publishes how many given-up-on deliveries are retained. +/// +/// Read from the store because the list falls as well as rises (eviction, and +/// reconciliation when a delivery settles elsewhere). Costs a read and a decode, +/// so call it only where one of those can have happened. +fn record_dead_letter_gauge(dbio: &RocksDBIO) { + match dbio.get_dead_letter_cross_zone_dispatches() { + Ok(records) => { + sequencer_core_metrics::record_cross_zone_dead_letter_dispatches(records.len()); + } + Err(err) => { + warn!("Failed to read the cross-zone dead letter for its gauge: {err:#}"); + } + } +} + /// Feed one channel delta into the follow state and mirror it to the store: /// revert orphaned, then apply and persist adopted and finalized blocks. /// Production builds on this same head. Wired to the publisher via @@ -1416,9 +1473,12 @@ fn apply_follow_update( }; sequencer_core_metrics::record_chain_height(head_height); + // The runtime reconcile path: finalizing another sequencer's block drops the + // dead letter of a delivery this node gave up on. + record_dead_letter_gauge(dbio); if outcome.accepted_deposits > 0 { - info!( + log::info!( "Recorded {} Bedrock Deposit event(s); their mints are drained from the store on our next turn", outcome.accepted_deposits ); @@ -1474,22 +1534,30 @@ fn build_genesis_state(config: &SequencerConfig) -> (lee::V03State, Vec (lee::V03State, Vec( + &'pbl self, + block: &'blk Block, withdrawals: Vec, ) -> Result { // Deterministic per-block id so head dedup behaves in tests. diff --git a/lez/sequencer/core/src/task_group.rs b/lez/sequencer/core/src/task_group.rs index 8572a62fb..578b6f1b7 100644 --- a/lez/sequencer/core/src/task_group.rs +++ b/lez/sequencer/core/src/task_group.rs @@ -1,9 +1,8 @@ //! A set of background tasks that can be stopped and waited on. -use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use log::warn; -use storage::sequencer::RocksDBIO; use tokio::task::JoinHandle; /// Background tasks owned by one component, stoppable on demand and stopped @@ -25,27 +24,6 @@ pub struct TaskGroup(Arc); #[derive(Default)] struct TaskGroupInner(Mutex>>); -/// A weak handle to the store, for observing when it is finally closed. -/// -/// Every strong reference lives inside a task or a server that shutdown stops, -/// but the last drop runs on whichever thread owned it, not on the one awaiting -/// shutdown. Watching the count is the difference between knowing the database -/// file is closed and assuming it from another crate's drop order. -pub struct StoreRelease(Weak); - -impl StoreRelease { - #[must_use] - pub fn new(store: &Arc) -> Self { - Self(Arc::downgrade(store)) - } - - /// How many holders are left. Zero means the store is closed. - #[must_use] - pub fn holders(&self) -> usize { - self.0.strong_count() - } -} - impl Drop for TaskGroupInner { fn drop(&mut self) { for task in Self::take(&self.0) { diff --git a/lez/sequencer/core/src/tests.rs b/lez/sequencer/core/src/tests.rs index 2eb13ff98..6dff0fe82 100644 --- a/lez/sequencer/core/src/tests.rs +++ b/lez/sequencer/core/src/tests.rs @@ -23,9 +23,9 @@ use logos_blockchain_core::{ use logos_blockchain_key_management_system_service::keys::ZkPublicKey; use logos_blockchain_zone_sdk::sequencer::DepositInfo; use mempool::MemPoolHandle; -use ping_core::{ReceiverInstruction, ping_record_pda}; +use ping_core::{ReceiverInstruction, ping_record_pda, receiver_config_account_id}; use storage::sequencer::sequencer_cells::{ - PendingCrossZoneDispatchRecord, PendingDepositEventRecord, + DispatchOrigin, PendingCrossZoneDispatchRecord, PendingDepositEventRecord, }; use tempfile::tempdir; use testnet_initial_state::{initial_pub_accounts_private_keys, initial_public_user_accounts}; @@ -209,16 +209,30 @@ fn ping_payload(payload: &[u8]) -> Vec { fn dispatch_tx(src_block_id: u64, payload: Vec) -> LeeTransaction { let receiver_id = programs::ping_receiver().id(); LeeTransaction::Public(cross_zone::build_dispatch_from_emission( - PEER_ZONE, - src_block_id, - 0, - programs::ping_sender().id(), + &cross_zone::EmissionSource { + src_zone: PEER_ZONE, + src_block_id, + src_block_hash: peer_block_hash(src_block_id), + src_tx_index: 0, + src_program_id: programs::ping_sender().id(), + }, receiver_id, - &[ping_record_pda(receiver_id).into_value()], + &[ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload, )) } +/// A stand-in for the peer block's recomputed hash, distinct per block id. These +/// records are seeded into the store, so no real block exists to hash. +fn peer_block_hash(src_block_id: u64) -> [u8; 32] { + let mut hash = [0_u8; 32]; + hash[..8].copy_from_slice(&src_block_id.to_le_bytes()); + hash +} + /// The pending record the watcher would leave behind for that dispatch. fn dispatch_record(src_block_id: u64, payload: Vec) -> PendingCrossZoneDispatchRecord { let tx = dispatch_tx(src_block_id, payload); @@ -286,7 +300,7 @@ async fn start_from_config_opens_existing_db_if_it_exists() { let genesis_block = genesis_hashable_data.into_pending_block(&signing_key); SequencerStore::create_db_with_genesis( - &config.home.join("rocksdb"), + &config.db_path(), &genesis_block, &genesis_state, signing_key, @@ -306,7 +320,7 @@ async fn start_from_config_panics_when_db_open_returns_non_not_found_error() { let temp_dir = tempdir().unwrap(); config.home = temp_dir.path().to_path_buf(); - let db_path = config.home.join("rocksdb"); + let db_path = config.db_path(); std::fs::create_dir_all(&config.home).unwrap(); // Force RocksDB open to fail with an IO error by placing a file at DB path. @@ -338,7 +352,7 @@ async fn unfulfilled_deposit_events_are_drained_from_the_store_on_production() { { let signing_key = lee::PrivateKey::try_new(config.signing_key).unwrap(); - let store = SequencerStore::open_db(&config.home.join("rocksdb"), signing_key).unwrap(); + let store = SequencerStore::open_db(&config.db_path(), signing_key).unwrap(); let inserted = store .dbio() @@ -698,15 +712,45 @@ async fn a_dispatch_that_never_executes_is_given_up_on_after_repeated_failures() ); } - // The attempt at the limit gives up on it, and giving up drops the record. - // Anything else leaves an entry no later block can ever remove, which is how - // a peer that can make deliveries fail would grow this list without bound. + // The attempt at the limit gives up on it, which takes the record out of the + // pending list. Anything else leaves an entry no later block can ever + // remove, which is how a peer that can make deliveries fail would grow this + // list without bound. sequencer.produce_new_block().await.unwrap(); assert!( pending_dispatches(&sequencer).is_empty(), - "giving up on a delivery must drop its record, not flag it" + "giving up on a delivery must take its record out of the pending list" ); + // The dead letter is the only record that this happened, and the origin is + // what identifies which message stopped being attempted. + let dbio = sequencer.store.dbio(); + let dead_letters = dbio.get_dead_letter_cross_zone_dispatches().unwrap(); + assert_eq!(dead_letters.len(), 1); + assert_eq!( + dead_letters[0].origin, + DispatchOrigin { + src_zone: PEER_ZONE, + src_block_id: 13, + src_tx_index: 0, + } + ); + assert_eq!( + dead_letters[0].message_key, + cross_zone_inbox_core::message_key(&PEER_ZONE, 13, 0) + ); + assert!(dead_letters[0].transaction_bytes > 0); + assert_eq!( + dead_letters[0].failed_attempts, + RETIRE_DISPATCH_AFTER_FAILURES + ); + assert_eq!(dbio.get_dead_letter_cross_zone_dispatch_count().unwrap(), 1); + + // The same view the RPC serves, so an operator sees what the store holds. + let (total_retired, retained) = sequencer.cross_zone_dead_letters().unwrap(); + assert_eq!(total_retired, 1); + assert_eq!(retained, dead_letters); + // And nothing re-feeds it, so it stops costing a guest execution per block. let block_id = sequencer.produce_new_block().await.unwrap(); let block = sequencer.store.get_block_at_id(block_id).unwrap().unwrap(); diff --git a/lez/sequencer/service/Cargo.toml b/lez/sequencer/service/Cargo.toml index 738cee298..230603f4c 100644 --- a/lez/sequencer/service/Cargo.toml +++ b/lez/sequencer/service/Cargo.toml @@ -9,27 +9,21 @@ license = { workspace = true } workspace = true [dependencies] -common.workspace = true -lee.workspace = true -mempool.workspace = true sequencer_core = { workspace = true, features = ["testnet"] } -sequencer_service_protocol.workspace = true -sequencer_service_rpc = { workspace = true, features = ["server"] } -sequencer_service_metrics = { workspace = true, features = ["record"] } -programs.workspace = true +sequencer_executor_actor.workspace = true +sequencer_rpc_server_actor.workspace = true clap = { workspace = true, features = ["derive", "env"] } anyhow.workspace = true env_logger.workspace = true +kameo.workspace = true +kameo_actors.workspace = true hex.workspace = true log.workspace = true metrics-exporter-prometheus.workspace = true tokio.workspace = true tokio-util.workspace = true -jsonrpsee.workspace = true futures.workspace = true -bytesize.workspace = true -borsh.workspace = true [features] default = [] diff --git a/lez/sequencer/service/protocol/Cargo.toml b/lez/sequencer/service/protocol/Cargo.toml index ced19e755..1eb413d0b 100644 --- a/lez/sequencer/service/protocol/Cargo.toml +++ b/lez/sequencer/service/protocol/Cargo.toml @@ -13,4 +13,5 @@ lee.workspace = true lee_core.workspace = true hex.workspace = true +serde.workspace = true serde_with.workspace = true diff --git a/lez/sequencer/service/protocol/src/lib.rs b/lez/sequencer/service/protocol/src/lib.rs index ce669d312..37f70415b 100644 --- a/lez/sequencer/service/protocol/src/lib.rs +++ b/lez/sequencer/service/protocol/src/lib.rs @@ -5,11 +5,36 @@ use std::{fmt::Display, str::FromStr}; pub use common::{HashType, block::Block, transaction::LeeTransaction}; pub use lee::{Account, AccountId, ProgramId}; pub use lee_core::{BlockId, Commitment, CommitmentSetDigest, MembershipProof, account::Nonce}; +use serde::{Deserialize, Serialize}; use serde_with::{DeserializeFromStr, SerializeDisplay}; #[derive(Debug, Clone, PartialEq, Eq, Hash, SerializeDisplay, DeserializeFromStr)] pub struct ChannelId(pub [u8; 32]); +/// A cross-zone delivery a sequencer gave up on after repeated failures. +/// +/// Identifies the message rather than carrying it: zone, block id and tx index +/// locate it on the peer's channel. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CrossZoneDeadLetter { + pub message_key: HashType, + pub src_zone: ChannelId, + pub src_block_id: u64, + pub src_tx_index: u32, + pub failed_attempts: u32, + pub transaction_bytes: u32, +} + +/// What a sequencer has given up delivering. +/// +/// `total_retired` counts every give-up, `retained` only the ones still kept; +/// they diverge on eviction and on reconciliation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CrossZoneDeadLetterReport { + pub total_retired: u64, + pub retained: Vec, +} + impl Display for ChannelId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let hex_string = hex::encode(self.0); diff --git a/lez/sequencer/service/rpc/src/lib.rs b/lez/sequencer/service/rpc/src/lib.rs index a1d2acfb4..6af9cb807 100644 --- a/lez/sequencer/service/rpc/src/lib.rs +++ b/lez/sequencer/service/rpc/src/lib.rs @@ -6,8 +6,8 @@ use jsonrpsee::types::ErrorObjectOwned; #[cfg(feature = "client")] pub use jsonrpsee::{core::ClientError, http_client::HttpClientBuilder as SequencerClientBuilder}; use sequencer_service_protocol::{ - Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, HashType, - LeeTransaction, MembershipProof, Nonce, ProgramId, + Account, AccountId, Block, BlockId, ChannelId, Commitment, CommitmentSetDigest, + CrossZoneDeadLetterReport, HashType, LeeTransaction, MembershipProof, Nonce, ProgramId, }; #[cfg(all(not(feature = "server"), not(feature = "client")))] @@ -90,4 +90,13 @@ pub trait Rpc { #[method(name = "getChannelId")] async fn get_channel_id(&self) -> Result; + + /// The cross-zone deliveries this sequencer has given up on. + /// + /// Its own method rather than folded into `checkHealth`: one undeliverable + /// peer message must not read as an unhealthy node. + #[method(name = "getCrossZoneDeadLetters")] + async fn get_cross_zone_dead_letters( + &self, + ) -> Result; } diff --git a/lez/sequencer/service/src/actor_handle.rs b/lez/sequencer/service/src/actor_handle.rs new file mode 100644 index 000000000..92433e35c --- /dev/null +++ b/lez/sequencer/service/src/actor_handle.rs @@ -0,0 +1,80 @@ +use anyhow::{Result, anyhow}; +use futures::never::Never; +use kameo::actor::ActorRef; +use log::{error, info}; + +/// A handle to an actor encapsulating some common operations like graceful shutdown and health +/// check. +pub struct ActorHandle { + actor_ref: ActorRef, + full_name: String, +} + +impl ActorHandle { + pub fn new(actor_ref: ActorRef) -> Self { + Self { + full_name: format!("{}{}", T::name(), actor_ref.id()), + actor_ref, + } + } + + pub fn full_name(&self) -> &str { + &self.full_name + } + + pub async fn shutdown(self) { + let full_name = self.full_name(); + info!("Stopping {full_name} actor..."); + + if let Err(err) = self.actor_ref.stop_gracefully().await { + error!("Failed to gracefully stop actor {full_name}: {err}",); + } + self.actor_ref.wait_for_shutdown_with_result(|_| ()).await; + + info!("{full_name} actor stopped"); + } + + pub async fn failed(&self) -> Result + where + T::Error: std::fmt::Display, + { + let err = self + .actor_ref + .wait_for_shutdown_with_result(|res| self.stop_res_into_anyhow(res)) + .await; + Err(err) + } + + pub fn is_healthy(&self) -> bool { + self.actor_ref.is_alive() + } + + fn stop_res_into_anyhow( + &self, + res: Result<&kameo::error::ActorStopReason, kameo::error::HookError<&E>>, + ) -> anyhow::Error { + let full_name = self.full_name(); + + match res { + Ok(reason) => anyhow!("{full_name} actor has been stopped: {reason}"), + Err(kameo::error::HookError::Panicked(err)) => { + anyhow!(err).context(format!("{full_name} actor has been stopped due to panic")) + } + Err(kameo::error::HookError::Error(err)) => { + // Can't use `anyhow!(err)` here because `err` is a reference to the error, not + // the error itself. Also can't require `E: Clone` as a lot + // of error types don't implement `Clone` + // (e.g. `std::io::Error` and `anyhow::Error`). + anyhow!(format!("{err:#}")) + .context(format!("{full_name} actor has been stopped due to error")) + } + } + } +} + +impl Drop for ActorHandle { + fn drop(&mut self) { + info!("Killing {} actor", self.full_name()); + self.actor_ref.kill(); + } +} diff --git a/lez/sequencer/service/src/lib.rs b/lez/sequencer/service/src/lib.rs index cd46829b1..26c365f13 100644 --- a/lez/sequencer/service/src/lib.rs +++ b/lez/sequencer/service/src/lib.rs @@ -1,50 +1,35 @@ -use std::{net::SocketAddr, sync::Arc, time::Duration}; +use std::net::SocketAddr; -use anyhow::{Context as _, Result, anyhow}; -use bytesize::ByteSize; -use common::transaction::LeeTransaction; +use anyhow::{Context as _, Result}; use futures::never::Never; -use jsonrpsee::server::ServerHandle; -use log::{error, info, warn}; -use mempool::MemPoolHandle; -#[cfg(not(feature = "standalone"))] -use sequencer_core::SequencerCore; -#[cfg(feature = "standalone")] -use sequencer_core::SequencerCoreWithMockClients as SequencerCore; +use kameo::actor::Spawn as _; +use kameo_actors::scheduler::{Scheduler, SetInterval}; +use log::info; pub use sequencer_core::config::*; -use sequencer_core::{ - TransactionOrigin, - block_publisher::BlockPublisherTrait as _, - gossip::GossipTxPublisher, - load_or_create_signing_key, - task_group::{StoreRelease, TaskGroup}, -}; -use sequencer_service_rpc::RpcServer as _; -use tokio::{sync::Mutex, task::JoinHandle}; -use tokio_util::sync::CancellationToken; +use sequencer_core::load_or_create_signing_key; +use sequencer_executor_actor::ExecutorActor; +use sequencer_rpc_server_actor::RpcServerActor; +use tokio::select; -pub mod service; +use crate::actor_handle::ActorHandle; -const REQUEST_BODY_MAX_SIZE: ByteSize = ByteSize::mib(10); +mod actor_handle; + +#[cfg(not(feature = "standalone"))] +type BlockPublisher = sequencer_core::block_publisher::ZoneSdkPublisher; + +#[cfg(feature = "standalone")] +type BlockPublisher = sequencer_core::mock::MockBlockPublisher; /// Handle to manage the sequencer and its tasks. /// -/// Implements `Drop` to ensure all tasks are aborted and the RPC server is stopped when dropped. +/// Implements `Drop` to ensure all actors are killed when dropped. pub struct SequencerHandle { + // NOTE: Order of fields matters as it affects drop order. + scheduler: ActorHandle, + rpc_server: ActorHandle, + executor: ActorHandle>, addr: SocketAddr, - server_handle: ServerHandle, - main_loop_handle: JoinHandle>, - /// Cancelled when the publisher's drive task terminates (e.g. a panicked - /// persist sink); no channel events are processed past that point. - driver_cancellation: CancellationToken, - /// The core's background tasks, taken before the core was shared. This - /// handle owns no reference to the core itself, so without these there is - /// nothing to wait on: aborting the main loop only starts the teardown. - background_tasks: Vec, - /// The store, weakly. Every strong reference lives inside something this - /// handle stops, so watching the count go to zero is how shutdown knows the - /// database file is actually closed rather than assuming it from drop order. - store: StoreRelease, /// Held for its lifetime: dropping it stops the gossip drive task. /// `None` when gossip is unconfigured. #[expect( @@ -56,59 +41,36 @@ pub struct SequencerHandle { impl SequencerHandle { const fn new( + scheduler: ActorHandle, + rpc_server: ActorHandle, + executor: ActorHandle>, addr: SocketAddr, - server_handle: ServerHandle, - main_loop_handle: JoinHandle>, - driver_cancellation: CancellationToken, - background_tasks: Vec, - store: StoreRelease, gossip: Option, ) -> Self { Self { + scheduler, + rpc_server, + executor, addr, - server_handle, - main_loop_handle, - driver_cancellation, - background_tasks, - store, gossip, } } /// Stops the sequencer and waits for every part of it to be gone. - /// - /// `Drop` alone cannot do this: it aborts the main loop without awaiting it, - /// and the core lives behind `Arc`s held by that task and the RPC server, so - /// after a plain drop the store is still open for an unbounded stretch. That - /// is why restarting a sequencer on the same home directory used to need a - /// sleep, and why an in-process restart could fail outright with a `RocksDB` - /// lock error. - /// - /// Order matters: the main loop stops first so nothing new is produced while - /// the publisher is torn down, then the background tasks that hold the store, - /// then the server. Consuming `self` drops the last references, so the store - /// is closed by the time this returns. - pub async fn shutdown(mut self) { - self.main_loop_handle.abort(); - if let Err(err) = (&mut self.main_loop_handle).await - && err.is_panic() - { - error!("Sequencer main loop panicked before shutdown: {err}"); - } + /// executor itself. + pub async fn shutdown(self) { + let Self { + scheduler, + rpc_server, + executor, + addr: _, + gossip: _, + } = self; - for tasks in &self.background_tasks { - tasks.shutdown().await; - } - - if let Err(err) = self.server_handle.stop() { - error!("An error occurred while stopping Sequencer RPC server: {err}"); - } - self.server_handle.clone().stopped().await; - - // Nothing this handle owns holds the store, so waiting here rather than - // after the drop is the same thing, and it keeps the guarantee inside - // the call the caller awaits. - wait_for_store_release(&self.store).await; + // NOTE: Order of shutdown matters. Make sure it follows the order of fields in the struct. + scheduler.shutdown().await; + rpc_server.shutdown().await; + executor.shutdown().await; } /// Wait for any of the sequencer tasks to fail and return the error. @@ -116,31 +78,24 @@ impl SequencerHandle { clippy::integer_division_remainder_used, reason = "Generated by select! macro, can't be easily rewritten to avoid this lint" )] - pub async fn failed(&mut self) -> Result { + pub async fn failed(&self) -> Result { let Self { + executor, + rpc_server, + scheduler, addr: _, - server_handle, - main_loop_handle, - driver_cancellation, - background_tasks: _, - store: _, gossip: _, } = self; - // Cloned rather than taken: `stopped()` consumes a handle, and taking - // this one would leave `shutdown` with no way to stop the server. - let server_handle = server_handle.clone(); - tokio::select! { - () = server_handle.stopped() => { - Err(anyhow!("RPC Server stopped")) + select! { + Err(err) = executor.failed() => { + Err(err) } - res = main_loop_handle => { - res - .context("Main loop task panicked")? - .context("Main loop exited unexpectedly") + Err(err) = rpc_server.failed() => { + Err(err) } - () = driver_cancellation.cancelled() => { - Err(anyhow!("Publisher drive task terminated")) + Err(err) = scheduler.failed() => { + Err(err) } } } @@ -152,22 +107,14 @@ impl SequencerHandle { #[must_use] pub fn is_healthy(&self) -> bool { let Self { + executor, + rpc_server, + scheduler, addr: _, - server_handle, - main_loop_handle, - driver_cancellation, - background_tasks, - store: _, gossip: _, } = self; - let stopped = server_handle.is_stopped() - || main_loop_handle.is_finished() - || driver_cancellation.is_cancelled() - // A watcher only ends by panicking, and a peer whose deliveries have - // silently stopped is exactly what this predicate exists to catch. - || background_tasks.iter().any(TaskGroup::any_finished); - !stopped + executor.is_healthy() && rpc_server.is_healthy() && scheduler.is_healthy() } #[must_use] @@ -176,69 +123,19 @@ impl SequencerHandle { } } -impl Drop for SequencerHandle { - fn drop(&mut self) { - let Self { - addr: _, - server_handle, - main_loop_handle, - driver_cancellation: _, - background_tasks: _, - store: _, - gossip: _, - } = self; - - main_loop_handle.abort(); - - if let Err(err) = server_handle.stop() { - error!("An error occurred while stopping Sequencer RPC server: {err}"); - } - } -} - -/// Waits until nothing holds the store any more. -/// -/// Everything that holds one lives inside a task or a server this handle has -/// already stopped, but the last drop happens on whichever thread ran them, not -/// on this one. Without this the caller can reopen the database a moment too -/// early and hit a `RocksDB` lock error, which is the kind of failure that shows -/// up as an occasional flake rather than a bug. -async fn wait_for_store_release(store: &StoreRelease) { - /// Long enough for a drop that is already in flight, short enough that a - /// leak is reported rather than hung on. - const RELEASE_TIMEOUT: Duration = Duration::from_secs(10); - const POLL: Duration = Duration::from_millis(10); - - let released = tokio::time::timeout(RELEASE_TIMEOUT, async { - while store.holders() > 0 { - tokio::time::sleep(POLL).await; - } - }) - .await; - - if released.is_err() { - error!( - "Sequencer store still held by {} reference(s) after shutdown; something outlived the tasks it should have died with", - store.holders() - ); - } -} - pub async fn run(config: SequencerConfig, listen_addr: SocketAddr) -> Result { - sequencer_service_metrics::init(); - let block_timeout = config.block_create_timeout; let max_block_size = config.max_block_size; - // Captured before `config` moves into the core; gossip needs them after. + // Captured before `config` moves into the executor; gossip needs them after. let gossip_config = config.gossip.clone(); let bedrock_config = config.bedrock_config.clone(); let sequencer_home = config.home.clone(); - let (sequencer_core, mempool_handle): (SequencerCore, _) = - SequencerCore::start_from_config(config).await; - - info!("Sequencer core set up"); + let executor = ExecutorActor::new(config).await; + let mempool_handle = executor.mempool_handle(); + let executor_ref = ExecutorActor::spawn(executor); + info!("Executor Actor spawned"); // Gossip is constructed only when configured; a `None` config means no // sockets and no tasks. Startup failure here is a hard error @@ -255,7 +152,7 @@ pub async fn run(config: SequencerConfig, listen_addr: SocketAddr) -> Result Result>, - mempool_handle: MemPoolHandle<(TransactionOrigin, LeeTransaction)>, - listen_addr: SocketAddr, - max_block_size: u64, - tx_publisher: Option, -) -> Result<(ServerHandle, SocketAddr)> { - let server = jsonrpsee::server::ServerBuilder::with_config( - jsonrpsee::server::ServerConfigBuilder::new() - .max_request_body_size( - u32::try_from(REQUEST_BODY_MAX_SIZE.as_u64()) - .expect("REQUEST_BODY_MAX_SIZE should be less than u32::MAX"), - ) - .build(), - ) - .build(listen_addr) - .await - .context("Failed to build RPC server")?; - - let addr = server - .local_addr() - .context("Failed to get local address of RPC server")?; - - info!("Starting Sequencer Service RPC server on {addr}"); - - let service = - service::SequencerService::new(sequencer, mempool_handle, max_block_size, tx_publisher); - let handle = server.start(service.into_rpc()); - Ok((handle, addr)) -} - -async fn main_loop(seq_core: Arc>, block_timeout: Duration) -> Result { - loop { - tokio::time::sleep(block_timeout).await; - - let mut state = seq_core.lock().await; - - // Only produce on our turn. - if !state.is_our_turn() { - continue; - } - - // Never inscribe a second block at a height we already published: the - // channel would carry two chains from there and nothing resolves that. - // The head rewinds under us when the sdk orphans our own unfinalized - // blocks, and recovers once they finalize, so this is a wait. - if let Some(high_water) = state.rewound_below_published() { - warn!( - "Skipping turn: head rewound to {} but block {high_water} is already inscribed; \ - waiting for the channel to restore it", - state.next_block_height().saturating_sub(1), - ); - continue; - } - - info!("Our turn: collecting transactions from mempool, creating block"); - let id = state.produce_new_block().await?; - info!("Block with id {id} created"); - } -} diff --git a/lez/sequencer/service/src/main.rs b/lez/sequencer/service/src/main.rs index b3d5bf719..9945ecd61 100644 --- a/lez/sequencer/service/src/main.rs +++ b/lez/sequencer/service/src/main.rs @@ -5,7 +5,7 @@ use std::{ use anyhow::{Context as _, Result}; use clap::Parser; -use log::{error, info}; +use log::error; use metrics_exporter_prometheus::{Matcher, PrometheusBuilder}; use tokio::signal::unix::{SignalKind, signal}; use tokio_util::sync::CancellationToken; @@ -49,12 +49,12 @@ async fn main() -> Result<()> { if let Some(metrics_address) = config.metrics_address { install_prometheus_recorder(metrics_address)?; } - let mut sequencer_handle = + let sequencer_handle = sequencer_service::run(config, SocketAddr::new(args.listen_address, args.port)).await?; tokio::select! { () = cancellation_token.cancelled() => { - info!("Shutting down sequencer..."); + log::info!("Shutting down sequencer..."); } Err(err) = sequencer_handle.failed() => { error!("Sequencer failed unexpectedly: {err}"); @@ -68,7 +68,7 @@ async fn main() -> Result<()> { // delivery and handing it over. sequencer_handle.shutdown().await; - info!("Sequencer shutdown complete"); + log::info!("Sequencer shutdown complete"); Ok(()) } @@ -135,13 +135,13 @@ fn listen_for_shutdown_signal() -> CancellationToken { tokio::select! { result = tokio::signal::ctrl_c() => match result { - Ok(()) => info!("Received Ctrl-C signal"), + Ok(()) => log::info!("Received Ctrl-C signal"), Err(err) => { error!("Failed to listen for Ctrl-C signal: {err}"); return; } }, - _ = terminate.recv() => info!("Received SIGTERM"), + _ = terminate.recv() => log::info!("Received SIGTERM"), } cancellation_token_clone.cancel(); diff --git a/lez/storage/src/sequencer/mod.rs b/lez/storage/src/sequencer/mod.rs index c771ea556..90f074145 100644 --- a/lez/storage/src/sequencer/mod.rs +++ b/lez/storage/src/sequencer/mod.rs @@ -20,6 +20,8 @@ use crate::{ cells::shared_cells::{BlockCell, FirstBlockCell, FirstBlockSetCell, LastBlockCell}, error::DbError, sequencer::sequencer_cells::{ + DeadLetterCrossZoneDispatchCountCell, DeadLetterCrossZoneDispatchesCellOwned, + DeadLetterCrossZoneDispatchesCellRef, DeadLetterDispatchRecord, DispatchOrigin, FinalBlockMetaCellOwned, FinalBlockMetaCellRef, FinalLeeStateCellOwned, FinalLeeStateCellRef, LEEStateCellOwned, LEEStateCellRef, LastFinalizedBlockIdCell, LatestBlockMetaCellOwned, LatestBlockMetaCellRef, PeerChainTip, PeerFloorCellOwned, @@ -55,6 +57,12 @@ pub const DB_META_CROSS_ZONE_PEER_TIP_KEY: &str = "cross_zone_peer_tip"; /// Key base for storing cross-zone deliveries the watcher has recorded but /// which are not yet known to be irreversibly delivered. pub const DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY: &str = "pending_cross_zone_dispatches"; +/// Key base for storing cross-zone deliveries this node has given up on. +pub const DB_META_DEAD_LETTER_CROSS_ZONE_DISPATCHES_KEY: &str = "dead_letter_cross_zone_dispatches"; +/// Key base for counting every cross-zone delivery given up on, including ones +/// since evicted from the retained list or reconciled out of it. +pub const DB_META_DEAD_LETTER_CROSS_ZONE_DISPATCH_COUNT_KEY: &str = + "dead_letter_cross_zone_dispatch_count"; /// Key base for counting unseen L2 withdraw intents. pub const DB_META_UNSEEN_WITHDRAW_COUNT_KEY: &str = "unseen_withdraw_count"; @@ -73,6 +81,17 @@ pub const DB_META_PUBLISHED_HIGH_WATER_KEY: &str = "published_high_water"; /// delivery floor and reads the slot again later. pub const MAX_PENDING_CROSS_ZONE_DISPATCHES: usize = 4096; +/// How many given-up-on cross-zone deliveries are kept for inspection. +/// +/// A peer chooses how many deliveries fail, so this cannot be unbounded. The +/// oldest evicts at the cap, and nothing is concealed by that: retirements are +/// counted separately and the count does not evict. +/// +/// An entry count bounds bytes only because a record identifies a delivery +/// rather than carrying it. At a fixed 84 bytes each the list is 21 KB, which +/// matters because it is one value rewritten under the block-production lock. +pub const MAX_DEAD_LETTER_CROSS_ZONE_DISPATCHES: usize = 256; + /// Key base for storing the LEE state. pub const DB_LEE_STATE_KEY: &str = "lee_state"; /// Key base for storing the LEE state at the last L1-finalized block. @@ -83,6 +102,20 @@ pub const DB_FINAL_BLOCK_META_KEY: &str = "final_block_meta"; /// Name of state column family. pub const CF_LEE_STATE_NAME: &str = "cf_lee_state"; +/// What counting a failed production attempt did to a delivery's record. +/// +/// Three outcomes rather than a bool: only one means this node stopped trying, +/// and a settled delivery has no record, so it is [`Self::Absent`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DispatchFailure { + /// Counted; the delivery is still pending and will be attempted again. + Retried { failed_attempts: u32 }, + /// Given up on: moved out of the pending list and into the dead letter. + Retired(Box), + /// No pending record, so nothing was counted and nothing was given up on. + Absent, +} + /// A single key/value entry from a column family, used inside [`DbDump`]. #[derive(BorshSerialize, BorshDeserialize)] pub struct DbDumpEntry { @@ -742,38 +775,103 @@ impl RocksDBIO { Ok(accepted) } - /// Counts a failed production attempt against a delivery, dropping its - /// record once it reaches `retire_at`. Returns whether it was dropped. + /// Counts a failed production attempt against a delivery, retiring it once + /// it reaches `retire_at`. /// - /// Dropped rather than flagged: a retired record is one the drain will never - /// turn into a block transaction again, so nothing would ever remove it, and - /// a peer that can make deliveries fail could grow the list without bound. - /// The delivery is given up on either way; this way the cost is a log line - /// rather than a permanent entry. + /// The pending list has to lose the record, or the drain re-feeds a + /// transaction that never executes for ever. The dead letter keeps the + /// delivery identifiable, since a dispatch that fails execution is left out + /// of the block and leaves no trace elsewhere, and it is bounded separately. /// - /// A delivery with no record is already retired as far as this is concerned: - /// there is nothing left to count against. - pub fn record_dispatch_failure(&self, message_key: [u8; 32], retire_at: u32) -> DbResult { + /// No pending record gives [`DispatchFailure::Absent`], not a retirement: + /// the ordinary shape of a delivery that settled and then failed a later + /// attempt. + pub fn record_dispatch_failure( + &self, + message_key: [u8; 32], + retire_at: u32, + origin: DispatchOrigin, + ) -> DbResult { let _pending = self.lock_pending_records(); let mut records = self.get_pending_cross_zone_dispatches()?; let Some(position) = records .iter() .position(|record| record.message_key == message_key) else { - return Ok(true); + return Ok(DispatchFailure::Absent); }; - let attempts = { + let failed_attempts = { let record = &mut records[position]; record.failed_attempts = record.failed_attempts.saturating_add(1); record.failed_attempts }; - let retired = attempts >= retire_at; - if retired { - records.remove(position); + if failed_attempts < retire_at { + self.put_pending_cross_zone_dispatches(&records)?; + return Ok(DispatchFailure::Retried { failed_attempts }); } - self.put_pending_cross_zone_dispatches(&records)?; - Ok(retired) + + let retired = records.remove(position); + let dead_letter = DeadLetterDispatchRecord { + message_key, + origin, + failed_attempts, + transaction_bytes: u32::try_from(retired.transaction.len()).unwrap_or(u32::MAX), + }; + + // One entry per delivery, not per retirement. A watcher rebuilding a + // peer tip re-reads from the peer's genesis, and a never-executing + // delivery never reaches the seen-set, so the same one retires again; + // undeduped it would evict every other entry with copies of itself. + let mut dead_letters = self.get_dead_letter_cross_zone_dispatches()?; + if !dead_letters + .iter() + .any(|record| record.message_key == message_key) + { + dead_letters.push(dead_letter.clone()); + while dead_letters.len() > MAX_DEAD_LETTER_CROSS_ZONE_DISPATCHES { + dead_letters.remove(0); + } + } + // Counted per retirement even so: the retained list evicts and drops + // settled entries, so its length is not how often this node gave up. + let count = self + .get_dead_letter_cross_zone_dispatch_count()? + .saturating_add(1); + + // One batch: a crash between the two halves either loses the message + // silently or leaves the drain retrying a delivery already recorded as + // given up on. + let mut batch = WriteBatch::default(); + self.put_pending_cross_zone_dispatches_batch(&records, &mut batch)?; + self.put_batch( + &DeadLetterCrossZoneDispatchesCellRef(&dead_letters), + (), + &mut batch, + )?; + self.put_batch(&DeadLetterCrossZoneDispatchCountCell(count), (), &mut batch)?; + self.db.write(batch).map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to retire a cross-zone dispatch into the dead letter".to_owned()), + ) + })?; + + Ok(DispatchFailure::Retired(Box::new(dead_letter))) + } + + /// The cross-zone deliveries given up on and still retained, oldest first. + pub fn get_dead_letter_cross_zone_dispatches(&self) -> DbResult> { + Ok(self + .get_opt::(())? + .map_or_else(Vec::new, |cell| cell.0)) + } + + /// Every cross-zone delivery given up on, including ones since evicted. + pub fn get_dead_letter_cross_zone_dispatch_count(&self) -> DbResult { + Ok(self + .get_opt::(())? + .map_or(0, |cell| cell.0)) } /// Drops the records of deliveries that are settled for good, outside any @@ -796,12 +894,52 @@ impl RocksDBIO { records.retain(|record| !to_remove.contains(&record.message_key)); let removed = before.saturating_sub(records.len()); + // Both lists in one batch, as in `record_dispatch_failure`: nothing + // recomputes these keys on a later pass to fix a torn write. + let mut batch = WriteBatch::default(); if removed > 0 { - self.put_pending_cross_zone_dispatches(&records)?; + self.put_pending_cross_zone_dispatches_batch(&records, &mut batch)?; + } + self.stage_reconciled_dead_letters(&to_remove, &mut batch)?; + if !batch.is_empty() { + self.db.write(batch).map_err(|rerr| { + DbError::rocksdb_cast_message( + rerr, + Some("Failed to drop settled cross-zone dispatches".to_owned()), + ) + })?; } Ok(removed) } + /// Stages the removal of dead letters whose delivery turned out to settle. + /// + /// Every sequencer gives up alone, against its own head, so a delivery this + /// one abandoned can still reach another's block. Nothing else removes an + /// entry, so without this it reports as abandoned for the store's lifetime. + /// + /// The count is deliberately not decremented: it records how often this node + /// gave up, which stays true. + fn stage_reconciled_dead_letters( + &self, + settled: &std::collections::HashSet<&[u8; 32]>, + batch: &mut WriteBatch, + ) -> DbResult { + let mut dead_letters = self.get_dead_letter_cross_zone_dispatches()?; + let before = dead_letters.len(); + dead_letters.retain(|record| !settled.contains(&record.message_key)); + let reconciled = before.saturating_sub(dead_letters.len()); + + if reconciled > 0 { + self.put_batch( + &DeadLetterCrossZoneDispatchesCellRef(&dead_letters), + (), + batch, + )?; + } + Ok(reconciled) + } + /// Drops the pending records of deliveries that just became irreversible, /// staged into `batch` so they go with the update that made them so. /// @@ -827,6 +965,10 @@ impl RocksDBIO { if removed > 0 { self.put_pending_cross_zone_dispatches_batch(&records, batch)?; } + + // The ordinary case: another sequencer carried a delivery this node gave + // up on into a block that just became irreversible. + self.stage_reconciled_dead_letters(&to_remove, batch)?; Ok(removed) } diff --git a/lez/storage/src/sequencer/sequencer_cells.rs b/lez/storage/src/sequencer/sequencer_cells.rs index 098d561bd..8cbfe012d 100644 --- a/lez/storage/src/sequencer/sequencer_cells.rs +++ b/lez/storage/src/sequencer/sequencer_cells.rs @@ -9,10 +9,12 @@ use crate::{ sequencer::{ CF_LEE_STATE_NAME, DB_FINAL_BLOCK_META_KEY, DB_FINAL_LEE_STATE_KEY, DB_LEE_STATE_KEY, DB_META_CROSS_ZONE_PEER_FLOOR_KEY, DB_META_CROSS_ZONE_PEER_TIP_KEY, - DB_META_LAST_FINALIZED_BLOCK_ID, DB_META_LATEST_BLOCK_META_KEY, - DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY, DB_META_PENDING_DEPOSIT_EVENTS_KEY, - DB_META_PUBLISHED_HIGH_WATER_KEY, DB_META_UNSEEN_WITHDRAW_COUNT_KEY, - DB_META_ZONE_CURSOR_KEY, DB_META_ZONE_SDK_CHECKPOINT_KEY, + DB_META_DEAD_LETTER_CROSS_ZONE_DISPATCH_COUNT_KEY, + DB_META_DEAD_LETTER_CROSS_ZONE_DISPATCHES_KEY, DB_META_LAST_FINALIZED_BLOCK_ID, + DB_META_LATEST_BLOCK_META_KEY, DB_META_PENDING_CROSS_ZONE_DISPATCHES_KEY, + DB_META_PENDING_DEPOSIT_EVENTS_KEY, DB_META_PUBLISHED_HIGH_WATER_KEY, + DB_META_UNSEEN_WITHDRAW_COUNT_KEY, DB_META_ZONE_CURSOR_KEY, + DB_META_ZONE_SDK_CHECKPOINT_KEY, }, }; @@ -294,9 +296,9 @@ pub struct PendingCrossZoneDispatchRecord { /// A dispatch's payload and target accounts are chosen on the peer zone and /// validated by nobody in between, so one can fail for good. A failure can /// equally be a property of the moment, so a single one is not enough to - /// give up on a delivery. Once too many accumulate the record is dropped - /// rather than flagged, since a delivery nothing will retry is also a - /// delivery nothing would ever remove. + /// give up on a delivery. Once too many accumulate the record leaves this + /// list (the drain re-feeds it every turn) for a + /// [`DeadLetterDispatchRecord`], which keeps the delivery identifiable. pub failed_attempts: u32, } @@ -347,6 +349,100 @@ impl SimpleWritableCell for PendingCrossZoneDispatchesCellRef<'_> { } } +/// Which peer message a delivery carried, kept so a lost one can be traced back +/// to the peer block it was in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct DispatchOrigin { + pub src_zone: PeerZoneKey, + pub src_block_id: u64, + pub src_tx_index: u32, +} + +/// A cross-zone delivery this node has given up on. +/// +/// A dispatch that fails execution is left out of the block, so nothing on chain +/// records that it was attempted; this is the only durable trace. +/// +/// It identifies the message rather than carrying it: the peer block and index +/// are enough to read it back off the channel, and the encoded transaction is +/// peer-chosen and can exceed a whole block, which would leave the list bounded +/// in entries but unbounded in bytes. +/// +/// Giving up is this node's decision, not the network's, so an entry is dropped +/// again if another sequencer carries the same delivery. +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct DeadLetterDispatchRecord { + pub message_key: [u8; 32], + pub origin: DispatchOrigin, + /// Attempts made before giving up, so the record carries the policy that was + /// in force at the time. + pub failed_attempts: u32, + /// Size of the delivery transaction that would not execute, the diagnostic + /// for size-related failures. + pub transaction_bytes: u32, +} + +#[derive(BorshDeserialize)] +pub struct DeadLetterCrossZoneDispatchesCellOwned(pub Vec); + +impl SimpleStorableCell for DeadLetterCrossZoneDispatchesCellOwned { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_DEAD_LETTER_CROSS_ZONE_DISPATCHES_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for DeadLetterCrossZoneDispatchesCellOwned {} + +#[derive(BorshSerialize)] +pub struct DeadLetterCrossZoneDispatchesCellRef<'records>(pub &'records [DeadLetterDispatchRecord]); + +impl SimpleStorableCell for DeadLetterCrossZoneDispatchesCellRef<'_> { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_DEAD_LETTER_CROSS_ZONE_DISPATCHES_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleWritableCell for DeadLetterCrossZoneDispatchesCellRef<'_> { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize dead-letter cross-zone dispatches cell".to_owned()), + ) + }) + } +} + +/// Deliveries given up on since this store was created. +/// +/// Separate from the retained list, which evicts at its cap and drops settled +/// entries: a node that gave up hundreds of times would otherwise look like one +/// that gave up at the cap. +#[derive(BorshSerialize, BorshDeserialize)] +pub struct DeadLetterCrossZoneDispatchCountCell(pub u64); + +impl SimpleStorableCell for DeadLetterCrossZoneDispatchCountCell { + type KeyParams = (); + + const CELL_NAME: &'static str = DB_META_DEAD_LETTER_CROSS_ZONE_DISPATCH_COUNT_KEY; + const CF_NAME: &'static str = CF_META_NAME; +} + +impl SimpleReadableCell for DeadLetterCrossZoneDispatchCountCell {} + +impl SimpleWritableCell for DeadLetterCrossZoneDispatchCountCell { + fn value_constructor(&self) -> DbResult> { + borsh::to_vec(&self).map_err(|err| { + DbError::borsh_cast_message( + err, + Some("Failed to serialize dead-letter cross-zone dispatch count".to_owned()), + ) + }) + } +} + #[derive(BorshDeserialize)] pub struct PendingDepositEventsCellOwned(pub Vec); diff --git a/lez/storage/src/sequencer/tests.rs b/lez/storage/src/sequencer/tests.rs index 385f7ffec..2d4d2ed34 100644 --- a/lez/storage/src/sequencer/tests.rs +++ b/lez/storage/src/sequencer/tests.rs @@ -41,6 +41,15 @@ fn dispatch_record(seed: u8) -> PendingCrossZoneDispatchRecord { PendingCrossZoneDispatchRecord::recorded([seed; 32], vec![seed; 4]) } +/// The peer coordinates a dead letter carries, distinct per seed. +fn dispatch_origin(seed: u8) -> DispatchOrigin { + DispatchOrigin { + src_zone: [seed; 32], + src_block_id: u64::from(seed), + src_tx_index: u32::from(seed), + } +} + /// A distinct message key per index, for filling the pending list. fn key_from_index(index: usize) -> [u8; 32] { let mut key = [0_u8; 32]; @@ -606,7 +615,7 @@ fn finalized_dispatch_records_are_removed_by_message_key() { } #[test] -fn record_dispatch_failure_drops_the_record_at_the_limit() { +fn record_dispatch_failure_retires_the_record_at_the_limit() { let temp_dir = tempdir().unwrap(); let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); @@ -616,36 +625,229 @@ fn record_dispatch_failure_drops_the_record_at_the_limit() { dbio.add_pending_cross_zone_dispatches(vec![record, survivor.clone()]) .unwrap(); - assert!(!dbio.record_dispatch_failure(key, 3).unwrap()); assert_eq!( - dbio.get_pending_cross_zone_dispatches().unwrap()[0].failed_attempts, - 1, + dbio.record_dispatch_failure(key, 3, dispatch_origin(1)) + .unwrap(), + DispatchFailure::Retried { failed_attempts: 1 }, "a failure short of the limit is counted, not given up on" ); - assert!(!dbio.record_dispatch_failure(key, 3).unwrap()); - assert!( - dbio.record_dispatch_failure(key, 3).unwrap(), - "the third failure is the one it is given up on" + assert_eq!( + dbio.get_pending_cross_zone_dispatches().unwrap()[0].failed_attempts, + 1 ); + assert_eq!( + dbio.record_dispatch_failure(key, 3, dispatch_origin(1)) + .unwrap(), + DispatchFailure::Retried { failed_attempts: 2 } + ); + let DispatchFailure::Retired(retired) = dbio + .record_dispatch_failure(key, 3, dispatch_origin(1)) + .unwrap() + else { + panic!("the third failure is the one it is given up on"); + }; + assert_eq!(retired.message_key, key); + assert_eq!(retired.origin, dispatch_origin(1)); + assert_eq!(retired.failed_attempts, 3); - // Dropped rather than flagged: a delivery the drain will never feed into a - // block again is one nothing would ever remove, so flagging it would let a - // peer that can make deliveries fail grow the list without bound. + // It has to leave the pending list, which the drain re-feeds every turn, or + // a delivery that can never execute would be retried for ever. assert_eq!( dbio.get_pending_cross_zone_dispatches().unwrap(), vec![survivor], - "giving up on a delivery drops its record and leaves the others alone" + "giving up on a delivery takes its record out and leaves the others alone" ); - // A key with no record reads as given up on: there is nothing left to count - // against, and nothing will feed it into a block. - assert!( - dbio.record_dispatch_failure(key, 3).unwrap(), - "a failure against a dropped delivery must not re-create its record" + // A key with no record is not a give-up: nothing was counted and nothing was + // abandoned. This is the shape of a delivery that settled and then failed a + // later attempt. + assert_eq!( + dbio.record_dispatch_failure(key, 3, dispatch_origin(1)) + .unwrap(), + DispatchFailure::Absent, + "a failure against a retired delivery must not re-create its record" ); assert_eq!(dbio.get_pending_cross_zone_dispatches().unwrap().len(), 1); } +#[test] +fn a_retired_dispatch_moves_into_the_dead_letter_identified_by_its_origin() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + let record = dispatch_record(7); + let key = record.message_key; + let encoded_len = u32::try_from(record.transaction.len()).unwrap(); + dbio.add_pending_cross_zone_dispatches(vec![record]) + .unwrap(); + + assert!( + dbio.get_dead_letter_cross_zone_dispatches() + .unwrap() + .is_empty() + ); + for _ in 0..3 { + dbio.record_dispatch_failure(key, 3, dispatch_origin(7)) + .unwrap(); + } + + let dead_letters = dbio.get_dead_letter_cross_zone_dispatches().unwrap(); + assert_eq!(dead_letters.len(), 1); + assert_eq!(dead_letters[0].message_key, key); + assert_eq!( + dead_letters[0].origin, + dispatch_origin(7), + "the peer coordinates are what let the message be read back off the peer channel" + ); + assert_eq!(dead_letters[0].transaction_bytes, encoded_len); + assert_eq!(dbio.get_dead_letter_cross_zone_dispatch_count().unwrap(), 1); +} + +#[test] +fn a_dead_letter_is_dropped_once_its_delivery_settles_elsewhere() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + let record = dispatch_record(7); + let key = record.message_key; + dbio.add_pending_cross_zone_dispatches(vec![record]) + .unwrap(); + dbio.record_dispatch_failure(key, 1, dispatch_origin(7)) + .unwrap(); + assert_eq!( + dbio.get_dead_letter_cross_zone_dispatches().unwrap().len(), + 1 + ); + + // A delivery this node gave up on can still reach another sequencer's block. + dbio.drop_settled_cross_zone_dispatches(&[key]).unwrap(); + assert!( + dbio.get_dead_letter_cross_zone_dispatches() + .unwrap() + .is_empty() + ); + + // The count is how often this node gave up, which stays true whatever + // happened next, and is what keeps the list readable as "still outstanding". + assert_eq!(dbio.get_dead_letter_cross_zone_dispatch_count().unwrap(), 1); +} + +#[test] +fn a_dead_letter_is_dropped_by_the_settlement_path_inside_a_store_update() { + let temp_dir = tempdir().unwrap(); + let (dbio, genesis) = dbio_with_genesis(temp_dir.path()); + + let record = dispatch_record(7); + let key = record.message_key; + dbio.add_pending_cross_zone_dispatches(vec![record]) + .unwrap(); + dbio.record_dispatch_failure(key, 1, dispatch_origin(7)) + .unwrap(); + assert_eq!( + dbio.get_dead_letter_cross_zone_dispatches().unwrap().len(), + 1 + ); + + // The ordinary route, unlike the standalone drop: a block carrying the + // delivery becomes irreversible and the update that records that also + // reconciles the dead letter, in the same batch. + let block2 = produce_dummy_block(2, Some(genesis.header.hash), vec![]); + dbio.store_update(&StoreUpdate { + blocks: &[(&block2, true)], + remove_dispatch_records: &[key], + ..StoreUpdate::new(&state_with_balance(200)) + }) + .unwrap(); + + assert!( + dbio.get_dead_letter_cross_zone_dispatches() + .unwrap() + .is_empty() + ); + assert_eq!(dbio.get_dead_letter_cross_zone_dispatch_count().unwrap(), 1); +} + +#[test] +fn one_delivery_that_always_fails_takes_one_dead_letter_slot() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + // A watcher rebuilding a peer tip re-reads from genesis, so the same + // never-executing delivery retires repeatedly (see `record_dispatch_failure`). + let key = key_from_index(1); + let other = key_from_index(2); + dbio.add_pending_cross_zone_dispatches(vec![PendingCrossZoneDispatchRecord::recorded( + other, + vec![1, 2, 3, 4], + )]) + .unwrap(); + dbio.record_dispatch_failure(other, 1, dispatch_origin(2)) + .unwrap(); + + for _ in 0..5 { + dbio.add_pending_cross_zone_dispatches(vec![PendingCrossZoneDispatchRecord::recorded( + key, + vec![1, 2, 3, 4], + )]) + .unwrap(); + dbio.record_dispatch_failure(key, 1, dispatch_origin(1)) + .unwrap(); + } + + let dead_letters = dbio.get_dead_letter_cross_zone_dispatches().unwrap(); + assert_eq!( + dead_letters.len(), + 2, + "one entry per delivery, not per retirement" + ); + assert_eq!( + dead_letters[0].message_key, other, + "the other message is not evicted" + ); + + // The count still measures give-ups, so the repetition remains visible. + assert_eq!(dbio.get_dead_letter_cross_zone_dispatch_count().unwrap(), 6); +} + +#[test] +fn dead_letters_evict_the_oldest_at_the_cap_but_keep_counting() { + let temp_dir = tempdir().unwrap(); + let (dbio, _genesis) = dbio_with_genesis(temp_dir.path()); + + let retirements = MAX_DEAD_LETTER_CROSS_ZONE_DISPATCHES + 3; + for index in 0..retirements { + let key = key_from_index(index); + dbio.add_pending_cross_zone_dispatches(vec![PendingCrossZoneDispatchRecord::recorded( + key, + vec![1, 2, 3, 4], + )]) + .unwrap(); + dbio.record_dispatch_failure(key, 1, dispatch_origin(1)) + .unwrap(); + } + + let dead_letters = dbio.get_dead_letter_cross_zone_dispatches().unwrap(); + assert_eq!(dead_letters.len(), MAX_DEAD_LETTER_CROSS_ZONE_DISPATCHES); + assert_eq!( + dead_letters[0].message_key, + key_from_index(3), + "the oldest retained entry is the fourth retirement, the first three having been evicted" + ); + assert_eq!( + dead_letters[dead_letters.len() - 1].message_key, + key_from_index(retirements - 1), + "the newest retirement is kept" + ); + + // What eviction must not do is hide that the evicted ones happened: a node + // that lost hundreds of messages would otherwise look like one that lost the + // cap. + assert_eq!( + dbio.get_dead_letter_cross_zone_dispatch_count().unwrap(), + u64::try_from(retirements).unwrap() + ); +} + #[test] fn repeated_withdrawal_key_in_one_update_folds_once_per_occurrence() { let temp_dir = tempdir().unwrap(); diff --git a/lez/testnet_initial_state/src/lib.rs b/lez/testnet_initial_state/src/lib.rs index f77a083f3..8ea71e22b 100644 --- a/lez/testnet_initial_state/src/lib.rs +++ b/lez/testnet_initial_state/src/lib.rs @@ -1,12 +1,9 @@ use std::collections::HashMap; use key_protocol::key_management::{ - KeyChain, - key_tree::chain_index::ChainIndex, - secret_holders::{PrivateKeyHolder, SecretSpendingKey, ViewingSecretKey}, + KeyChain, key_tree::chain_index::ChainIndex, secret_holders::SecretSpendingKey, }; use lee::{Account, AccountId, Data, PrivateKey, PublicKey, V03State, program::Program}; -use lee_core::{NullifierPublicKey, encryption::ViewingPublicKey}; use serde::{Deserialize, Serialize}; const PRIVATE_KEY_PUB_ACC_A: [u8; 32] = [ @@ -29,46 +26,6 @@ const SSK_PRIV_ACC_B: [u8; 32] = [ 180, 43, 120, 55, 151, 50, 21, 113, 22, 254, 83, 148, 56, ]; -const NSK_PRIV_ACC_A: [u8; 32] = [ - 25, 21, 186, 59, 180, 224, 101, 64, 163, 208, 228, 43, 13, 185, 100, 123, 156, 47, 80, 179, 72, - 51, 115, 11, 180, 99, 21, 201, 48, 194, 118, 144, -]; - -const NSK_PRIV_ACC_B: [u8; 32] = [ - 99, 82, 190, 140, 234, 10, 61, 163, 15, 211, 179, 54, 70, 166, 87, 5, 182, 68, 117, 244, 217, - 23, 99, 9, 4, 177, 230, 125, 109, 91, 160, 30, -]; - -const VSK_D_PRIV_ACC_A: [u8; 32] = [ - 255, 250, 140, 26, 222, 223, 174, 95, 132, 108, 124, 88, 30, 247, 82, 72, 52, 70, 84, 139, 241, - 187, 41, 163, 19, 231, 232, 122, 225, 55, 134, 184, -]; - -const VSK_Z_PRIV_ACC_A: [u8; 32] = [ - 225, 24, 98, 78, 31, 203, 175, 248, 213, 17, 133, 207, 10, 135, 132, 151, 59, 184, 5, 81, 28, - 238, 137, 62, 233, 227, 99, 17, 236, 159, 244, 63, -]; - -const VSK_D_PRIV_ACC_B: [u8; 32] = [ - 128, 85, 85, 103, 226, 218, 119, 56, 60, 252, 31, 113, 232, 215, 156, 2, 159, 247, 156, 192, - 12, 178, 229, 236, 255, 120, 146, 211, 169, 117, 153, 180, -]; - -const VSK_Z_PRIV_ACC_B: [u8; 32] = [ - 165, 80, 169, 87, 248, 88, 167, 154, 27, 67, 131, 122, 50, 130, 111, 40, 164, 180, 204, 75, - 188, 140, 110, 132, 113, 133, 222, 8, 49, 123, 187, 18, -]; - -const NPK_PRIV_ACC_A: [u8; 32] = [ - 167, 108, 50, 153, 74, 47, 151, 188, 140, 79, 195, 31, 181, 9, 40, 167, 201, 32, 175, 129, 45, - 245, 223, 193, 210, 170, 247, 128, 167, 140, 155, 129, -]; - -const NPK_PRIV_ACC_B: [u8; 32] = [ - 32, 67, 72, 164, 106, 53, 66, 239, 141, 15, 52, 230, 136, 177, 2, 236, 207, 243, 134, 135, 210, - 143, 87, 232, 215, 128, 194, 120, 113, 224, 4, 165, -]; - const DEFAULT_PROGRAM_OWNER: [u32; 8] = [0, 0, 0, 0, 0, 0, 0, 0]; const PUB_ACC_A_INITIAL_BALANCE: u128 = 10000; @@ -133,26 +90,23 @@ pub fn initial_pub_accounts_private_keys() -> Vec Vec { - let key_chain_1 = KeyChain { - secret_spending_key: SecretSpendingKey(SSK_PRIV_ACC_A), - private_key_holder: PrivateKeyHolder { - nullifier_secret_key: NSK_PRIV_ACC_A, - viewing_secret_key: ViewingSecretKey::new(VSK_D_PRIV_ACC_A, VSK_Z_PRIV_ACC_A), - }, - nullifier_public_key: NullifierPublicKey(NPK_PRIV_ACC_A), - viewing_public_key: ViewingPublicKey::from_seed(&VSK_D_PRIV_ACC_A, &VSK_Z_PRIV_ACC_A), - }; +fn key_chain_from_ssk(ssk: [u8; 32]) -> KeyChain { + let secret_spending_key = SecretSpendingKey(ssk); + let private_key_holder = secret_spending_key.produce_private_key_holder(None); + let nullifier_public_key = private_key_holder.generate_nullifier_public_key(); + let viewing_public_key = private_key_holder.generate_viewing_public_key(); - let key_chain_2 = KeyChain { - secret_spending_key: SecretSpendingKey(SSK_PRIV_ACC_B), - private_key_holder: PrivateKeyHolder { - nullifier_secret_key: NSK_PRIV_ACC_B, - viewing_secret_key: ViewingSecretKey::new(VSK_D_PRIV_ACC_B, VSK_Z_PRIV_ACC_B), - }, - nullifier_public_key: NullifierPublicKey(NPK_PRIV_ACC_B), - viewing_public_key: ViewingPublicKey::from_seed(&VSK_D_PRIV_ACC_B, &VSK_Z_PRIV_ACC_B), - }; + KeyChain { + secret_spending_key, + private_key_holder, + nullifier_public_key, + viewing_public_key, + } +} + +fn initial_priv_accounts_private_keys() -> Vec { + let key_chain_1 = key_chain_from_ssk(SSK_PRIV_ACC_A); + let key_chain_2 = key_chain_from_ssk(SSK_PRIV_ACC_B); vec![ PrivateAccountPrivateInitialData { @@ -313,13 +267,35 @@ pub fn initial_state_testnet() -> V03State { mod tests { use std::str::FromStr as _; + use key_protocol::key_management::secret_holders::ViewingSecretKey; + use super::*; + const VSK_D_PRIV_ACC_A: [u8; 32] = [ + 4, 118, 187, 42, 14, 254, 144, 150, 125, 176, 205, 240, 109, 81, 234, 177, 244, 236, 108, + 71, 107, 10, 107, 169, 95, 134, 75, 193, 213, 57, 81, 218, + ]; + + const VSK_Z_PRIV_ACC_A: [u8; 32] = [ + 117, 29, 113, 136, 175, 148, 38, 38, 110, 220, 157, 155, 245, 13, 239, 244, 106, 126, 188, + 90, 204, 28, 82, 70, 200, 16, 219, 33, 43, 210, 125, 239, + ]; + + const VSK_D_PRIV_ACC_B: [u8; 32] = [ + 100, 59, 111, 232, 245, 32, 102, 179, 205, 119, 145, 238, 9, 235, 62, 38, 55, 252, 179, + 217, 219, 211, 6, 188, 85, 160, 68, 54, 61, 114, 102, 81, + ]; + + const VSK_Z_PRIV_ACC_B: [u8; 32] = [ + 123, 246, 87, 46, 116, 95, 39, 122, 251, 71, 207, 144, 70, 227, 120, 27, 98, 59, 67, 247, + 209, 194, 110, 231, 250, 247, 205, 243, 31, 142, 104, 208, + ]; + const PUB_ACC_A_TEXT_ADDR: &str = "6iArKUXxhUJqS7kCaPNhwMWt3ro71PDyBj7jwAyE2VQV"; const PUB_ACC_B_TEXT_ADDR: &str = "7wHg9sbJwc6h3NP1S9bekfAzB8CHifEcxKswCKUt3YQo"; - const PRIV_ACC_A_TEXT_ADDR: &str = "EVesBKsYRVtkjnTcsbk8tWHkBn2xZmzAXzwgrP3ZaVoZ"; - const PRIV_ACC_B_TEXT_ADDR: &str = "94MXhZnueurjX6v37CYDKVEKYBiyhYArvtEdceq2XDQP"; + const PRIV_ACC_A_TEXT_ADDR: &str = "GSx3EttJzQqhFPibttxguyhKXkiD4DJmA2dMmuszEmFv"; + const PRIV_ACC_B_TEXT_ADDR: &str = "Dec1rT4DynCafh6k5pmywLGUU16RpxcxCdrSVYq8ukaN"; #[test] fn pub_state_consistency() { @@ -358,78 +334,24 @@ mod tests { let init_private_accs_keys = initial_priv_accounts_private_keys(); let init_comms = initial_commitments(); + // `nsk`/`npk` carry no constants of their own: the key chains derive from `SSK_*`, and the + // two address canaries below pin H(PREFIX || npk || vpk || identifier), so drift anywhere + // in ask -> nsk -> npk or in vsk -> vpk moves one of them. Nothing is left unpinned. + // `VSK_*` stays pinned separately because it is the last value on the vsk -> vpk leg that + // a test can compare directly. assert_eq!( - init_private_accs_keys[0] - .key_chain - .secret_spending_key - .produce_private_key_holder(None) - .nullifier_secret_key, init_private_accs_keys[0] .key_chain .private_key_holder - .nullifier_secret_key - ); - assert_eq!( - init_private_accs_keys[0] - .key_chain - .secret_spending_key - .produce_private_key_holder(None) .viewing_secret_key, - init_private_accs_keys[0] - .key_chain - .private_key_holder - .viewing_secret_key - ); - assert_eq!( - init_private_accs_keys[0] - .key_chain - .private_key_holder - .generate_nullifier_public_key(), - init_private_accs_keys[0].key_chain.nullifier_public_key - ); - assert_eq!( - init_private_accs_keys[0] - .key_chain - .private_key_holder - .generate_viewing_public_key(), - init_private_accs_keys[0].key_chain.viewing_public_key - ); - - assert_eq!( - init_private_accs_keys[1] - .key_chain - .secret_spending_key - .produce_private_key_holder(None) - .nullifier_secret_key, - init_private_accs_keys[1] - .key_chain - .private_key_holder - .nullifier_secret_key + ViewingSecretKey::new(VSK_D_PRIV_ACC_A, VSK_Z_PRIV_ACC_A) ); assert_eq!( init_private_accs_keys[1] .key_chain - .secret_spending_key - .produce_private_key_holder(None) + .private_key_holder .viewing_secret_key, - init_private_accs_keys[1] - .key_chain - .private_key_holder - .viewing_secret_key - ); - assert_eq!( - init_private_accs_keys[1] - .key_chain - .private_key_holder - .generate_nullifier_public_key(), - init_private_accs_keys[1].key_chain.nullifier_public_key - ); - assert_eq!( - init_private_accs_keys[1] - .key_chain - .private_key_holder - .generate_viewing_public_key(), - init_private_accs_keys[1].key_chain.viewing_public_key + ViewingSecretKey::new(VSK_D_PRIV_ACC_B, VSK_Z_PRIV_ACC_B) ); assert_eq!( @@ -453,7 +375,7 @@ mod tests { assert_eq!( init_comms[0], PrivateAccountPublicInitialData { - npk: NullifierPublicKey(NPK_PRIV_ACC_A), + npk: init_private_accs_keys[0].key_chain.nullifier_public_key, vpk: init_private_accs_keys[0] .key_chain .viewing_public_key @@ -470,7 +392,7 @@ mod tests { assert_eq!( init_comms[1], PrivateAccountPublicInitialData { - npk: NullifierPublicKey(NPK_PRIV_ACC_B), + npk: init_private_accs_keys[1].key_chain.nullifier_public_key, vpk: init_private_accs_keys[1] .key_chain .viewing_public_key diff --git a/lez/wallet-ffi/Cargo.toml b/lez/wallet-ffi/Cargo.toml index 5440bee2b..25d5f2a50 100644 --- a/lez/wallet-ffi/Cargo.toml +++ b/lez/wallet-ffi/Cargo.toml @@ -14,6 +14,7 @@ crate-type = ["rlib", "cdylib", "staticlib"] wallet.workspace = true lee.workspace = true lee_core.workspace = true +common.workspace = true programs.workspace = true tokio.workspace = true diff --git a/lez/wallet-ffi/src/generic_transaction.rs b/lez/wallet-ffi/src/generic_transaction.rs index 7be6ddafc..6420e5e81 100644 --- a/lez/wallet-ffi/src/generic_transaction.rs +++ b/lez/wallet-ffi/src/generic_transaction.rs @@ -3,6 +3,7 @@ use std::{ ffi::{c_char, CString}, }; +use common::HashType; use lee::{privacy_preserving_transaction::circuit::ProgramWithDependencies, program::Program}; use crate::{ @@ -390,6 +391,43 @@ pub unsafe extern "C" fn wallet_ffi_send_generic_private_transaction( } } +/// Poll transaction for its status. +/// +/// # Parameters +/// - `handle`: Valid pointer to wallet handle. +/// - `tx_hash`: Bytes of a transaction hash, +/// - `transaction_status`: Valid pointer into `bool`. +/// +/// # Returns +/// - `true` if seen included, `false` othervise. +/// +/// # Safety +/// - `handle` must be a valid pointer. +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_poll_transaction_status( + handle: *mut WalletHandle, + tx_hash: FfiBytes32, + // ToDo: Replace with status enum. + transaction_status: *mut bool, +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + let wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {e}")); + return WalletFfiError::InternalError; + } + }; + + *transaction_status = block_on(wallet.poll_transaction(HashType(tx_hash.data))).is_ok(); + + WalletFfiError::Success +} + /// Free a transaction result returned by `wallet_ffi_send_generic_public_transaction` or /// `wallet_ffi_send_generic_private_transaction`. /// diff --git a/lez/wallet-ffi/src/keys.rs b/lez/wallet-ffi/src/keys.rs index 6a2c4d0bb..b3f52d22d 100644 --- a/lez/wallet-ffi/src/keys.rs +++ b/lez/wallet-ffi/src/keys.rs @@ -361,6 +361,7 @@ pub unsafe extern "C" fn wallet_ffi_free_account_identity( kind: _, account_id: _, key_path, + authorization_secret_key: _, nullifier_secret_key: _, nullifier_public_key: _, viewing_public_key, diff --git a/lez/wallet-ffi/src/types.rs b/lez/wallet-ffi/src/types.rs index 3779ba018..de2d94969 100644 --- a/lez/wallet-ffi/src/types.rs +++ b/lez/wallet-ffi/src/types.rs @@ -7,8 +7,12 @@ use std::{ str::FromStr as _, }; +use common::HashType; use lee::{Data, ProgramId, SharedSecretKey}; -use lee_core::{encryption::MlKem768EncapsulationKey, program::PdaSeed, NullifierPublicKey}; +use lee_core::{ + encryption::MlKem768EncapsulationKey, program::PdaSeed, AuthorizationSecretKey, + NullifierPublicKey, NullifierSecretKey, +}; use wallet::{account::AccountIdWithPrivacy, AccountIdentity}; use crate::error::WalletFfiError; @@ -156,6 +160,7 @@ impl Default for FfiAccountList { /// Result of a transfer operation. #[repr(C)] +#[derive(Debug)] pub struct FfiTransferResult { // TODO: Replace with HashType FFI representation /// Transaction hash (null-terminated string, or null on failure). @@ -173,6 +178,22 @@ impl Default for FfiTransferResult { } } +impl FfiTransferResult { + #[must_use] + /// Casting valid results hash into bytes. Effectively frees `FfiTransferResult`. + /// + /// # Safety + /// Field `tx_hash` must be a valid pointer into transaction hash. + pub unsafe fn tx_hash_bytes(self) -> FfiBytes32 { + let cstring = unsafe { CString::from_raw(self.tx_hash) }; + let rstring = cstring.into_string().expect("Must be a valid Rust string"); + + let hash_val = HashType::from_str(&rstring).expect("Must be a valid hex string"); + + FfiBytes32 { data: hash_val.0 } + } +} + // Helper functions to convert between Rust and FFI types impl FfiBytes32 { @@ -238,6 +259,7 @@ pub struct FfiAccountIdentity { pub account_id: FfiBytes32, /// C-compatible string. pub key_path: *mut c_char, + pub authorization_secret_key: FfiBytes32, pub nullifier_secret_key: FfiBytes32, pub nullifier_public_key: FfiBytes32, pub viewing_public_key: *const u8, @@ -251,6 +273,7 @@ impl Default for FfiAccountIdentity { kind: FfiAccountIdentityKind::Public, account_id: FfiBytes32::default(), key_path: std::ptr::null_mut(), + authorization_secret_key: FfiBytes32::default(), nullifier_secret_key: FfiBytes32::default(), nullifier_public_key: FfiBytes32::default(), viewing_public_key: std::ptr::null(), @@ -444,8 +467,7 @@ impl From for FfiAccountIdentity { } } AccountIdentity::PrivateShared { - nsk, - npk, + ask, vpk, identifier, } => { @@ -458,10 +480,13 @@ impl From for FfiAccountIdentity { ptr::null() }; + let nsk = NullifierSecretKey::from(&ask); + Self { kind: FfiAccountIdentityKind::PrivateShared, + authorization_secret_key: ask.0.into(), nullifier_secret_key: nsk.into(), - nullifier_public_key: npk.0.into(), + nullifier_public_key: NullifierPublicKey::from(&nsk).0.into(), viewing_public_key: vpk_data, viewing_public_key_len: vpk_len, identifier: identifier.into(), @@ -471,7 +496,6 @@ impl From for FfiAccountIdentity { AccountIdentity::PrivatePdaShared { account_id, nsk, - npk, vpk, identifier, } => { @@ -488,7 +512,7 @@ impl From for FfiAccountIdentity { kind: FfiAccountIdentityKind::PrivatePdaShared, account_id: account_id.into(), nullifier_secret_key: nsk.into(), - nullifier_public_key: npk.0.into(), + nullifier_public_key: NullifierPublicKey::from(&nsk).0.into(), viewing_public_key: vpk_data, viewing_public_key_len: vpk_len, identifier: identifier.into(), @@ -578,9 +602,16 @@ impl TryFrom<&FfiAccountIdentity> for AccountIdentity { Err(WalletFfiError::InvalidKeyValue) }?; + let ask = AuthorizationSecretKey(value.authorization_secret_key.data); + let nsk = NullifierSecretKey::from(&ask); + if value.nullifier_secret_key.data != nsk + || value.nullifier_public_key.data != NullifierPublicKey::from(&nsk).0 + { + return Err(WalletFfiError::InvalidKeyValue); + } + Ok(Self::PrivateShared { - nsk: value.nullifier_secret_key.data, - npk: NullifierPublicKey(value.nullifier_public_key.data), + ask, vpk, identifier: value.identifier.into(), }) @@ -599,10 +630,14 @@ impl TryFrom<&FfiAccountIdentity> for AccountIdentity { Err(WalletFfiError::InvalidKeyValue) }?; + let nsk = value.nullifier_secret_key.data; + if value.nullifier_public_key.data != NullifierPublicKey::from(&nsk).0 { + return Err(WalletFfiError::InvalidKeyValue); + } + Ok(Self::PrivatePdaShared { account_id: value.account_id.into(), - nsk: value.nullifier_secret_key.data, - npk: NullifierPublicKey(value.nullifier_public_key.data), + nsk, vpk, identifier: value.identifier.into(), }) @@ -658,10 +693,13 @@ impl From for AccountIdWithPrivacy { #[cfg(test)] mod tests { use lee::{AccountId, PrivateKey, PublicKey}; - use lee_core::{encryption::ViewingPublicKey, program::PdaSeed, PrivateAccountKind}; + use lee_core::{ + encryption::ViewingPublicKey, program::PdaSeed, AuthorizationSecretKey, NullifierSecretKey, + PrivateAccountKind, + }; use wallet::AccountIdentity; - use crate::{FfiAccountIdentity, FfiAccountIdentityKind}; + use crate::{error::WalletFfiError, FfiAccountIdentity, FfiAccountIdentityKind, FfiBytes32}; #[test] fn account_identity_roundtrip() { @@ -669,7 +707,8 @@ mod tests { let public_key = PublicKey::new_from_private_key(&private_key); let pub_acc_id = (&public_key).into(); - let nsk = [43; 32]; + let ask = AuthorizationSecretKey([43; 32]); + let nsk = NullifierSecretKey::from(&ask); let vpk = ViewingPublicKey::from_seed(&[44; 32], &[54; 32]); let npk = (&nsk).into(); let identifier = u128::from_le_bytes([45; 16]); @@ -708,15 +747,13 @@ mod tests { identifier, }; let acc_identity_7 = AccountIdentity::PrivateShared { - nsk, - npk, + ask, vpk: vpk.clone(), identifier, }; let acc_identity_8 = AccountIdentity::PrivatePdaShared { account_id: private_pda_acc_id, nsk, - npk, vpk, identifier, }; @@ -765,6 +802,10 @@ mod tests { FfiAccountIdentityKind::PrivatePdaShared ); + assert_eq!(ffi_acc_identity_7.nullifier_secret_key.data, nsk); + assert_eq!(ffi_acc_identity_7.nullifier_public_key.data, npk.0); + assert_eq!(ffi_acc_identity_8.nullifier_public_key.data, npk.0); + let acc_identity_res_1: AccountIdentity = (&ffi_acc_identity_1).try_into().unwrap(); let acc_identity_res_2: AccountIdentity = (&ffi_acc_identity_2).try_into().unwrap(); let acc_identity_res_2_5: AccountIdentity = (&ffi_acc_identity_2_5).try_into().unwrap(); @@ -785,4 +826,49 @@ mod tests { assert_eq!(acc_identity_res_7, acc_identity_7); assert_eq!(acc_identity_res_8, acc_identity_8); } + + #[test] + fn inconsistent_derived_keys_are_rejected() { + let ask = AuthorizationSecretKey([43; 32]); + let nsk = NullifierSecretKey::from(&ask); + let vpk = ViewingPublicKey::from_seed(&[44; 32], &[54; 32]); + let identifier = u128::from_le_bytes([45; 16]); + + let shared = AccountIdentity::PrivateShared { + ask, + vpk: vpk.clone(), + identifier, + }; + let pda_shared = AccountIdentity::PrivatePdaShared { + account_id: AccountId::new([46; 32]), + nsk, + vpk, + identifier, + }; + + let mut tampered_nsk: FfiAccountIdentity = shared.clone().into(); + tampered_nsk.nullifier_secret_key.data[0] ^= 1; + let mut tampered_npk: FfiAccountIdentity = shared.clone().into(); + tampered_npk.nullifier_public_key.data[0] ^= 1; + let mut zeroed: FfiAccountIdentity = shared.into(); + zeroed.nullifier_secret_key = FfiBytes32::default(); + zeroed.nullifier_public_key = FfiBytes32::default(); + let mut tampered_pda_npk: FfiAccountIdentity = pda_shared.clone().into(); + tampered_pda_npk.nullifier_public_key.data[0] ^= 1; + let mut zeroed_pda: FfiAccountIdentity = pda_shared.into(); + zeroed_pda.nullifier_public_key = FfiBytes32::default(); + + for inconsistent in [ + &tampered_nsk, + &tampered_npk, + &zeroed, + &tampered_pda_npk, + &zeroed_pda, + ] { + assert_eq!( + AccountIdentity::try_from(inconsistent).unwrap_err(), + WalletFfiError::InvalidKeyValue + ); + } + } } diff --git a/lez/wallet-ffi/wallet_ffi.h b/lez/wallet-ffi/wallet_ffi.h index bbd7da1fa..08bdecf37 100644 --- a/lez/wallet-ffi/wallet_ffi.h +++ b/lez/wallet-ffi/wallet_ffi.h @@ -249,6 +249,7 @@ typedef struct FfiAccountIdentity { * C-compatible string. */ char *key_path; + struct FfiBytes32 authorization_secret_key; struct FfiBytes32 nullifier_secret_key; struct FfiBytes32 nullifier_public_key; const uint8_t *viewing_public_key; @@ -668,6 +669,24 @@ enum WalletFfiError wallet_ffi_send_generic_private_transaction(struct WalletHan const struct FfiProgramWithDependencies *program_with_dependencies, struct FfiTransactionResult *out_result); +/** + * Poll transaction for its status. + * + * # Parameters + * - `handle`: Valid pointer to wallet handle. + * - `tx_hash`: Bytes of a transaction hash, + * - `transaction_status`: Valid pointer into `bool`. + * + * # Returns + * - `true` if seen included, `false` othervise. + * + * # Safety + * - `handle` must be a valid pointer. + */ +enum WalletFfiError wallet_ffi_poll_transaction_status(struct WalletHandle *handle, + struct FfiBytes32 tx_hash, + bool *transaction_status); + /** * Free a transaction result returned by `wallet_ffi_send_generic_public_transaction` or * `wallet_ffi_send_generic_private_transaction`. diff --git a/lez/wallet/src/account_manager.rs b/lez/wallet/src/account_manager.rs index bad162128..3cf1cce4e 100644 --- a/lez/wallet/src/account_manager.rs +++ b/lez/wallet/src/account_manager.rs @@ -4,9 +4,9 @@ use anyhow::Result; use keycard_wallet::KeycardWallet; use lee::{AccountId, PrivateKey, PublicKey, Signature}; use lee_core::{ - Commitment, CommitmentSetDigest, DummyInput, Identifier, InputAccountIdentity, MembershipProof, - NullifierPublicKey, NullifierSecretKey, NullifierWitness, PrivateAccountKind, PrivateWitness, - SharedSecretKey, WitnessKind, + AuthorizationSecretKey, Commitment, CommitmentSetDigest, DummyInput, Identifier, + InputAccountIdentity, MembershipProof, NullifierPublicKey, NullifierSecretKey, + NullifierWitness, PrivateAccountKind, PrivateWitness, SharedSecretKey, WitnessKind, account::{Account, AccountWithMetadata, Nonce}, compute_digest_for_path, encryption::{ @@ -45,20 +45,20 @@ pub enum AccountIdentity { identifier: Identifier, }, /// A shared regular private account with externally-provided keys (e.g. from GMS). - /// Uses standard `AccountId = from((&npk, identifier))` with authorized/unauthorized private - /// paths. Works with `authenticated_transfer` and all existing programs out of the box. + /// Carries the authorization secret key: the `nsk` and `npk` behind + /// `AccountId = from((&npk, &vpk, identifier))` are derived from it. + /// Works with `authenticated_transfer` and all existing programs out of the box. PrivateShared { - nsk: NullifierSecretKey, - npk: NullifierPublicKey, + ask: AuthorizationSecretKey, vpk: ViewingPublicKey, identifier: Identifier, }, /// A shared private PDA with externally-provided keys (e.g. from GMS). - /// `account_id` was derived via [`AccountId::for_private_pda`]. + /// `account_id` was derived via [`AccountId::for_private_pda`]; its `npk` is derived from + /// the `nsk` at use. PrivatePdaShared { account_id: AccountId, nsk: NullifierSecretKey, - npk: NullifierPublicKey, vpk: ViewingPublicKey, identifier: Identifier, }, @@ -102,20 +102,15 @@ impl fmt::Debug for AccountIdentity { .field("identifier", identifier) .finish(), Self::PrivateShared { - npk, - vpk, - identifier, - .. + vpk, identifier, .. } => f .debug_struct("PrivateShared") - .field("nsk", &"") - .field("npk", npk) + .field("ask", &"") .field("vpk", vpk) .field("identifier", identifier) .finish(), Self::PrivatePdaShared { account_id, - npk, vpk, identifier, .. @@ -123,7 +118,6 @@ impl fmt::Debug for AccountIdentity { .debug_struct("PrivatePdaShared") .field("account_id", account_id) .field("nsk", &"") - .field("npk", npk) .field("vpk", vpk) .field("identifier", identifier) .finish(), @@ -266,21 +260,10 @@ impl AccountManager { vpk, identifier, } => { - let acc = lee_core::account::Account::default(); - let auth_acc = AccountWithMetadata::new(acc, true, (&npk, &vpk, identifier)); - let random_seed = random_bytes(); - let pre = AccountPreparedData { - nsk: None, - npk, - identifier, - vpk, - pre_state: auth_acc, - proof: None, - random_seed, - is_pda: false, - }; - - State::Private(pre) + let account_id = lee::AccountId::from((&npk, &vpk, identifier)); + State::Private(private_foreign_acc_preparation( + account_id, npk, vpk, identifier, false, + )) } AccountIdentity::PrivatePdaOwned(account_id) => { let pre = private_key_tree_acc_preparation(wallet, account_id, true)?; @@ -291,31 +274,25 @@ impl AccountManager { npk, vpk, identifier, - } => { - let acc = lee_core::account::Account::default(); - let auth_acc = AccountWithMetadata::new(acc, false, account_id); - let random_seed = random_bytes(); - let pre = AccountPreparedData { - nsk: None, - npk, - identifier, - vpk, - pre_state: auth_acc, - proof: None, - random_seed, - is_pda: true, - }; - State::Private(pre) - } + } => State::Private(private_foreign_acc_preparation( + account_id, npk, vpk, identifier, true, + )), AccountIdentity::PrivateShared { - nsk, - npk, + ask, vpk, identifier, } => { + let nsk = NullifierSecretKey::from(&ask); + let npk = NullifierPublicKey::from(&nsk); let account_id = lee::AccountId::from((&npk, &vpk, identifier)); let pre = private_shared_acc_preparation( - wallet, account_id, nsk, npk, vpk, identifier, false, + wallet, + account_id, + nsk, + vpk, + identifier, + Some(ask), + false, ); State::Private(pre) @@ -323,12 +300,11 @@ impl AccountManager { AccountIdentity::PrivatePdaShared { account_id, nsk, - npk, vpk, identifier, } => { let pre = private_shared_acc_preparation( - wallet, account_id, nsk, npk, vpk, identifier, true, + wallet, account_id, nsk, vpk, identifier, None, true, ); State::Private(pre) @@ -448,7 +424,7 @@ impl AccountManager { kind: if pre.is_pda { WitnessKind::Pda { binding: None } } else { - WitnessKind::Regular + WitnessKind::Regular { ask: pre.ask } }, nullifier: match (pre.nsk, pre.proof.clone()) { (Some(nsk), Some(membership_proof)) => NullifierWitness::Update { @@ -527,6 +503,7 @@ impl AccountManager { } struct AccountPreparedData { + ask: Option, nsk: Option, npk: NullifierPublicKey, identifier: Identifier, @@ -550,7 +527,8 @@ fn private_key_tree_acc_preparation( let from_identifier = from_acc.kind.identifier(); let from_keys = &from_acc.key_chain; - let nsk = from_keys.private_key_holder.nullifier_secret_key; + let ask = from_keys.private_key_holder.authorization_secret_key; + let nsk = from_keys.private_key_holder.nullifier_secret_key(); let from_npk = from_keys.nullifier_public_key; let from_vpk = from_keys.viewing_public_key.clone(); @@ -561,6 +539,8 @@ fn private_key_tree_acc_preparation( let random_seed = random_bytes(); Ok(AccountPreparedData { + // A PDA is program-authorized and carries no credential of its own. + ask: (!is_pda).then_some(ask), nsk: Some(nsk), npk: from_npk, identifier: from_identifier, @@ -572,15 +552,40 @@ fn private_key_tree_acc_preparation( }) } -fn private_shared_acc_preparation( - wallet: &WalletCore, +/// Prepare a private account with no secret key knowledge, i.e. for inits. +fn private_foreign_acc_preparation( account_id: AccountId, - nsk: NullifierSecretKey, npk: NullifierPublicKey, vpk: ViewingPublicKey, identifier: Identifier, is_pda: bool, ) -> AccountPreparedData { + AccountPreparedData { + // The wallet holds no key for a recipient, so it can neither spend the account nor + // consent on its behalf. The program still claims it: a private claim never requires + // authorization. + ask: None, + nsk: None, + npk, + identifier, + vpk, + pre_state: AccountWithMetadata::new(Account::default(), false, account_id), + proof: None, + random_seed: random_bytes(), + is_pda, + } +} + +fn private_shared_acc_preparation( + wallet: &WalletCore, + account_id: AccountId, + nsk: NullifierSecretKey, + vpk: ViewingPublicKey, + identifier: Identifier, + ask: Option, + is_pda: bool, +) -> AccountPreparedData { + let npk = NullifierPublicKey::from(&nsk); let acc = wallet .storage() .key_chain() @@ -593,6 +598,7 @@ fn private_shared_acc_preparation( let random_seed = random_bytes(); AccountPreparedData { + ask, nsk: Some(nsk), npk, identifier, @@ -701,8 +707,7 @@ mod tests { #[test] fn private_shared_is_private() { let acc = AccountIdentity::PrivateShared { - nsk: [0; 32], - npk: NullifierPublicKey([1; 32]), + ask: AuthorizationSecretKey([0; 32]), vpk: ViewingPublicKey::from_seed(&[2_u8; 32], &[3_u8; 32]), identifier: 42, }; @@ -715,6 +720,7 @@ mod tests { let vpk = ViewingPublicKey::from_seed(&[0; 32], &[0; 32]); let pre_state = AccountWithMetadata::new(Account::default(), false, (&npk, &vpk, 0)); State::Private(AccountPreparedData { + ask: None, nsk: None, npk, identifier: 0, @@ -741,6 +747,23 @@ mod tests { } } + #[test] + fn foreign_private_init_is_unauthorized() { + let npk = NullifierPublicKey([7; 32]); + let vpk = ViewingPublicKey::from_seed(&[8; 32], &[9; 32]); + let account_id = lee::AccountId::from((&npk, &vpk, 0)); + let pre = private_foreign_acc_preparation(account_id, npk, vpk, 0, false); + + assert!(pre.ask.is_none()); + assert!(!pre.pre_state.is_authorized); + + let identities = manager(vec![State::Private(pre)]).account_identities(); + let InputAccountIdentity::Private(witness) = &identities[0] else { + panic!("expected a private witness"); + }; + assert!(matches!(witness.kind, WitnessKind::Regular { ask: None })); + } + #[test] fn dummy_inputs_default_pads_private_count_to_max() { let max = AccountManager::MAX_PRIVATE_ACCOUNTS; diff --git a/lez/wallet/src/lib.rs b/lez/wallet/src/lib.rs index d6d7d316c..96c25a1d8 100644 --- a/lez/wallet/src/lib.rs +++ b/lez/wallet/src/lib.rs @@ -29,7 +29,7 @@ use lee_core::{ BlockId, Commitment, CommitmentSetDigest, MembershipProof, SharedSecretKey, account::Nonce, program::InstructionData, }; -use log::{info, warn}; +use log::warn; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use storage::Storage; use tokio::io::AsyncWriteExt as _; @@ -305,7 +305,7 @@ impl WalletCore { // Ensure data is flushed to disk before returning to prevent race conditions config_file.sync_all().await?; - info!("Stored data at {}", self.config_path.display()); + log::info!("Stored data at {}", self.config_path.display()); Ok(()) } @@ -373,23 +373,19 @@ impl WalletCore { .key_chain() .shared_private_account(account_id)?; let keys = self.storage.key_chain().derive_shared_account_keys(entry)?; - let nsk = keys.nullifier_secret_key; - let npk = keys.generate_nullifier_public_key(); let vpk = keys.generate_viewing_public_key(); let identifier = entry.identifier; if entry.pda_seed.is_some() { Some(AccountIdentity::PrivatePdaShared { account_id, - nsk, - npk, + nsk: keys.nullifier_secret_key(), vpk, identifier, }) } else { Some(AccountIdentity::PrivateShared { - nsk, - npk, + ask: keys.authorization_secret_key, vpk, identifier, }) @@ -444,7 +440,7 @@ impl WalletCore { return Ok(()); } - info!("Scanning shared account {account_id:#?} from genesis to block {cursor}"); + log::info!("Scanning shared account {account_id:#?} from genesis to block {cursor}"); let mut index = NullifierIndex::default(); index.track_initialization(account_id); @@ -989,7 +985,7 @@ impl WalletCore { &key_chain.viewing_public_key, &kind, ); - let nsk = key_chain.private_key_holder.nullifier_secret_key; + let nsk = key_chain.private_key_holder.nullifier_secret_key(); (account_id, kind, res_acc, nsk) }) }) @@ -998,7 +994,7 @@ impl WalletCore { .collect::>(); for (affected_account_id, kind, new_acc, nsk) in affected_accounts { - info!( + log::info!( "Received new account for account_id {affected_account_id:#?} with account object {new_acc:#?}" ); // Await the account's next update by its nullifier, so later updates @@ -1028,7 +1024,7 @@ impl WalletCore { let keys = self.storage.key_chain().derive_shared_account_keys(entry)?; let npk = keys.generate_nullifier_public_key(); let vpk = keys.generate_viewing_public_key(); - let nsk = keys.nullifier_secret_key; + let nsk = keys.nullifier_secret_key(); let vsk = keys.viewing_secret_key; Some((account_id, npk, vpk, vsk, nsk)) }) @@ -1049,7 +1045,7 @@ impl WalletCore { continue; }; if let Some((_kind, new_acc)) = decrypt_note_at(message, ciph_id, &shared_secret) { - info!("Synced shared account {account_id:#?} with new state {new_acc:#?}"); + log::info!("Synced shared account {account_id:#?} with new state {new_acc:#?}"); index.track(account_id, &new_acc, &nsk); self.storage .key_chain_mut() diff --git a/lez/wallet/src/poller.rs b/lez/wallet/src/poller.rs index 80f2a0c59..f20205f59 100644 --- a/lez/wallet/src/poller.rs +++ b/lez/wallet/src/poller.rs @@ -3,7 +3,7 @@ use std::time::Duration; use anyhow::Result; use common::{HashType, block::Block, transaction::LeeTransaction}; use lee_core::BlockId; -use log::{info, warn}; +use log::warn; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use tokio::task::JoinSet; @@ -35,9 +35,9 @@ impl TxPoller { pub async fn poll_tx(&self, tx_hash: HashType) -> Result<(LeeTransaction, BlockId)> { let max_blocks_to_query = self.polling_max_blocks_to_query; - info!("Starting poll for transaction {tx_hash}"); + log::info!("Starting poll for transaction {tx_hash}"); for poll_id in 1..max_blocks_to_query { - info!("Poll {poll_id}"); + log::info!("Poll {poll_id}"); let mut try_error_counter = 0_u64; diff --git a/lez/wallet/src/storage/key_chain.rs b/lez/wallet/src/storage/key_chain.rs index 3f5eba05a..3acd57ed0 100644 --- a/lez/wallet/src/storage/key_chain.rs +++ b/lez/wallet/src/storage/key_chain.rs @@ -365,7 +365,7 @@ impl UserKeyChain { &found.key_chain.viewing_public_key, found.kind, ); - let nsk = found.key_chain.private_key_holder.nullifier_secret_key; + let nsk = found.key_chain.private_key_holder.nullifier_secret_key(); index.track(account_id, found.account, &nsk); } @@ -374,7 +374,7 @@ impl UserKeyChain { let Some(keys) = self.derive_shared_account_keys(entry) else { continue; }; - let nsk = keys.nullifier_secret_key; + let nsk = keys.nullifier_secret_key(); index.track(account_id, &entry.account, &nsk); } @@ -426,14 +426,14 @@ impl UserKeyChain { &keys.viewing_secret_key.d, &keys.viewing_secret_key.z, )?; - (keys.nullifier_secret_key, secret, true) + (keys.nullifier_secret_key(), secret, true) } else { let found = self.private_account(account_id)?; let secret = found .key_chain .calculate_shared_secret_receiver(&encrypted.epk)?; ( - found.key_chain.private_key_holder.nullifier_secret_key, + found.key_chain.private_key_holder.nullifier_secret_key(), secret, false, ) @@ -459,14 +459,14 @@ impl UserKeyChain { return Some(NullifierIndex::next_update_nullifier( account_id, &entry.account, - &keys.nullifier_secret_key, + &keys.nullifier_secret_key(), )); } let acc = self.private_account(account_id)?; Some(NullifierIndex::next_update_nullifier( account_id, acc.account, - &acc.key_chain.private_key_holder.nullifier_secret_key, + &acc.key_chain.private_key_holder.nullifier_secret_key(), )) } @@ -898,7 +898,7 @@ mod tests { let mut kc = UserKeyChain::default(); let key_chain = KeyChain::new_os_random(); - let nsk = key_chain.private_key_holder.nullifier_secret_key; + let nsk = key_chain.private_key_holder.nullifier_secret_key(); let identifier = 0; let account_id = AccountId::for_private_account( &key_chain.nullifier_public_key, @@ -966,7 +966,7 @@ mod tests { let keys = holder.derive_regular_shared_account_keys_from_identifier(identifier); let npk = keys.generate_nullifier_public_key(); let vpk = keys.generate_viewing_public_key(); - let nsk = keys.nullifier_secret_key; + let nsk = keys.nullifier_secret_key(); let account_id = AccountId::from((&npk, &vpk, identifier)); kc.insert_group_key_holder(label.clone(), holder); @@ -1036,7 +1036,7 @@ mod tests { let keys = holder.derive_regular_shared_account_keys_from_identifier(identifier); let npk = keys.generate_nullifier_public_key(); let vpk = keys.generate_viewing_public_key(); - let nsk = keys.nullifier_secret_key; + let nsk = keys.nullifier_secret_key(); let account_id = AccountId::from((&npk, &vpk, identifier)); kc.insert_group_key_holder(label.clone(), holder); diff --git a/monitoring/grafana/dashboards/sequencer.json b/monitoring/grafana/dashboards/sequencer.json index ede0ae4bd..afc6c2756 100644 --- a/monitoring/grafana/dashboards/sequencer.json +++ b/monitoring/grafana/dashboards/sequencer.json @@ -346,6 +346,86 @@ ], "title": "Submitted vs failed transactions (per minute)", "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "color": { "mode": "fixed", "fixedColor": "red" }, "unit": "short", "decimals": 0 }, + "overrides": [ ] + }, + "gridPos": { "h": 7, "w": 6, "x": 0, "y": 41 }, + "id": 11, + "options": { + "colorMode": "value", + "graphMode": "area", + "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "cross_zone_dispatches_retired_total", + "legendFormat": "given up on", + "refId": "A" + } + ], + "title": "Cross-zone deliveries given up on since startup", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { "color": { "mode": "fixed", "fixedColor": "orange" }, "unit": "short", "decimals": 0 }, + "overrides": [ ] + }, + "gridPos": { "h": 7, "w": 6, "x": 6, "y": 41 }, + "id": 12, + "options": { + "colorMode": "value", + "graphMode": "area", + "reduceOptions": { "calcs": [ "lastNotNull" ], "fields": "", "values": false } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "cross_zone_dead_letter_dispatches", + "legendFormat": "retained", + "refId": "A" + } + ], + "title": "Dead letters retained", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { + "defaults": { + "custom": { "drawStyle": "line", "lineWidth": 1, "fillOpacity": 10 }, + "unit": "short", + "min": 0.0 + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "given up on" }, + "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] + } + ] + }, + "gridPos": { "h": 7, "w": 12, "x": 12, "y": 41 }, + "id": 13, + "options": { + "legend": { "displayMode": "list", "placement": "bottom", "calcs": [ "last", "max" ] }, + "tooltip": { "mode": "single" } + }, + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "expr": "rate(cross_zone_dispatches_retired_total[$__rate_interval]) * 60", + "legendFormat": "given up on", + "refId": "A" + } + ], + "title": "Cross-zone deliveries given up on (per minute)", + "type": "timeseries" } ], "refresh": "5s", diff --git a/test_fixtures/fixtures/prebuilt_sequencer_db.dump b/test_fixtures/fixtures/prebuilt_sequencer_db.dump index ec2cac386..0fe43eded 100644 Binary files a/test_fixtures/fixtures/prebuilt_sequencer_db.dump and b/test_fixtures/fixtures/prebuilt_sequencer_db.dump differ diff --git a/test_fixtures/src/bin/regenerate_test_fixture.rs b/test_fixtures/src/bin/regenerate_test_fixture.rs index 038d4b0cd..22e529513 100644 --- a/test_fixtures/src/bin/regenerate_test_fixture.rs +++ b/test_fixtures/src/bin/regenerate_test_fixture.rs @@ -57,7 +57,7 @@ async fn generate_prebuilt_fixture(dest: &Path) -> Result<()> { .context("Failed to setup Sequencer for fixture generation")?; let (mut wallet, _temp_wallet_dir, _wallet_password) = setup_wallet( - sequencer_handle.addr(), + &[sequencer_handle.addr()], &initial_public_accounts, &initial_private_accounts, WalletConfigOverrides::default(), @@ -76,7 +76,9 @@ async fn generate_prebuilt_fixture(dest: &Path) -> Result<()> { drop(wallet); drop(sequencer_handle); - let db_path = temp_sequencer_dir.path().join("rocksdb"); + let db_path = temp_sequencer_dir + .path() + .join(format!("rocksdb-{}", config::bedrock_channel_id())); let store = open_store_with_retry(&db_path) .await .context("Failed to reopen sequencer store after shutdown")?; diff --git a/test_fixtures/src/config.rs b/test_fixtures/src/config.rs index 894ad12a7..f07b92e86 100644 --- a/test_fixtures/src/config.rs +++ b/test_fixtures/src/config.rs @@ -77,6 +77,26 @@ impl std::fmt::Display for UrlProtocol { } } +#[derive(Debug, Clone, Copy)] +/// Config for test context in multi-node case. +pub struct MultiNodeTestContextConfig { + pub num_nodes: usize, + pub bedrock_channel: ChannelId, +} + +impl Default for MultiNodeTestContextConfig { + fn default() -> Self { + Self { + num_nodes: 1, + bedrock_channel: bedrock_channel_id(), + } + } +} + +#[expect( + clippy::too_many_arguments, + reason = "All fields are necessary and better to keep separate" +)] pub fn sequencer_config( partial: SequencerPartialConfig, home: PathBuf, @@ -85,6 +105,7 @@ pub fn sequencer_config( funding_key: ZkPublicKey, genesis_transactions: Vec, cross_zone: Option, + signing_key: Option<[u8; 32]>, ) -> Result { let SequencerPartialConfig { max_num_tx_in_block, @@ -101,7 +122,7 @@ pub fn sequencer_config( block_create_timeout, retry_pending_blocks_timeout: Duration::from_secs(5), genesis: genesis_transactions, - signing_key: SEQUENCER_SIGNING_KEY, + signing_key: signing_key.unwrap_or(SEQUENCER_SIGNING_KEY), bedrock_config: BedrockConfig { channel_id, node_url: addr_to_url(UrlProtocol::Http, bedrock_addr) @@ -199,13 +220,19 @@ pub fn genesis_from_accounts( .collect() } -pub fn wallet_config(sequencer_addr: SocketAddr) -> Result { - Ok(WalletConfig { - sequencers: vec![SequencerConnectionData { - sequencer_addr: addr_to_url(UrlProtocol::Http, sequencer_addr) +pub fn wallet_config(sequencer_addrs: &[SocketAddr]) -> Result { + let mut sequencers = vec![]; + + for addr in sequencer_addrs { + sequencers.push(SequencerConnectionData { + sequencer_addr: addr_to_url(UrlProtocol::Http, *addr) .context("Failed to convert sequencer addr to URL")?, basic_auth: None, - }], + }); + } + + Ok(WalletConfig { + sequencers, seq_poll_timeout: Duration::from_secs(30), seq_tx_poll_max_blocks: 15, seq_poll_max_retries: 10, @@ -269,6 +296,31 @@ pub fn bedrock_channel_id_b() -> ChannelId { ChannelId::from(channel_id) } +/// Generate sequencer signing key from `u32` number via repeating le bytes 8 times. +#[must_use] +pub fn sequencer_signing_key_from_seed(seed: u32) -> [u8; 32] { + seed.to_le_bytes() + .repeat(8) + .try_into() + .unwrap_or_else(|_| unreachable!()) +} + +/// Generate bedrock channel id from `u32` number via repeating le bytes 8 times. +/// +/// Counting from the end of `u32` to guarantee, that it is different from +/// `sequencer_signing_key_from_seed`. +#[must_use] +pub fn bedrock_channel_id_from_seed(seed: u32) -> ChannelId { + let channel_id: [u8; 32] = + // Useless in this case, but will make clippy happy + u32::MAX.saturating_sub(seed) + .to_le_bytes() + .repeat(8) + .try_into() + .unwrap_or_else(|_| unreachable!()); + ChannelId::from(channel_id) +} + /// Funding key of the Bedrock test node, matching `funding_pk` in `bedrock/node-config.yaml`. #[must_use] pub fn bedrock_funding_key() -> ZkPublicKey { diff --git a/test_fixtures/src/indexer_client.rs b/test_fixtures/src/indexer_client.rs index 5641d8243..ea7a9e9e4 100644 --- a/test_fixtures/src/indexer_client.rs +++ b/test_fixtures/src/indexer_client.rs @@ -9,14 +9,13 @@ use std::ops::Deref; use anyhow::{Context as _, Result}; use jsonrpsee::ws_client::{WsClient, WsClientBuilder}; -use log::info; use url::Url; pub struct IndexerClient(WsClient); impl IndexerClient { pub async fn new(indexer_url: &Url) -> Result { - info!("Connecting to Indexer at {indexer_url}"); + log::info!("Connecting to Indexer at {indexer_url}"); let client = WsClientBuilder::default() .build(indexer_url) .await diff --git a/test_fixtures/src/lib.rs b/test_fixtures/src/lib.rs index 9077feaf5..ccbfaf93e 100644 --- a/test_fixtures/src/lib.rs +++ b/test_fixtures/src/lib.rs @@ -1,17 +1,20 @@ //! 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 std::{collections::HashMap, net::SocketAddr, path::Path, sync::LazyLock}; use anyhow::{Context as _, Result}; use common::{HashType, transaction::LeeTransaction}; use futures::FutureExt as _; -use indexer_service::IndexerHandle; -use lee::{AccountId, PrivacyPreservingTransaction}; +use indexer_service::{ChannelId, IndexerHandle}; +use lee::{AccountId, PrivacyPreservingTransaction, PrivateKey}; use lee_core::Commitment; use log::{debug, error}; -use sequencer_core::config::GenesisAction; -use sequencer_service::SequencerHandle; +use sequencer_core::{ + block_publisher::{Ed25519Key, post_channel_config}, + config::GenesisAction, +}; +use sequencer_service::{BedrockConfig, CrossZoneConfig, SequencerHandle, default_priority_fee}; use sequencer_service_rpc::{RpcClient as _, SequencerClient}; use serde::Serialize; use tempfile::TempDir; @@ -22,6 +25,10 @@ use wallet::{ }; use crate::{ + config::{ + InitialPrivateAccountForWallet, MultiNodeTestContextConfig, SequencerPartialConfig, + bedrock_funding_key, + }, indexer_client::IndexerClient, setup::{ SequencerSetup, setup_bedrock_node, setup_indexer, @@ -36,6 +43,10 @@ pub mod setup; // TODO: Remove this and control time from tests pub const TIME_TO_WAIT_FOR_BLOCK_SECONDS: u64 = 12; +/// 1 s bedrock slots: rotate the turn every ~20 s of tenure; steal a stalled +/// turn after ~30 s (bounds the stall while B is accredited but not started). +const POSTING_TIMEFRAME_SLOTS: u32 = 20; +const POSTING_TIMEOUT_SLOTS: u32 = 30; pub(crate) const BEDROCK_SERVICE_WITH_OPEN_PORT: &str = "logos-blockchain-node-0"; pub(crate) const BEDROCK_SERVICE_PORT: u16 = 18080; @@ -70,56 +81,184 @@ pub struct DiskSizes { pub wallet_bytes: u64, } +pub struct SequencerComponents { + pub sequencer_handle: SequencerHandle, + pub temp_sequencer_dir: TempDir, + pub sequencer_client: SequencerClient, +} + +pub struct WalletComponents { + wallet: WalletCore, + wallet_password: String, + temp_wallet_dir: TempDir, +} + +pub struct TestContextZone { + wallet: Option, + /// Order of sequencers matter, as first one starts a channel, other ones connect in order. + sequencers: Vec, + indexer: Option, +} + /// Test context which sets up a sequencer and a wallet for integration tests. /// /// It's memory and logically safe to create multiple instances of this struct in parallel tests, /// as each instance uses its own temporary directories for sequencer and wallet data. -// NOTE: Order of fields is important for proper drop order. pub struct TestContext { - sequencer_client: SequencerClient, - wallet: WalletCore, - wallet_password: String, - /// Optional to move out value in Drop. - sequencer_handle: Option, - indexer_components: Option, + zones: HashMap, bedrock_compose: DockerCompose, bedrock_addr: SocketAddr, - temp_sequencer_dir: TempDir, - temp_wallet_dir: TempDir, } impl TestContext { - /// Create new test context. + /// Create new test context with singular config(1 zone, 1 sequencer). pub async fn new() -> Result { - Self::builder().build().await + MultiZoneTestContextBuilder::default() + .with_zone(ZoneTestContextBuilder::new( + MultiNodeTestContextConfig::default(), + )) + .build() + .await } - /// Get a builder for the test context to customize its configuration. + /// Reference for the default zone(in case if only one present). + /// + /// Panics in case if there is more than one zone. #[must_use] - pub fn builder() -> TestContextBuilder { - TestContextBuilder::new() + pub fn default_zone(&self) -> &TestContextZone { + assert!(self.zones.len() == 1); + + self.zones + .values() + .next() + .expect("Must be at least one zone") } - /// Get reference to the wallet. + /// Reference for the default sequencer component(in case, if only one zone exists and only + /// one sequencer exists). + /// + /// Panics in case if there is more than one zone. #[must_use] - pub const fn wallet(&self) -> &WalletCore { - &self.wallet + pub fn default_sequencer_component(&self) -> &SequencerComponents { + self.default_zone() + .sequencers + .first() + .expect("Must be at least one sequencer component") } + /// Iterator over all zones in random order. + pub fn zones_iter(&self) -> impl Iterator { + self.zones.iter() + } + + /// Iterator over all sequencer components in zone in order. + #[must_use] + pub fn sequencer_components_iter( + &self, + channel_id: ChannelId, + ) -> Option> { + self.zones + .get(&channel_id) + .map(|zone| zone.sequencers.iter()) + } + + /// Reference for the default sequencer component for a zone (in case, if only one sequencer + /// exists). + #[must_use] + pub fn zone_default_sequencer_component(&self, channel_id: ChannelId) -> &SequencerComponents { + self.sequencer_components_iter(channel_id) + .unwrap() + .next() + .unwrap() + } + + /// Mutable reference for the default zone(in case if only one present). + /// + /// Panics in case if there is more than one zone. + pub fn default_zone_mut(&mut self) -> &mut TestContextZone { + assert!(self.zones.len() == 1); + + self.zones + .values_mut() + .next() + .expect("Must be at least one zone") + } + + /// Mutable reference for the default sequencer component(in case, if only one zone exists and + /// only one sequencer exists). + /// + /// Panics in case if there is more than one zone. + pub fn default_sequencer_component_mut(&mut self) -> &mut SequencerComponents { + self.default_zone_mut() + .sequencers + .iter_mut() + .next() + .expect("Must be at least one integration component") + } + + /// Get reference to the default wallet. + /// + /// Panics in case if there is more than one zone. + #[must_use] + pub fn wallet(&self) -> &WalletCore { + &self.default_zone().wallet.as_ref().unwrap().wallet + } + + /// Get password of the default wallet password. + /// + /// Panics in case if there is more than one zone. #[must_use] pub fn wallet_password(&self) -> &str { - &self.wallet_password + &self.default_zone().wallet.as_ref().unwrap().wallet_password } - /// Get mutable reference to the wallet. - pub const fn wallet_mut(&mut self) -> &mut WalletCore { - &mut self.wallet + /// Get mutable reference to default the wallet. + /// + /// Panics in case if there is more than one zone. + pub fn wallet_mut(&mut self) -> &mut WalletCore { + &mut self.default_zone_mut().wallet.as_mut().unwrap().wallet } - /// Get reference to the sequencer client. + /// Get reference to the zone wallet. #[must_use] - pub const fn sequencer_client(&self) -> &SequencerClient { - &self.sequencer_client + pub fn wallet_zone(&self, channel_id: ChannelId) -> Option<&WalletCore> { + self.zones + .get(&channel_id) + .map(|val| &val.wallet.as_ref().unwrap().wallet) + } + + /// Get password of the zone wallet. + #[must_use] + pub fn wallet_password_zone(&self, channel_id: ChannelId) -> Option<&str> { + self.zones + .get(&channel_id) + .map(|val| val.wallet.as_ref().unwrap().wallet_password.as_str()) + } + + /// Get mutable reference to the zone wallet. + pub fn wallet_mut_zone(&mut self, channel_id: ChannelId) -> Option<&mut WalletCore> { + self.zones + .get_mut(&channel_id) + .map(|val| &mut val.wallet.as_mut().unwrap().wallet) + } + + /// Get reference to the sequencer client in default case (1 zone, 1 sequencer). + /// + /// Panics in case if there is more than one zone. + #[must_use] + pub fn sequencer_client(&self) -> &SequencerClient { + &self.default_sequencer_component().sequencer_client + } + + /// Get reference to the sequencer client by node zone `channel_id` and its `id`. + #[must_use] + pub fn sequencer_client_by_node_ids( + &self, + channel_id: ChannelId, + id: usize, + ) -> Option<&SequencerClient> { + let val = self.zones.get(&channel_id)?; + val.sequencers.get(id).map(|vall| &vall.sequencer_client) } /// Get the Bedrock Node address. @@ -128,62 +267,127 @@ impl TestContext { self.bedrock_addr } - /// Get reference to the indexer. + /// Get reference to the default indexer(1 zone). /// /// # Panics /// /// Panics if the indexer is not enabled in the test context. See - /// [`TestContextBuilder::disable_indexer()`]. + /// [`ZoneTestContextBuilder::disable_indexer()`]. + /// + /// Panics in case if there is more than one zone. #[must_use] pub fn indexer(&self) -> &IndexerHandle { - self.indexer_components + &self + .default_zone() + .indexer .as_ref() - .map(|components| &components.indexer_handle) .expect("Called `TestContext::indexer()` on context with disabled indexer") + .indexer_handle } - /// Get the indexer's bound socket address. + /// Get the default indexer's(1 zone) bound socket address. /// /// # Panics /// /// Panics if the indexer is not enabled in the test context. + /// + /// Panics in case if there is more than one zone. #[must_use] pub fn indexer_addr(&self) -> SocketAddr { self.indexer().addr() } - /// Get reference to the indexer client. + /// Get reference to the default indexer(1 zone) client. /// /// # Panics /// /// Panics if the indexer is not enabled in the test context. See - /// [`TestContextBuilder::disable_indexer()`]. + /// [`ZoneTestContextBuilder::disable_indexer()`]. + /// + /// Panics in case if there is more than one zone. #[must_use] pub fn indexer_client(&self) -> &IndexerClient { - self.indexer_components + &self + .default_zone() + .indexer .as_ref() - .map(|components| &components.indexer_client) - .expect("Called `TestContext::indexer_client()` on context with disabled indexer") + .expect("Called `TestContext::indexer()` on context with disabled indexer") + .indexer_client + } + + /// Get reference to the indexer for corresponding zone. + /// + /// # Panics + /// + /// Panics if the indexer is not enabled in the test context. See + /// [`ZoneTestContextBuilder::disable_indexer()`]. + #[must_use] + pub fn indexer_zone(&self, channel_id: ChannelId) -> Option<&IndexerHandle> { + let val = self.zones.get(&channel_id)?; + val.indexer.as_ref().map(|val| &val.indexer_handle) + } + + /// Get the default indexer's bound socket address for corresponding zone. + /// + /// # Panics + /// + /// Panics if the indexer is not enabled in the test context. + #[must_use] + pub fn indexer_addr_zone(&self, channel_id: ChannelId) -> Option { + self.indexer_zone(channel_id) + .map(indexer_service::IndexerHandle::addr) + } + + /// Get reference to the indexer client for corresponding zone. + /// + /// # Panics + /// + /// Panics if the indexer is not enabled in the test context. See + /// [`ZoneTestContextBuilder::disable_indexer()`]. + #[must_use] + pub fn indexer_client_zone(&self, channel_id: ChannelId) -> Option<&IndexerClient> { + let val = self.zones.get(&channel_id)?; + val.indexer.as_ref().map(|val| &val.indexer_client) } /// Recursively-sized bytes on disk for sequencer + indexer + wallet tempdirs. /// Indexer bytes are zero if the indexer is disabled. + /// Wallet bytes are zero if the wallet 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()), + sequencer_bytes: self.zones.values().fold(0, |acc, zone| { + acc.saturating_add(zone.sequencers.iter().fold(0, |accc, component| { + accc.saturating_add(dir_size_bytes(component.temp_sequencer_dir.path())) + })) + }), + indexer_bytes: self.zones.values().fold(0, |acc, zone| { + acc.saturating_add( + zone.indexer + .as_ref() + .map_or(0, |val| dir_size_bytes(val.temp_dir.path())), + ) + }), + wallet_bytes: self.zones.values().fold(0, |acc, zone| { + acc.saturating_add( + zone.wallet + .as_ref() + .map_or(0, |val| dir_size_bytes(val.temp_wallet_dir.path())), + ) + }), } } - /// Get existing public account IDs in the wallet. + /// Get default(1 zone) existing public account IDs in the wallet. + /// + /// Panics in case if there is more than one zone. #[must_use] pub fn existing_public_accounts(&self) -> Vec { - self.wallet + self.default_zone() + .wallet + .as_ref() + .unwrap() + .wallet .storage() .key_chain() .public_account_ids() @@ -191,43 +395,84 @@ impl TestContext { .collect() } - /// Get existing private account IDs in the wallet. + /// Get default (1 zone) existing private account IDs in the wallet. + /// + /// Panics in case if there is more than one zone. #[must_use] pub fn existing_private_accounts(&self) -> Vec { - self.wallet + self.default_zone() + .wallet + .as_ref() + .unwrap() + .wallet .storage() .key_chain() .private_account_ids() .map(|(account_id, _idx)| account_id) .collect() } + + /// Get existing public account IDs in the wallet. + #[must_use] + pub fn existing_public_accounts_zone(&self, channel_id: ChannelId) -> Option> { + self.wallet_zone(channel_id).map(|wallet_ref| { + wallet_ref + .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_zone(&self, channel_id: ChannelId) -> Option> { + self.wallet_zone(channel_id).map(|wallet_ref| { + wallet_ref + .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, + zones, bedrock_compose, bedrock_addr: _, - indexer_components: _, - sequencer_client: _, - wallet: _, - wallet_password: _, - temp_sequencer_dir: _, - temp_wallet_dir: _, } = self; - let mut 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:#}" - ); + #[expect( + clippy::iter_over_hash_type, + reason = "Zones can be stopped in any order" + )] + for TestContextZone { + wallet: _, + sequencers, + indexer: _, + } in zones.values_mut() + { + for SequencerComponents { + sequencer_handle, + temp_sequencer_dir: _, + sequencer_client: _, + } in sequencers.iter_mut() + { + 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 @@ -250,25 +495,40 @@ impl Drop for TestContext { } } -pub struct TestContextBuilder { +#[derive(Debug)] +pub struct ZoneTestContextBuilder { genesis_transactions: Option>, sequencer_partial_config: Option, enable_indexer: bool, + enable_wallet: bool, wallet_config_overrides: WalletConfigOverrides, from_scratch: bool, + mn_config: MultiNodeTestContextConfig, + cross_zone_config: Option, } -impl TestContextBuilder { - fn new() -> Self { +impl ZoneTestContextBuilder { + #[must_use] + pub fn new(mn_config: MultiNodeTestContextConfig) -> Self { Self { genesis_transactions: None, sequencer_partial_config: None, enable_indexer: true, + enable_wallet: true, wallet_config_overrides: WalletConfigOverrides::default(), from_scratch: false, + mn_config, + // There is no point providing cross zone config here, it is easier to provide it from + // builder pattern. + cross_zone_config: None, } } + #[must_use] + pub const fn bedrock_channel(&self) -> ChannelId { + self.mn_config.bedrock_channel + } + /// Override wallet config fields (e.g. polling timeouts) for the wallet built by this context. #[must_use] pub fn with_wallet_config_overrides( @@ -279,12 +539,16 @@ impl TestContextBuilder { self } + /// Set the genesis transactions to apply when initializing the sequencer. + /// If not set, the sequencer will be initialized from a prebuilt database dump. #[must_use] pub fn with_genesis(mut self, genesis_transactions: Vec) -> Self { self.genesis_transactions = Some(genesis_transactions); self } + /// Set the sequencer partial config to apply when initializing the sequencer. + /// If not set, the sequencer will be initialized with default one. #[must_use] pub const fn with_sequencer_partial_config( mut self, @@ -313,33 +577,51 @@ impl TestContextBuilder { self } - pub async fn build(self) -> Result { + /// Exclude wallet from test context. + /// Wallet is enabled by default. + /// + /// Methods like [`TestContext::wallet()`] will panic if + /// called when wallet is disabled. + #[must_use] + pub const fn disable_wallet(mut self) -> Self { + self.enable_wallet = false; + self + } + + /// Set the cross zone config to apply when initializing the zone. + /// If not set, the zone will be initialized with default one. + #[must_use] + pub fn with_cross_zone(mut self, cross_zone_config: Option) -> Self { + self.cross_zone_config = cross_zone_config; + self + } + + pub async fn build(self, bedrock_addr: SocketAddr) -> Result { let Self { genesis_transactions, sequencer_partial_config, enable_indexer, + enable_wallet, wallet_config_overrides, from_scratch, + mn_config, + cross_zone_config, } = self; - // Ensure logger is initialized only once - *LOGGER; - debug!("Test context setup"); // The fixture bakes in the default accounts + genesis, so custom genesis / from_scratch // must build live. Otherwise load the fixture (fails if it is missing). let use_prebuilt = !from_scratch && genesis_transactions.is_none(); - 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, config::bedrock_channel_id(), None) - .await - .context("Failed to setup Indexer")?; + let (indexer_handle, temp_indexer_dir) = setup_indexer( + bedrock_addr, + mn_config.bedrock_channel, + cross_zone_config.clone(), + ) + .await + .context("Failed to setup Indexer")?; let indexer_client = setup::indexer_client(indexer_handle.addr()) .await .context("Failed to create indexer client")?; @@ -357,67 +639,185 @@ impl TestContextBuilder { let partial_config = sequencer_partial_config.unwrap_or_default(); - let mut sequencer_setup = SequencerSetup::new(partial_config, bedrock_addr); - if !use_prebuilt { - // 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, - }; - sequencer_setup = sequencer_setup.with_genesis(genesis); - } - let (sequencer_handle, temp_sequencer_dir) = sequencer_setup - .setup() - .await - .context("Failed to setup Sequencer")?; + let mut sequencer_addrs = vec![]; + let mut sequencer_components = vec![]; - let (mut wallet, temp_wallet_dir, wallet_password) = setup_wallet( - sequencer_handle.addr(), + let mut sequencer_keys = vec![]; + + sequencer_keys.push(config::SEQUENCER_SIGNING_KEY); + + sequencer_keys.extend((1..mn_config.num_nodes).map(|i| { + config::sequencer_signing_key_from_seed( + u32::try_from(i).expect("Not being able to fit is realistically impossible"), + ) + })); + + // First, need to start a leader. + let (leader_addr, leader_components) = build_sequencer_components( + partial_config, + bedrock_addr, + enable_wallet, + use_prebuilt, &initial_public_accounts, &initial_private_accounts, - wallet_config_overrides, + genesis_transactions.clone(), + config::SEQUENCER_SIGNING_KEY, + mn_config.bedrock_channel, + cross_zone_config.clone(), ) - .await - .context("Failed to setup wallet")?; + .await?; - if use_prebuilt { - // Funds already exist on-chain in the prebuilt blocks; sync instead of claiming live. - sync_wallet_from_prebuilt(&mut wallet) - .await - .context("Failed to sync wallet from prebuilt database")?; - } else { - setup_public_accounts_with_initial_supply(&mut wallet, &initial_public_accounts) - .await - .context("Failed to initialize public accounts in wallet")?; + // Wait for genesis to be published + wait_until_genesis(&leader_components.sequencer_client) + .await + .context("Encountered an error while waiting for genesis to be published")?; - setup_private_accounts_with_initial_supply(&mut wallet, &initial_private_accounts) - .await - .context("Failed to initialize private accounts in wallet")?; + log::info!("Passed wait untill genesis"); + + sequencer_addrs.push(leader_addr); + sequencer_components.push(leader_components); + + // Skip posting chain config with just one node. + if mn_config.num_nodes != 1 { + post_chain_config_with_default_parameters( + mn_config.bedrock_channel, + bedrock_addr, + sequencer_keys.clone(), + ) + .await?; } - let sequencer_client = setup::sequencer_client(sequencer_handle.addr()) - .context("Failed to create sequencer client")?; + for sequencer_key in sequencer_keys.into_iter().skip(1) { + let (sequencer_addr, sequencer_component) = build_sequencer_components( + partial_config, + bedrock_addr, + enable_wallet, + use_prebuilt, + &initial_public_accounts, + &initial_private_accounts, + genesis_transactions.clone(), + sequencer_key, + mn_config.bedrock_channel, + cross_zone_config.clone(), + ) + .await?; + + sequencer_addrs.push(sequencer_addr); + sequencer_components.push(sequencer_component); + } + + let wallet_components = if enable_wallet { + let (mut wallet, temp_wallet_dir, wallet_password) = setup_wallet( + &sequencer_addrs, + &initial_public_accounts, + &initial_private_accounts, + wallet_config_overrides, + ) + .await + .context("Failed to setup wallet")?; + + if use_prebuilt { + // Funds already exist on-chain in the prebuilt blocks; sync instead of + // claiming live. + sync_wallet_from_prebuilt(&mut wallet) + .await + .context("Failed to sync wallet from prebuilt database")?; + } else { + setup_public_accounts_with_initial_supply(&mut 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")?; + } + + Some(WalletComponents { + wallet, + wallet_password, + temp_wallet_dir, + }) + } else { + None + }; + + Ok(TestContextZone { + wallet: wallet_components, + sequencers: sequencer_components, + indexer: indexer_components, + }) + } + + pub fn build_blocking(self, bedrock_addr: SocketAddr) -> Result { + let runtime = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?; + + let ctx = runtime.block_on(self.build(bedrock_addr))?; + + Ok(BlockingTestContextZone { + ctx: Some(ctx), + runtime, + }) + } +} + +#[derive(Default)] +pub struct MultiZoneTestContextBuilder { + zone_builders: HashMap, +} + +impl MultiZoneTestContextBuilder { + pub async fn build(self) -> Result { + // Ensure logger is initialized only once + *LOGGER; + + let (bedrock_compose, bedrock_addr) = setup_bedrock_node() + .await + .context("Failed to setup Bedrock node")?; + + let mut zones = HashMap::new(); + + #[expect( + clippy::iter_over_hash_type, + reason = "Zones can be started in any order" + )] + for (channel_id, zone_builder) in self.zone_builders { + let zone_ctx = zone_builder.build(bedrock_addr).await?; + + log::info!("Built context for {channel_id}"); + + zones.insert(channel_id, zone_ctx); + } Ok(TestContext { - sequencer_client, - wallet, - wallet_password, + zones, bedrock_compose, bedrock_addr, - sequencer_handle: Some(sequencer_handle), - indexer_components, - temp_sequencer_dir, - temp_wallet_dir, }) } + #[must_use] + pub fn with_zone(mut self, zone_builder: ZoneTestContextBuilder) -> Self { + assert!( + !self + .zone_builders + .contains_key(&zone_builder.bedrock_channel()) + ); + + self.zone_builders + .insert(zone_builder.bedrock_channel(), zone_builder); + + self + } + + #[must_use] + pub fn default_channel_id(&self) -> ChannelId { + *self + .zone_builders + .keys() + .next() + .expect("Must be at least one channel") + } + pub fn build_blocking(self) -> Result { let runtime = tokio::runtime::Runtime::new().context("Failed to create Tokio runtime")?; @@ -430,6 +830,63 @@ impl TestContextBuilder { } } +/// A test context to be used in normal #[test] tests. +pub struct BlockingTestContextZone { + ctx: Option, + runtime: tokio::runtime::Runtime, +} + +impl BlockingTestContextZone { + pub fn new(config: MultiNodeTestContextConfig, bedrock_addr: SocketAddr) -> Result { + ZoneTestContextBuilder::new(config).build_blocking(bedrock_addr) + } + + pub const fn ctx(&self) -> &TestContextZone { + self.ctx.as_ref().expect("TestContext is set") + } + + pub const fn ctx_mut(&mut self) -> &mut TestContextZone { + self.ctx.as_mut().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 TestContextZone) -> 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 TestContextZone) -> 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 BlockingTestContextZone { + 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); + } + }); + } +} + /// A test context to be used in normal #[test] tests. pub struct BlockingTestContext { ctx: Option, @@ -437,8 +894,16 @@ pub struct BlockingTestContext { } impl BlockingTestContext { - pub fn new() -> Result { - TestContext::builder().build_blocking() + /// For now, only one zone and one sequencer is supported for blocking operations. + pub fn new_default() -> Result { + let mut zone_builders = HashMap::new(); + + zone_builders.insert( + config::bedrock_channel_id(), + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()), + ); + + MultiZoneTestContextBuilder { zone_builders }.build_blocking() } pub const fn ctx(&self) -> &TestContext { @@ -547,3 +1012,121 @@ fn dir_size_bytes(path: &Path) -> u64 { } total } + +async fn post_chain_config_with_default_parameters( + channel_id: ChannelId, + bedrock_addr: SocketAddr, + sequencer_keys: Vec<[u8; 32]>, +) -> Result<()> { + log::info!( + "Sequencer committee is {:?} at {channel_id}", + sequencer_keys + .iter() + .map(|key| Ed25519Key::from_bytes(key).public_key().as_bytes().to_vec()) + .map(hex::encode) + .collect::>() + ); + + post_channel_config( + &BedrockConfig { + channel_id, + node_url: config::addr_to_url(config::UrlProtocol::Http, bedrock_addr)?, + auth: None, + funding_key: bedrock_funding_key(), + priority_fee: default_priority_fee(), + }, + &Ed25519Key::from_bytes( + sequencer_keys + .first() + .expect("Must be at least one sequencer"), + ), + sequencer_keys + .clone() + .into_iter() + .map(|key| Ed25519Key::from_bytes(&key).public_key()) + .collect(), + POSTING_TIMEFRAME_SLOTS, + POSTING_TIMEOUT_SLOTS, + 1, + 1, + ) + .await + .context("Failed to configure the channel committee") +} + +async fn wait_until_genesis(client: &SequencerClient) -> Result<()> { + log::info!("Waiting for leader to send genesis"); + + let wait = async { + loop { + if client.get_last_block_id().await? >= 1 { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + }; + tokio::time::timeout(std::time::Duration::from_secs(360), wait) + .await + .with_context(|| "Timed out waiting for genesis")? +} + +#[expect(clippy::too_many_arguments, reason = "No need to repackage fields")] +async fn build_sequencer_components( + partial_config: SequencerPartialConfig, + bedrock_addr: SocketAddr, + enable_wallet: bool, + use_prebuilt: bool, + initial_public_accounts: &[(PrivateKey, u128)], + initial_private_accounts: &[InitialPrivateAccountForWallet], + genesis_transactions: Option>, + sequencer_key: [u8; 32], + bedrock_channel_id: ChannelId, + cross_zone_config: Option, +) -> Result<(SocketAddr, SequencerComponents)> { + let mut sequencer_setup = SequencerSetup::new(partial_config, bedrock_addr); + + let genesis_actions = if enable_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); + match genesis_transactions { + Some(mut custom) => { + custom.extend(wallet_genesis); + custom + } + None => wallet_genesis, + } + } else { + genesis_transactions.unwrap_or_default() + }; + + if !use_prebuilt { + sequencer_setup = sequencer_setup.with_genesis(genesis_actions); + } + + sequencer_setup = sequencer_setup.with_bedrock_signing_key(sequencer_key); + sequencer_setup = sequencer_setup.with_channel_id(bedrock_channel_id); + if let Some(cross_zone_config) = cross_zone_config.clone() { + sequencer_setup = sequencer_setup.with_cross_zone(cross_zone_config); + } + + let (sequencer_handle, temp_sequencer_dir) = sequencer_setup + .setup() + .await + .context("Failed to setup Sequencer")?; + + let sequencer_client = setup::sequencer_client(sequencer_handle.addr()) + .context("Failed to create sequencer client")?; + + Ok(( + sequencer_handle.addr(), + SequencerComponents { + sequencer_handle, + temp_sequencer_dir, + sequencer_client, + }, + )) +} diff --git a/test_fixtures/src/setup.rs b/test_fixtures/src/setup.rs index 5d185a2d7..59d9cae7e 100644 --- a/test_fixtures/src/setup.rs +++ b/test_fixtures/src/setup.rs @@ -28,6 +28,7 @@ use crate::{ private_mention, public_mention, }; +#[derive(Debug)] pub struct SequencerSetup { partial: config::SequencerPartialConfig, bedrock_addr: SocketAddr, @@ -118,8 +119,9 @@ impl SequencerSetup { genesis } else { let dump = load_prebuilt_dump()?; - // `SequencerCore::open_or_create_store` looks for `/rocksdb`. - let dst = home.join("rocksdb"); + // `SequencerCore::open_or_create_store` looks for the channel-suffixed + // db under its home, so the restore has to land on the same name. + let dst = home.join(format!("rocksdb-{channel_id}")); let _store = SequencerStore::restore_db_from_dump( &dst, &dump, @@ -139,6 +141,7 @@ impl SequencerSetup { config::bedrock_funding_key(), genesis_transactions, cross_zone, + bedrock_signing_key, ) .context("Failed to create Sequencer config")?; @@ -278,12 +281,13 @@ pub async fn setup_indexer( } pub async fn setup_wallet( - sequencer_addr: SocketAddr, + sequencer_addrs: &[SocketAddr], initial_public_accounts: &[(PrivateKey, u128)], initial_private_accounts: &[InitialPrivateAccountForWallet], config_overrides: WalletConfigOverrides, ) -> Result<(WalletCore, TempDir, String)> { - let config = config::wallet_config(sequencer_addr).context("Failed to create Wallet config")?; + let config = + config::wallet_config(sequencer_addrs).context("Failed to create Wallet config")?; let config_serialized = serde_json::to_string_pretty(&config).context("Failed to serialize Wallet config")?; diff --git a/test_fixtures/tests/prebuilt_fixture.rs b/test_fixtures/tests/prebuilt_fixture.rs index 4960af812..2487e43fe 100644 --- a/test_fixtures/tests/prebuilt_fixture.rs +++ b/test_fixtures/tests/prebuilt_fixture.rs @@ -6,15 +6,24 @@ use anyhow::{Context as _, Result}; use lee::{AccountId, PublicKey}; use sequencer_service_rpc::RpcClient as _; use test_fixtures::{ - TestContext, - config::{default_private_accounts_for_wallet, default_public_accounts_for_wallet}, + MultiZoneTestContextBuilder, TestContext, ZoneTestContextBuilder, + config::{ + MultiNodeTestContextConfig, default_private_accounts_for_wallet, + default_public_accounts_for_wallet, + }, verify_commitment_is_in_state, }; /// Builds from genesis (no prebuilt database) and checks the on-chain state follows the config. #[tokio::test] async fn genesis_from_scratch_follows_config() -> Result<()> { - let ctx = TestContext::builder().from_scratch().build().await?; + let ctx = MultiZoneTestContextBuilder::default() + .with_zone( + ZoneTestContextBuilder::new(MultiNodeTestContextConfig::default()).from_scratch(), + ) + .build() + .await?; + assert_context_follows_config(&ctx).await } diff --git a/test_programs/guest/src/bin/simple_balance_transfer.rs b/test_programs/guest/src/bin/simple_balance_transfer.rs new file mode 100644 index 000000000..addc4a191 --- /dev/null +++ b/test_programs/guest/src/bin/simple_balance_transfer.rs @@ -0,0 +1,57 @@ +use lee_core::program::{AccountPostState, Claim, ProgramInput, ProgramOutput, read_lee_inputs}; + +type Instruction = u128; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: balance, + }, + instruction_words, + ) = read_lee_inputs::(); + + if let Ok([account_pre]) = <[_; 1]>::try_from(pre_states.clone()) { + let account_post = + AccountPostState::new_claimed_if_default(account_pre.account, Claim::Authorized); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + vec![account_post], + ) + .write(); + return; + } + + let Ok([sender_pre, receiver_pre]) = <[_; 2]>::try_from(pre_states) else { + return; + }; + + let mut sender_post = sender_pre.account.clone(); + let mut receiver_post = receiver_pre.account.clone(); + sender_post.balance = sender_post + .balance + .checked_sub(balance) + .expect("Not enough balance to transfer"); + receiver_post.balance = receiver_post + .balance + .checked_add(balance) + .expect("Overflow when adding balance"); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![sender_pre, receiver_pre], + vec![ + AccountPostState::new_claimed_if_default(sender_post, Claim::Authorized), + AccountPostState::new_claimed_if_default(receiver_post, Claim::Authorized), + ], + ) + .write(); +} diff --git a/test_programs/src/lib.rs b/test_programs/src/lib.rs index c13a10282..f1df3e961 100644 --- a/test_programs/src/lib.rs +++ b/test_programs/src/lib.rs @@ -11,6 +11,21 @@ mod guests { include!(concat!(env!("OUT_DIR"), "/methods.rs")); } +#[must_use] +#[inline] +pub const fn simple_balance_transfer() -> Program { + use guests::{ + SIMPLE_BALANCE_TRANSFER_ELF, SIMPLE_BALANCE_TRANSFER_ID, SIMPLE_BALANCE_TRANSFER_PATH, + }; + + let _unused = SIMPLE_BALANCE_TRANSFER_PATH; + + Program::new_unchecked( + SIMPLE_BALANCE_TRANSFER_ID, + Cow::Borrowed(SIMPLE_BALANCE_TRANSFER_ELF), + ) +} + #[must_use] #[inline] pub const fn chain_caller() -> Program { diff --git a/tools/cross_zone_chat/Cargo.toml b/tools/cross_zone_chat/Cargo.toml index 2c74e6dfa..a7c5de31a 100644 --- a/tools/cross_zone_chat/Cargo.toml +++ b/tools/cross_zone_chat/Cargo.toml @@ -23,3 +23,4 @@ serde = { workspace = true, features = ["derive"] } axum.workspace = true log.workspace = true env_logger.workspace = true +rand.workspace = true diff --git a/tools/cross_zone_chat/src/main.rs b/tools/cross_zone_chat/src/main.rs index 88f6d3856..83962eaf8 100644 --- a/tools/cross_zone_chat/src/main.rs +++ b/tools/cross_zone_chat/src/main.rs @@ -29,8 +29,8 @@ clippy::unused_async, clippy::needless_pass_by_value, clippy::infinite_loop, - reason = "Demo binary: stdout banner is the deliverable; ordinal/elapsed arithmetic is \ - bounded at chat scale; the per-zone scanners and finality poller are daemon \ + reason = "Demo binary: stdout banner is the deliverable; elapsed arithmetic is bounded at chat \ + scale; the per-zone scanners and finality poller are daemon \ loops that run for the process lifetime; axum handlers must be `async` and take \ their extractors (State/Json/Query) by value to satisfy the framework's bounds." )] @@ -57,11 +57,14 @@ use common::{block::BedrockStatus, transaction::LeeTransaction}; use cross_zone_inbox_core::{CrossZoneConfig, CrossZonePeer, CrossZoneRoute, Instruction, ZoneId}; use cross_zone_outbox_core::outbox_pda; use lee::{ - ProgramId, PublicTransaction, + Account, ProgramId, PublicTransaction, public_transaction::{Message, WitnessSet}, }; use log::{info, warn}; -use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda}; +use ping_core::{ + ReceiverInstruction, SenderInstruction, ping_record_pda, receiver_config_account_id, + sender_config_account_id, +}; use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; use serde::{Deserialize, Serialize}; use test_fixtures::{ @@ -71,6 +74,10 @@ use test_fixtures::{ const HTTP_PORT: u16 = 8088; const POLL_INTERVAL: Duration = Duration::from_secs(1); +/// Ordinals probed for a free outbox slot before giving up. +const ORDINAL_PROBE_LIMIT: u32 = 1_024; +/// Transient RPC failures tolerated per probed slot. +const RPC_RETRY_LIMIT: u32 = 5; /// One chat message tracked through its cross-zone pipeline. Displayed in the /// receiving zone's column; the stage fields drive the on-page timeline. @@ -127,8 +134,12 @@ struct ZoneRuntime { /// The peer zone's channel id; the target of sends from this zone. other_zone: ZoneId, client: SequencerClient, - /// Monotonic outbox ordinal per (this zone -> peer); each send must use a - /// fresh value because the outbox PDA is claimed only when default. + /// Next outbox ordinal to try for (this zone -> peer). + /// + /// An outbox slot is written once and the ordinal names a slot in a space + /// every user of `ping_sender` shares, so a fixed starting point collides + /// with whatever the chain already holds. Seeded from a free slot found by + /// [`next_free_ordinal`], then incremented per send. ordinal: AtomicU32, } @@ -173,7 +184,7 @@ impl AppState { created: Instant::now(), delivered_secs: None, }); - info!("[stage] msg {id} submitted {source_label}->{dest_label}"); + log::info!("[stage] msg {id} submitted {source_label}->{dest_label}"); id } @@ -184,7 +195,7 @@ impl AppState { m.source_label == source_label && m.ordinal == ordinal && m.source_block.is_none() }) { message.source_block = Some(block_id); - info!( + log::info!( "[stage] msg {} in source block {source_label}#{block_id} (+{}s)", message.id, message.created.elapsed().as_secs() @@ -208,7 +219,7 @@ impl AppState { message.delivered_block = Some(block_id); let secs = message.created.elapsed().as_secs(); message.delivered_secs = Some(secs); - info!( + log::info!( "[stage] msg {} delivered {dest_label}#{block_id} (+{secs}s total)", message.id ); @@ -239,7 +250,7 @@ impl AppState { && !message.finalized { message.finalized = true; - info!( + log::info!( "[stage] msg {} source block {source_label}#{block_id} finalized on Bedrock (+{}s)", message.id, message.created.elapsed().as_secs() @@ -304,16 +315,26 @@ async fn main() -> Result<()> { .await .context("Failed to set up zone B sequencer")?; + let client_a = sequencer_client(seq_a.addr())?; + let client_b = sequencer_client(seq_b.addr())?; + let ordinal_a = next_free_ordinal(&client_a, &zone_b) + .await + .context("Failed to find a free outbox ordinal for zone A")?; + let ordinal_b = next_free_ordinal(&client_b, &zone_a) + .await + .context("Failed to find a free outbox ordinal for zone B")?; + info!("Outbox ordinals start at A={ordinal_a} B={ordinal_b}"); + let state = Arc::new(AppState { zone_a: ZoneRuntime { other_zone: zone_b, - client: sequencer_client(seq_a.addr())?, - ordinal: AtomicU32::new(0), + client: client_a, + ordinal: AtomicU32::new(ordinal_a), }, zone_b: ZoneRuntime { other_zone: zone_a, - client: sequencer_client(seq_b.addr())?, - ordinal: AtomicU32::new(0), + client: client_b, + ordinal: AtomicU32::new(ordinal_b), }, next_id: AtomicU64::new(1), messages: Mutex::new(Vec::new()), @@ -365,6 +386,55 @@ fn sequencer_client(addr: SocketAddr) -> Result { .context("Failed to build sequencer client") } +/// A free outbox slot for this zone's `ping_sender` to start counting from: the +/// first unwritten one at or after a random ordinal. +/// +/// An outbox slot is written once, so a send into an occupied one fails at block +/// production, and `send_transaction` checks a transaction only statelessly, so +/// nothing tells the sender. The predicate here is the one the guest asserts, +/// asked before submitting instead of after. +/// +/// Random rather than zero because the ordinal space is shared by every user of +/// `ping_sender` and a slot costs one unsigned transaction to occupy, so any +/// fixed starting point can be squatted. On a chain this process owns, which is +/// what `just cross-zone-chat` boots, nothing is occupied and this returns on +/// its first try; it earns its keep against a chain the tool did not create. +/// +/// It only places the first send. Later ordinals come from incrementing, so a +/// slot taken after this returns still collides, at a probability of the +/// occupied count over 2^32. +async fn next_free_ordinal(client: &SequencerClient, target_zone: &ZoneId) -> Result { + let outbox_id = programs::cross_zone_outbox().id(); + let emitter = programs::ping_sender().id(); + let start: u32 = rand::random(); + + for offset in 0..ORDINAL_PROBE_LIMIT { + let ordinal = start.wrapping_add(offset); + let slot = outbox_pda(outbox_id, emitter, target_zone, ordinal); + // Retried rather than propagated: by here the run has already paid for a + // Bedrock bring-up and two sequencer boots, and every other RPC caller + // in this tool rides out a transient error rather than ending the run. + let mut attempt = 0_u32; + let account = loop { + match client.get_account(slot).await { + Ok(account) => break account, + Err(err) if attempt < RPC_RETRY_LIMIT => { + attempt += 1; + warn!("Outbox probe failed, retrying ({attempt}/{RPC_RETRY_LIMIT}): {err}"); + tokio::time::sleep(POLL_INTERVAL).await; + } + Err(err) => { + return Err(err).context("Failed to read an outbox slot while probing"); + } + } + }; + if account == Account::default() { + return Ok(ordinal); + } + } + anyhow::bail!("No free outbox ordinal in {ORDINAL_PROBE_LIMIT} tried from {start}") +} + /// Scans one zone's new blocks. A `ping_sender` tx marks the message's source /// block (its outbound leg); an inbox dispatch marks delivery on this zone. /// Runs forever; transient RPC errors are logged and retried. @@ -455,7 +525,9 @@ fn decode_inbox_text(instruction_data: &[u32]) -> Option { fn decode_send_ordinal(instruction_data: &[u32]) -> Option { let instruction: SenderInstruction = risc0_zkvm::serde::from_slice::(instruction_data).ok()?; - let SenderInstruction::Send { ordinal, .. } = instruction; + let SenderInstruction::Send { ordinal, .. } = instruction else { + return None; + }; Some(ordinal) } @@ -470,7 +542,9 @@ fn decode_payload(payload: &[u8]) -> Option { .collect(); let instruction: ReceiverInstruction = risc0_zkvm::serde::from_slice::(&words).ok()?; - let ReceiverInstruction::Record { payload: bytes } = instruction; + let ReceiverInstruction::Record { payload: bytes } = instruction else { + return None; + }; Some(String::from_utf8_lossy(&bytes).into_owned()) } @@ -487,18 +561,21 @@ fn build_send_tx(other_zone: ZoneId, ordinal: u32, text: &str) -> LeeTransaction let payload: Vec = words.iter().flat_map(|word| word.to_le_bytes()).collect(); let send = SenderInstruction::Send { - outbox_program_id: outbox_id, target_zone: other_zone, target_program_id: receiver_id, - target_accounts: vec![ping_record_pda(receiver_id).into_value()], + target_accounts: vec![ + receiver_config_account_id(receiver_id).into_value(), + ping_record_pda(receiver_id).into_value(), + ], payload, ordinal, }; - let outbox_account = outbox_pda(outbox_id, &other_zone, ordinal); + let sender_id = programs::ping_sender().id(); + let outbox_account = outbox_pda(outbox_id, sender_id, &other_zone, ordinal); let message = Message::try_new( - programs::ping_sender().id(), - vec![outbox_account], + sender_id, + vec![sender_config_account_id(sender_id), outbox_account], vec![], send, ) diff --git a/tools/crypto_primitives_bench/README.md b/tools/crypto_primitives_bench/README.md index eb2da1491..8834d1769 100644 --- a/tools/crypto_primitives_bench/README.md +++ b/tools/crypto_primitives_bench/README.md @@ -12,7 +12,7 @@ cargo bench -p crypto_primitives_bench --bench primitives 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_os_random`: full mnemonic → SSK → ASK → NSK, plus SSK → VSK, and 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. diff --git a/tools/dashboard_gen/Cargo.toml b/tools/dashboard_gen/Cargo.toml index 4bc73e869..1823d14cb 100644 --- a/tools/dashboard_gen/Cargo.toml +++ b/tools/dashboard_gen/Cargo.toml @@ -9,7 +9,7 @@ workspace = true [dependencies] sequencer_core_metrics.workspace = true -sequencer_service_metrics.workspace = true +sequencer_rpc_server_actor_metrics.workspace = true clap = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive", "alloc"] } diff --git a/tools/dashboard_gen/src/dashboards/sequencer.rs b/tools/dashboard_gen/src/dashboards/sequencer.rs index 0520251c0..aed7f8dc1 100644 --- a/tools/dashboard_gen/src/dashboards/sequencer.rs +++ b/tools/dashboard_gen/src/dashboards/sequencer.rs @@ -156,9 +156,9 @@ pub fn dashboard() -> Dashboard { // `clamp_min` keeps an idle window (nothing submitted) // reading as 0% instead of a division by zero. "100 * (increase({before_mempool}[$__range]) + increase({in_mempool}[$__range])) / clamp_min(increase({submitted}[$__range]), 1)", - before_mempool = sequencer_service_metrics::names::BEFORE_MEMPOOL_FAILED_TRANSACTIONS_TOTAL, + before_mempool = sequencer_rpc_server_actor_metrics::names::BEFORE_MEMPOOL_FAILED_TRANSACTIONS_TOTAL, in_mempool = sequencer_core_metrics::names::MEMPOOL_FAILED_TRANSACTIONS_TOTAL, - submitted = sequencer_service_metrics::names::SUBMITTED_TRANSACTIONS_TOTAL, + submitted = sequencer_rpc_server_actor_metrics::names::SUBMITTED_TRANSACTIONS_TOTAL, )) .legend("failed"), ), @@ -169,11 +169,11 @@ pub fn dashboard() -> Dashboard { .fill_opacity(35) .gradient_mode(GradientMode::Opacity) .target(rate_per_min( - sequencer_service_metrics::names::SUBMITTED_TRANSACTIONS_TOTAL, + sequencer_rpc_server_actor_metrics::names::SUBMITTED_TRANSACTIONS_TOTAL, "submitted", )) .target(rate_per_min( - sequencer_service_metrics::names::BEFORE_MEMPOOL_FAILED_TRANSACTIONS_TOTAL, + sequencer_rpc_server_actor_metrics::names::BEFORE_MEMPOOL_FAILED_TRANSACTIONS_TOTAL, "failed · before mempool", )) .target(rate_per_min( @@ -192,4 +192,44 @@ pub fn dashboard() -> Dashboard { ), ], ) + .row( + 7, + [ + // A failed dispatch is left out of the block, so nothing on chain + // records it. These panels are the only signal. + Panel::stat("Cross-zone deliveries given up on since startup") + .width(6) + .unit(Unit::Short) + .decimals(0) + .color(Color::fixed("red")) + .target( + Target::new( + sequencer_core_metrics::names::CROSS_ZONE_DISPATCHES_RETIRED_TOTAL, + ) + .legend("given up on"), + ), + Panel::stat("Dead letters retained") + .width(6) + .unit(Unit::Short) + .decimals(0) + .color(Color::fixed("orange")) + .target( + Target::new( + sequencer_core_metrics::names::CROSS_ZONE_DEAD_LETTER_DISPATCHES, + ) + .legend("retained"), + ), + Panel::timeseries("Cross-zone deliveries given up on (per minute)") + .width(12) + .unit(Unit::Short) + .min(0.0) + .target(rate_per_min( + sequencer_core_metrics::names::CROSS_ZONE_DISPATCHES_RETIRED_TOTAL, + "given up on", + )) + .with_override( + FieldOverride::by_name("given up on").color(Color::fixed("red")), + ), + ], + ) }