Merge branch 'dev' into Pravdyvy/pda-account-id-generation

This commit is contained in:
Pravdyvy 2026-07-15 12:57:16 +03:00
commit e4fd9b2397
220 changed files with 22074 additions and 14421 deletions

View File

@ -60,6 +60,7 @@ allow-git = [
"https://github.com/logos-blockchain/logos-blockchain.git",
"https://github.com/logos-blockchain/logos-blockchain-circuits.git",
"https://github.com/logos-blockchain/logos-blockchain-rust-rapidsnark.git",
"https://github.com/logos-blockchain/sponges",
"https://github.com/arkworks-rs/spongefish.git",
]
unknown-git = "deny"

View File

@ -28,10 +28,6 @@ jobs:
- uses: ./.github/actions/install-risc0
- uses: ./.github/actions/install-logos-blockchain-circuits
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install active toolchain
run: rustup install

View File

@ -135,7 +135,37 @@ jobs:
env:
RISC0_DEV_MODE: "1"
RUST_LOG: "info"
run: cargo nextest run --workspace --exclude integration_tests --all-features
run: cargo nextest run --workspace --exclude integration_tests --exclude test_fixtures --all-features
test-fixtures-tests:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha || github.head_ref }}
- uses: ./.github/actions/install-system-deps
- uses: ./.github/actions/install-risc0
- name: Install active toolchain
run: rustup install
- name: Restore Rust cache
uses: Swatinem/rust-cache@v2
with:
shared-key: ci-rust-cache
save-if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' }}
- name: Install nextest
run: cargo install --locked cargo-nextest
- name: Run test_fixtures tests
env:
RISC0_DEV_MODE: "1"
RUST_LOG: "info"
run: cargo nextest run -p test_fixtures
integration-tests-prebuild:
runs-on: ubuntu-latest
@ -193,7 +223,7 @@ jobs:
echo "Discovered integration targets: $targets_json"
integration-tests:
needs: integration-tests-prebuild
needs: [test-fixtures-tests, integration-tests-prebuild]
runs-on: ubuntu-latest
timeout-minutes: 90
strategy:

197
Cargo.lock generated
View File

@ -1154,6 +1154,25 @@ dependencies = [
"serde",
]
[[package]]
name = "bridge_lock_core"
version = "0.1.0"
dependencies = [
"lee_core",
"serde",
]
[[package]]
name = "bridge_lock_program"
version = "0.1.0"
dependencies = [
"bridge_lock_core",
"cross_zone_outbox_core",
"lee_core",
"risc0-zkvm",
"wrapped_token_core",
]
[[package]]
name = "bridge_program"
version = "0.1.0"
@ -1833,6 +1852,76 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "cross_zone"
version = "0.1.0"
dependencies = [
"bridge_lock_core",
"cross_zone_inbox_core",
"lee",
"lee_core",
"ping_core",
"programs",
"risc0-zkvm",
]
[[package]]
name = "cross_zone_chat"
version = "0.1.0"
dependencies = [
"anyhow",
"axum 0.8.9",
"common",
"cross_zone_inbox_core",
"cross_zone_outbox_core",
"env_logger",
"lee",
"log",
"ping_core",
"programs",
"risc0-zkvm",
"sequencer_service_rpc",
"serde",
"test_fixtures",
"tokio",
]
[[package]]
name = "cross_zone_inbox_core"
version = "0.1.0"
dependencies = [
"borsh",
"lee_core",
"risc0-zkvm",
"serde",
]
[[package]]
name = "cross_zone_inbox_program"
version = "0.1.0"
dependencies = [
"cross_zone_inbox_core",
"lee_core",
]
[[package]]
name = "cross_zone_outbox_core"
version = "0.1.0"
dependencies = [
"borsh",
"lee_core",
"risc0-zkvm",
"serde",
]
[[package]]
name = "cross_zone_outbox_program"
version = "0.1.0"
dependencies = [
"cross_zone_outbox_core",
"lee_core",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
@ -1914,7 +2003,6 @@ dependencies = [
"criterion",
"key_protocol",
"lee_core",
"rand 0.8.6",
]
[[package]]
@ -2077,7 +2165,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]]
@ -3850,21 +3938,27 @@ dependencies = [
"anyhow",
"arc-swap",
"async-stream",
"authenticated_transfer_core",
"borsh",
"common",
"cross_zone",
"cross_zone_inbox_core",
"futures",
"hex",
"humantime-serde",
"lee",
"lee_core",
"log",
"logos-blockchain-core",
"logos-blockchain-zone-sdk",
"ping_core",
"programs",
"risc0-zkvm",
"serde",
"serde_json",
"storage",
"tempfile",
"testnet_initial_state",
"thiserror 2.0.18",
"tokio",
"url",
]
@ -3912,6 +4006,7 @@ dependencies = [
"base64",
"common",
"hex",
"indexer_core",
"lee",
"lee_core",
"schemars 1.2.1",
@ -4012,8 +4107,11 @@ dependencies = [
"authenticated_transfer_core",
"borsh",
"bridge_core",
"bridge_lock_core",
"bytesize",
"common",
"cross_zone_inbox_core",
"cross_zone_outbox_core",
"faucet_core",
"futures",
"hex",
@ -4029,8 +4127,10 @@ dependencies = [
"logos-blockchain-key-management-system-service",
"logos-blockchain-zone-sdk",
"num-bigint 0.4.6",
"ping_core",
"programs",
"reqwest",
"risc0-zkvm",
"sequencer_core",
"sequencer_service_rpc",
"serde_json",
@ -4043,6 +4143,7 @@ dependencies = [
"vault_core",
"wallet",
"wallet-ffi",
"wrapped_token_core",
]
[[package]]
@ -4441,11 +4542,11 @@ dependencies = [
[[package]]
name = "keccak"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa"
source = "git+https://github.com/logos-blockchain/sponges?rev=3a56e99771beedf04946eab21a4a62adc2951377#3a56e99771beedf04946eab21a4a62adc2951377"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"risc0-zkvm",
]
[[package]]
@ -4553,7 +4654,6 @@ dependencies = [
"anyhow",
"borsh",
"build_utils",
"env_logger",
"hex",
"hex-literal 1.1.0",
"k256",
@ -4568,7 +4668,6 @@ dependencies = [
"test-case",
"test_methods",
"thiserror 2.0.18",
"token_core",
]
[[package]]
@ -7177,6 +7276,31 @@ dependencies = [
"token_core",
]
[[package]]
name = "ping_core"
version = "0.1.0"
dependencies = [
"lee_core",
"serde",
]
[[package]]
name = "ping_receiver_program"
version = "0.1.0"
dependencies = [
"lee_core",
"ping_core",
]
[[package]]
name = "ping_sender_program"
version = "0.1.0"
dependencies = [
"cross_zone_outbox_core",
"lee_core",
"ping_core",
]
[[package]]
name = "pkcs1"
version = "0.7.5"
@ -7449,15 +7573,20 @@ dependencies = [
"associated_token_account_program",
"authenticated_transfer_core",
"bridge_core",
"bridge_lock_core",
"build_utils",
"clock_core",
"cross_zone_inbox_core",
"cross_zone_outbox_core",
"faucet_core",
"lee",
"lee_core",
"ping_core",
"risc0-zkvm",
"token_core",
"token_program",
"vault_core",
"wrapped_token_core",
]
[[package]]
@ -8896,10 +9025,13 @@ dependencies = [
"bytesize",
"chrono",
"common",
"cross_zone",
"cross_zone_inbox_core",
"faucet_core",
"futures",
"hex",
"humantime-serde",
"itertools 0.14.0",
"key_protocol",
"lee",
"lee_core",
@ -9461,11 +9593,13 @@ dependencies = [
"borsh",
"common",
"lee",
"log",
"programs",
"rocksdb",
"system_accounts",
"tempfile",
"thiserror 2.0.18",
"zstd",
]
[[package]]
@ -9739,6 +9873,7 @@ name = "test_fixtures"
version = "0.1.0"
dependencies = [
"anyhow",
"bip39",
"bytesize",
"common",
"env_logger",
@ -9758,6 +9893,7 @@ dependencies = [
"tempfile",
"testcontainers",
"tokio",
"tokio-util",
"url",
"wallet",
]
@ -9840,6 +9976,7 @@ dependencies = [
"programs",
"serde",
"system_accounts",
"wrapped_token_core",
]
[[package]]
@ -10890,7 +11027,6 @@ dependencies = [
"risc0-zkvm",
"sequencer_service_rpc",
"serde_json",
"tempfile",
"tokio",
"vault_core",
"wallet",
@ -11550,6 +11686,23 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "wrapped_token_core"
version = "0.1.0"
dependencies = [
"lee_core",
"risc0-zkvm",
"serde",
]
[[package]]
name = "wrapped_token_program"
version = "0.1.0"
dependencies = [
"lee_core",
"wrapped_token_core",
]
[[package]]
name = "writeable"
version = "0.6.3"
@ -11802,3 +11955,31 @@ dependencies = [
"log",
"simd-adler32",
]
[[package]]
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"zstd-safe",
]
[[package]]
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
]

View File

@ -43,6 +43,13 @@ members = [
"lez/programs/pinata_token",
"lez/programs/token",
"lez/programs/vault",
"lez/programs/cross_zone_inbox",
"lez/programs/cross_zone_outbox",
"lez/programs/bridge_lock",
"lez/programs/wrapped_token",
"lez/programs/ping_sender",
"lez/programs/ping_receiver",
"lez/cross_zone",
"test_programs",
"test_programs/guest",
@ -58,6 +65,7 @@ members = [
"tools/cycle_bench",
"tools/crypto_primitives_bench",
"tools/integration_bench",
"tools/cross_zone_chat",
]
[workspace.dependencies]
@ -94,6 +102,12 @@ authenticated_transfer_core = { path = "lez/programs/authenticated_transfer/core
faucet_core = { path = "lez/programs/faucet/core" }
bridge_core = { path = "lez/programs/bridge/core" }
vault_core = { path = "lez/programs/vault/core" }
cross_zone_inbox_core = { path = "lez/programs/cross_zone_inbox/core" }
cross_zone_outbox_core = { path = "lez/programs/cross_zone_outbox/core" }
bridge_lock_core = { path = "lez/programs/bridge_lock/core" }
wrapped_token_core = { path = "lez/programs/wrapped_token/core" }
ping_core = { path = "lez/programs/ping_core" }
cross_zone = { path = "lez/cross_zone" }
build_utils = { path = "build_utils" }
test_programs = { path = "test_programs" }
testnet_initial_state = { path = "lez/testnet_initial_state" }
@ -147,6 +161,7 @@ bip39 = "2.2.0"
hmac-sha512 = "1.1.7"
chrono = "0.4.41"
borsh = "1.5.7"
zstd = "0.13"
base58 = "0.2.0"
itertools = "0.14.0"
num-bigint = "0.4.6"
@ -181,6 +196,7 @@ elliptic-curve = { version = "0.13.8", features = ["arithmetic"] }
actix-web = { version = "4.13.0", default-features = false, features = [
"macros",
] }
axum = "0.8.4"
clap = { version = "4.5.42", features = ["derive", "env"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
pyo3 = { version = "0.29", features = ["auto-initialize"] }
@ -194,6 +210,10 @@ opt-level = 'z'
lto = true
codegen-units = 1
# Keccak speedup for in-guest ML KEM
[patch.crates-io]
keccak = { git = "https://github.com/logos-blockchain/sponges", rev = "3a56e99771beedf04946eab21a4a62adc2951377" }
# Keep backtraces but drop full DWARF type info to avoid LLD OOM/SIGBUS when
# linking large integration-test binaries on resource-constrained CI runners.
[profile.dev]

View File

@ -6,6 +6,11 @@ default:
# ---- Configuration ----
ARTIFACTS := "artifacts"
# On macOS the integration-test binary links pyo3 against the CommandLineTools
# Python framework with no embedded rpath, so it needs this to launch. Empty on
# Linux/CI, which is unaffected.
DEMO_ENV := if os() == "macos" { "DYLD_FALLBACK_FRAMEWORK_PATH=/Library/Developer/CommandLineTools/Library/Frameworks" } else { "" }
# Build risc0 program artifacts.
build-artifacts:
@echo "🔨 Building artifacts"
@ -35,6 +40,11 @@ test:
@echo "🧪 Running tests"
RISC0_DEV_MODE=1 cargo nextest run --no-fail-fast
# Regenerate the prebuilt sequencer db dump for fast TestContext::new() (needs Docker; commit the dump).
regenerate-test-fixture:
@echo "🧪 Regenerating test fixtures"
RISC0_DEV_MODE=1 cargo run -p test_fixtures --bin regenerate_test_fixture
# Run criterion benches: fast crypto primitives, then the slow PPE verify (real proving setup).
bench:
@echo "📊 Running criterion benches"
@ -94,6 +104,26 @@ wallet-import-test-accounts:
just run-wallet account list
# Demo: cross-zone ping. Boots two zones on one Bedrock and sends a message from
# zone A to zone B, where the indexer re-derives and verifies it (Option B)
# before ping_receiver records it. Dev mode, no proving.
demo-cross-zone-ping:
@echo "📡 Cross-zone ping demo (message A → B, indexer-verified)"
{{DEMO_ENV}} RISC0_DEV_MODE=1 cargo test -p integration_tests --release --test cross_zone_verified -- --nocapture
# Demo: cross-zone wrapped-token bridge. Locks a balance on zone A and mints the
# wrapped token to a recipient on zone B over the same verified spine.
demo-cross-zone-bridge:
@echo "🌉 Cross-zone bridge demo (lock on A, mint on B)"
{{DEMO_ENV}} RISC0_DEV_MODE=1 cargo test -p integration_tests --release --test cross_zone_bridge -- --nocapture
# Demo: interactive cross-zone chat. Boots two zones on one Bedrock and serves a
# local two-column web UI; type in one zone and watch the message cross into the
# other. Two people can chat across the zones. Dev mode, no proving.
cross-zone-chat:
@echo "💬 Cross-zone chat demo — open the printed localhost URL"
{{DEMO_ENV}} RISC0_DEV_MODE=1 cargo run -p cross_zone_chat --release
# Clean runtime data
clean:
@echo "🧹 Cleaning run artifacts"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -22,6 +22,12 @@ associated_token_account_core.workspace = true
vault_core.workspace = true
faucet_core.workspace = true
bridge_core.workspace = true
ping_core.workspace = true
cross_zone_outbox_core.workspace = true
cross_zone_inbox_core.workspace = true
bridge_lock_core.workspace = true
wrapped_token_core.workspace = true
risc0-zkvm.workspace = true
indexer_service_rpc = { workspace = true, features = ["client"] }
sequencer_service_rpc = { workspace = true, features = ["client"] }
wallet-ffi.workspace = true

View File

@ -6,11 +6,168 @@
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::{
cli::{
CliAccountMention, Command, SubcommandReturnValue,
account::{AccountSubcommand, NewSubcommand},
programs::{
native_token_transfer::AuthTransferSubcommand, token::TokenProgramAgnosticSubcommand,
},
},
storage::key_chain::FoundPrivateAccount,
};
/// Maximum time to wait for the indexer to catch up to the sequencer.
pub const L2_TO_L1_TIMEOUT: Duration = Duration::from_mins(7);
pub const L2_TO_L1_TIMEOUT: Duration = Duration::from_mins(6);
/// Create a private or public account at the given chain index and return its ID.
/// Pass `cci: None` to use the wallet's next available chain index.
pub async fn new_account(
ctx: &mut TestContext,
private: bool,
cci: Option<ChainIndex>,
) -> Result<AccountId> {
let subcommand = if private {
NewSubcommand::Private { cci, label: None }
} else {
NewSubcommand::Public { cci, label: None }
};
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(subcommand)),
)
.await?;
let SubcommandReturnValue::RegisterAccount { account_id } = result else {
anyhow::bail!("Expected RegisterAccount return value");
};
Ok(account_id)
}
/// Send `amount` from `from` to `to` via an authenticated transfer (identifier 0).
pub async fn send(
ctx: &mut TestContext,
from: CliAccountMention,
to: CliAccountMention,
amount: u128,
) -> Result<()> {
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from,
to: Some(to),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
Ok(())
}
/// Create a token (New) and wait for the block to be included.
pub async fn create_token(
ctx: &mut TestContext,
definition_account_id: CliAccountMention,
supply_account_id: CliAccountMention,
name: impl Into<String>,
total_supply: u128,
) -> Result<()> {
let subcommand = TokenProgramAgnosticSubcommand::New {
definition_account_id,
supply_account_id,
name: name.into(),
total_supply,
};
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
Ok(())
}
/// Send tokens and wait for the block to be included.
pub async fn token_send(
ctx: &mut TestContext,
from: CliAccountMention,
to: CliAccountMention,
amount: u128,
) -> Result<()> {
let subcommand = TokenProgramAgnosticSubcommand::Send {
from,
to: Some(to),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount,
};
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
Ok(())
}
/// Retrieve the native token balance for `account_id`.
pub async fn account_balance(ctx: &TestContext, account_id: AccountId) -> Result<u128> {
Ok(ctx
.sequencer_client()
.get_account_balance(account_id)
.await?)
}
/// Fetch the full account state for `account_id` from the sequencer.
pub async fn get_account(ctx: &TestContext, account_id: AccountId) -> Result<lee::Account> {
Ok(ctx.sequencer_client().get_account(account_id).await?)
}
/// Fetch the current commitment for `account_id` and assert it is present in the sequencer state.
pub async fn assert_private_commitment_in_state(
ctx: &TestContext,
account_id: AccountId,
label: &str,
) -> Result<()> {
let commitment = ctx
.wallet()
.get_private_account_commitment(account_id)
.with_context(|| format!("Failed to get commitment for {label}"))?;
assert!(verify_commitment_is_in_state(commitment, ctx.sequencer_client()).await);
Ok(())
}
/// Sync the wallet's private accounts.
pub async fn sync_private(ctx: &mut TestContext) -> Result<()> {
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::SyncPrivate {}),
)
.await?;
Ok(())
}
/// Look up a restored private account for `account_id`, panicking with `label` if absent.
pub fn restored_private_account<'ctx>(
ctx: &'ctx TestContext,
account_id: AccountId,
label: &str,
) -> FoundPrivateAccount<'ctx> {
ctx.wallet()
.storage()
.key_chain()
.private_account(account_id)
.unwrap_or_else(|| panic!("{label} should be restored"))
}
/// Assert that a restored public account's signing key exists, panicking with `label` if absent.
pub fn assert_public_account_restored(ctx: &TestContext, account_id: AccountId, label: &str) {
ctx.wallet()
.storage()
.key_chain()
.pub_account_signing_key(account_id)
.unwrap_or_else(|| panic!("{label} should be restored"));
}
/// Poll the indexer until its last finalized block id reaches the sequencer's
/// current last block id or until [`L2_TO_L1_TIMEOUT`] elapses.

View File

@ -4,12 +4,11 @@
)]
use anyhow::{Context as _, Result};
use integration_tests::{TestContext, private_mention};
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 sequencer_service_rpc::RpcClient as _;
use tokio::test;
use wallet::{
account::{AccountIdWithPrivacy, HumanReadableAccount, Label},
@ -24,10 +23,7 @@ use wallet::{
async fn get_existing_account() -> Result<()> {
let ctx = TestContext::new().await?;
let account = ctx
.sequencer_client()
.get_account(ctx.existing_public_accounts()[0])
.await?;
let account = get_account(&ctx, ctx.existing_public_accounts()[0]).await?;
assert_eq!(
account.program_owner,
@ -95,18 +91,7 @@ async fn add_label_to_existing_account() -> Result<()> {
async fn new_public_account_without_label() -> Result<()> {
let mut ctx = TestContext::new().await?;
let command = Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
}));
let result = execute_subcommand(ctx.wallet_mut(), command).await?;
// Extract the account_id from the result
let wallet::cli::SubcommandReturnValue::RegisterAccount { account_id } = result else {
panic!("Expected RegisterAccount return value")
};
let account_id = new_account(&mut ctx, false, None).await?;
// Verify no label was stored for the account id
assert!(
@ -156,7 +141,11 @@ async fn import_private_account() -> Result<()> {
let mut ctx = TestContext::new().await?;
let key_chain = KeyChain::new_os_random();
let account_id = lee::AccountId::from((&key_chain.nullifier_public_key, 0));
let account_id = lee::AccountId::from((
&key_chain.nullifier_public_key,
&key_chain.viewing_public_key,
0,
));
let account = lee::Account {
program_owner: programs::authenticated_transfer().id(),
balance: 777,
@ -213,7 +202,11 @@ async fn import_private_account_second_time_overrides_account_data() -> Result<(
let mut ctx = TestContext::new().await?;
let key_chain = KeyChain::new_os_random();
let account_id = lee::AccountId::from((&key_chain.nullifier_public_key, 0));
let account_id = lee::AccountId::from((
&key_chain.nullifier_public_key,
&key_chain.viewing_public_key,
0,
));
let key_chain_json =
serde_json::to_string(&key_chain).context("Failed to serialize key chain")?;

View File

@ -7,16 +7,18 @@
use std::time::Duration;
use anyhow::Result;
use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, public_mention};
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, create_token, get_account, new_account,
public_mention, token_send,
};
use log::info;
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
use wallet::{
account::Label,
cli::{
Command, SubcommandReturnValue,
account::{AccountSubcommand, NewSubcommand},
programs::{amm::AmmProgramAgnosticSubcommand, token::TokenProgramAgnosticSubcommand},
programs::amm::AmmProgramAgnosticSubcommand,
},
};
@ -25,148 +27,60 @@ async fn amm_public() -> Result<()> {
let mut ctx = TestContext::new().await?;
// Create new account for the token definition
let SubcommandReturnValue::RegisterAccount {
account_id: definition_account_id_1,
} = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let definition_account_id_1 = new_account(&mut ctx, false, None).await?;
// Create new account for the token supply holder
let SubcommandReturnValue::RegisterAccount {
account_id: supply_account_id_1,
} = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let supply_account_id_1 = new_account(&mut ctx, false, None).await?;
// Create new account for receiving a token transaction
let SubcommandReturnValue::RegisterAccount {
account_id: recipient_account_id_1,
} = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let recipient_account_id_1 = new_account(&mut ctx, false, None).await?;
// Create new account for the token definition
let SubcommandReturnValue::RegisterAccount {
account_id: definition_account_id_2,
} = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let definition_account_id_2 = new_account(&mut ctx, false, None).await?;
// Create new account for the token supply holder
let SubcommandReturnValue::RegisterAccount {
account_id: supply_account_id_2,
} = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let supply_account_id_2 = new_account(&mut ctx, false, None).await?;
// Create new account for receiving a token transaction
let SubcommandReturnValue::RegisterAccount {
account_id: recipient_account_id_2,
} = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let recipient_account_id_2 = new_account(&mut ctx, false, None).await?;
// Create new token
let subcommand = TokenProgramAgnosticSubcommand::New {
definition_account_id: public_mention(definition_account_id_1),
supply_account_id: public_mention(supply_account_id_1),
name: "A NAM1".to_owned(),
total_supply: 37,
};
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
create_token(
&mut ctx,
public_mention(definition_account_id_1),
public_mention(supply_account_id_1),
"A NAM1".to_owned(),
37,
)
.await?;
// Transfer 7 tokens from `supply_acc` to the account at account_id `recipient_account_id_1`
let subcommand = TokenProgramAgnosticSubcommand::Send {
from: public_mention(supply_account_id_1),
to: Some(public_mention(recipient_account_id_1)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 7,
};
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
token_send(
&mut ctx,
public_mention(supply_account_id_1),
public_mention(recipient_account_id_1),
7,
)
.await?;
// Create new token
let subcommand = TokenProgramAgnosticSubcommand::New {
definition_account_id: public_mention(definition_account_id_2),
supply_account_id: public_mention(supply_account_id_2),
name: "A NAM2".to_owned(),
total_supply: 37,
};
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
create_token(
&mut ctx,
public_mention(definition_account_id_2),
public_mention(supply_account_id_2),
"A NAM2".to_owned(),
37,
)
.await?;
// Transfer 7 tokens from `supply_acc` to the account at account_id `recipient_account_id_2`
let subcommand = TokenProgramAgnosticSubcommand::Send {
from: public_mention(supply_account_id_2),
to: Some(public_mention(recipient_account_id_2)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 7,
};
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
token_send(
&mut ctx,
public_mention(supply_account_id_2),
public_mention(recipient_account_id_2),
7,
)
.await?;
info!("=================== SETUP FINISHED ===============");
@ -174,19 +88,7 @@ async fn amm_public() -> Result<()> {
// Setup accounts
// Create new account for the user holding lp
let SubcommandReturnValue::RegisterAccount {
account_id: user_holding_lp,
} = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let user_holding_lp = new_account(&mut ctx, false, None).await?;
// Send creation tx
let subcommand = AmmProgramAgnosticSubcommand::New {
@ -201,17 +103,11 @@ async fn amm_public() -> Result<()> {
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let user_holding_a_acc = ctx
.sequencer_client()
.get_account(recipient_account_id_1)
.await?;
let user_holding_a_acc = get_account(&ctx, recipient_account_id_1).await?;
let user_holding_b_acc = ctx
.sequencer_client()
.get_account(recipient_account_id_2)
.await?;
let user_holding_b_acc = get_account(&ctx, recipient_account_id_2).await?;
let user_holding_lp_acc = ctx.sequencer_client().get_account(user_holding_lp).await?;
let user_holding_lp_acc = get_account(&ctx, user_holding_lp).await?;
assert_eq!(
u128::from_le_bytes(user_holding_a_acc.data[33..].try_into().unwrap()),
@ -244,17 +140,11 @@ async fn amm_public() -> Result<()> {
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let user_holding_a_acc = ctx
.sequencer_client()
.get_account(recipient_account_id_1)
.await?;
let user_holding_a_acc = get_account(&ctx, recipient_account_id_1).await?;
let user_holding_b_acc = ctx
.sequencer_client()
.get_account(recipient_account_id_2)
.await?;
let user_holding_b_acc = get_account(&ctx, recipient_account_id_2).await?;
let user_holding_lp_acc = ctx.sequencer_client().get_account(user_holding_lp).await?;
let user_holding_lp_acc = get_account(&ctx, user_holding_lp).await?;
assert_eq!(
u128::from_le_bytes(user_holding_a_acc.data[33..].try_into().unwrap()),
@ -287,17 +177,11 @@ async fn amm_public() -> Result<()> {
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let user_holding_a_acc = ctx
.sequencer_client()
.get_account(recipient_account_id_1)
.await?;
let user_holding_a_acc = get_account(&ctx, recipient_account_id_1).await?;
let user_holding_b_acc = ctx
.sequencer_client()
.get_account(recipient_account_id_2)
.await?;
let user_holding_b_acc = get_account(&ctx, recipient_account_id_2).await?;
let user_holding_lp_acc = ctx.sequencer_client().get_account(user_holding_lp).await?;
let user_holding_lp_acc = get_account(&ctx, user_holding_lp).await?;
assert_eq!(
u128::from_le_bytes(user_holding_a_acc.data[33..].try_into().unwrap()),
@ -331,17 +215,11 @@ async fn amm_public() -> Result<()> {
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let user_holding_a_acc = ctx
.sequencer_client()
.get_account(recipient_account_id_1)
.await?;
let user_holding_a_acc = get_account(&ctx, recipient_account_id_1).await?;
let user_holding_b_acc = ctx
.sequencer_client()
.get_account(recipient_account_id_2)
.await?;
let user_holding_b_acc = get_account(&ctx, recipient_account_id_2).await?;
let user_holding_lp_acc = ctx.sequencer_client().get_account(user_holding_lp).await?;
let user_holding_lp_acc = get_account(&ctx, user_holding_lp).await?;
assert_eq!(
u128::from_le_bytes(user_holding_a_acc.data[33..].try_into().unwrap()),
@ -375,17 +253,11 @@ async fn amm_public() -> Result<()> {
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let user_holding_a_acc = ctx
.sequencer_client()
.get_account(recipient_account_id_1)
.await?;
let user_holding_a_acc = get_account(&ctx, recipient_account_id_1).await?;
let user_holding_b_acc = ctx
.sequencer_client()
.get_account(recipient_account_id_2)
.await?;
let user_holding_b_acc = get_account(&ctx, recipient_account_id_2).await?;
let user_holding_lp_acc = ctx.sequencer_client().get_account(user_holding_lp).await?;
let user_holding_lp_acc = get_account(&ctx, user_holding_lp).await?;
assert_eq!(
u128::from_le_bytes(user_holding_a_acc.data[33..].try_into().unwrap()),
@ -412,33 +284,9 @@ async fn amm_new_pool_using_labels() -> Result<()> {
let mut ctx = TestContext::new().await?;
// Create token 1 accounts
let SubcommandReturnValue::RegisterAccount {
account_id: definition_account_id_1,
} = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let definition_account_id_1 = new_account(&mut ctx, false, None).await?;
let SubcommandReturnValue::RegisterAccount {
account_id: supply_account_id_1,
} = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let supply_account_id_1 = new_account(&mut ctx, false, None).await?;
// Create holding_a with a label
let holding_a_label = Label::new("amm-holding-a-label");
@ -457,33 +305,9 @@ async fn amm_new_pool_using_labels() -> Result<()> {
};
// Create token 2 accounts
let SubcommandReturnValue::RegisterAccount {
account_id: definition_account_id_2,
} = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let definition_account_id_2 = new_account(&mut ctx, false, None).await?;
let SubcommandReturnValue::RegisterAccount {
account_id: supply_account_id_2,
} = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let supply_account_id_2 = new_account(&mut ctx, false, None).await?;
// Create holding_b with a label
let holding_b_label = Label::new("amm-holding-b-label");
@ -518,48 +342,40 @@ async fn amm_new_pool_using_labels() -> Result<()> {
};
// Create token 1 and distribute to holding_a
let subcommand = TokenProgramAgnosticSubcommand::New {
definition_account_id: public_mention(definition_account_id_1),
supply_account_id: public_mention(supply_account_id_1),
name: "TOKEN1".to_owned(),
total_supply: 10,
};
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
create_token(
&mut ctx,
public_mention(definition_account_id_1),
public_mention(supply_account_id_1),
"TOKEN1".to_owned(),
10,
)
.await?;
let subcommand = TokenProgramAgnosticSubcommand::Send {
from: public_mention(supply_account_id_1),
to: Some(public_mention(holding_a_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 5,
};
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
token_send(
&mut ctx,
public_mention(supply_account_id_1),
public_mention(holding_a_id),
5,
)
.await?;
// Create token 2 and distribute to holding_b
let subcommand = TokenProgramAgnosticSubcommand::New {
definition_account_id: public_mention(definition_account_id_2),
supply_account_id: public_mention(supply_account_id_2),
name: "TOKEN2".to_owned(),
total_supply: 10,
};
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
create_token(
&mut ctx,
public_mention(definition_account_id_2),
public_mention(supply_account_id_2),
"TOKEN2".to_owned(),
10,
)
.await?;
let subcommand = TokenProgramAgnosticSubcommand::Send {
from: public_mention(supply_account_id_2),
to: Some(public_mention(holding_b_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 5,
};
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::Token(subcommand)).await?;
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
token_send(
&mut ctx,
public_mention(supply_account_id_2),
public_mention(holding_b_id),
5,
)
.await?;
// Create AMM pool using account labels instead of IDs
let subcommand = AmmProgramAgnosticSubcommand::New {
@ -572,7 +388,7 @@ async fn amm_new_pool_using_labels() -> Result<()> {
wallet::cli::execute_subcommand(ctx.wallet_mut(), Command::AMM(subcommand)).await?;
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let holding_lp_acc = ctx.sequencer_client().get_account(holding_lp_id).await?;
let holding_lp_acc = get_account(&ctx, holding_lp_id).await?;
// LP balance should be 3 (geometric mean of 3, 3)
assert_eq!(

View File

@ -9,75 +9,34 @@ use std::time::Duration;
use anyhow::{Context as _, Result};
use associated_token_account_core::{compute_ata_seed, get_associated_token_account_id};
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention,
verify_commitment_is_in_state,
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, create_token, get_account, new_account,
private_mention, public_mention, token_send, verify_commitment_is_in_state,
};
use log::info;
use sequencer_service_rpc::RpcClient as _;
use token_core::{TokenDefinition, TokenHolding};
use tokio::test;
use wallet::cli::{
Command, SubcommandReturnValue,
account::{AccountSubcommand, NewSubcommand},
programs::{ata::AtaSubcommand, token::TokenProgramAgnosticSubcommand},
};
/// Create a public account and return its ID.
async fn new_public_account(ctx: &mut TestContext) -> Result<lee::AccountId> {
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount { account_id } = result else {
anyhow::bail!("Expected RegisterAccount return value");
};
Ok(account_id)
}
/// Create a private account and return its ID.
async fn new_private_account(ctx: &mut TestContext) -> Result<lee::AccountId> {
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount { account_id } = result else {
anyhow::bail!("Expected RegisterAccount return value");
};
Ok(account_id)
}
use wallet::cli::{Command, programs::ata::AtaSubcommand};
#[test]
async fn create_ata_initializes_holding_account() -> Result<()> {
let mut ctx = TestContext::new().await?;
let definition_account_id = new_public_account(&mut ctx).await?;
let supply_account_id = new_public_account(&mut ctx).await?;
let owner_account_id = new_public_account(&mut ctx).await?;
let definition_account_id = new_account(&mut ctx, false, None).await?;
let supply_account_id = new_account(&mut ctx, false, None).await?;
let owner_account_id = new_account(&mut ctx, false, None).await?;
// Create a fungible token
let total_supply = 100_u128;
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::New {
definition_account_id: public_mention(definition_account_id),
supply_account_id: public_mention(supply_account_id),
name: "TEST".to_owned(),
total_supply,
}),
create_token(
&mut ctx,
public_mention(definition_account_id),
public_mention(supply_account_id),
"TEST".to_owned(),
total_supply,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Create the ATA for owner + definition
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
@ -121,25 +80,20 @@ async fn create_ata_initializes_holding_account() -> Result<()> {
async fn create_ata_is_idempotent() -> Result<()> {
let mut ctx = TestContext::new().await?;
let definition_account_id = new_public_account(&mut ctx).await?;
let supply_account_id = new_public_account(&mut ctx).await?;
let owner_account_id = new_public_account(&mut ctx).await?;
let definition_account_id = new_account(&mut ctx, false, None).await?;
let supply_account_id = new_account(&mut ctx, false, None).await?;
let owner_account_id = new_account(&mut ctx, false, None).await?;
// Create a fungible token
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::New {
definition_account_id: public_mention(definition_account_id),
supply_account_id: public_mention(supply_account_id),
name: "TEST".to_owned(),
total_supply: 100,
}),
create_token(
&mut ctx,
public_mention(definition_account_id),
public_mention(supply_account_id),
"TEST".to_owned(),
100,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Create the ATA once
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
@ -196,28 +150,23 @@ async fn create_ata_is_idempotent() -> Result<()> {
async fn transfer_and_burn_via_ata() -> Result<()> {
let mut ctx = TestContext::new().await?;
let definition_account_id = new_public_account(&mut ctx).await?;
let supply_account_id = new_public_account(&mut ctx).await?;
let sender_account_id = new_public_account(&mut ctx).await?;
let recipient_account_id = new_public_account(&mut ctx).await?;
let definition_account_id = new_account(&mut ctx, false, None).await?;
let supply_account_id = new_account(&mut ctx, false, None).await?;
let sender_account_id = new_account(&mut ctx, false, None).await?;
let recipient_account_id = new_account(&mut ctx, false, None).await?;
let total_supply = 1000_u128;
// Create a fungible token, supply goes to supply_account_id
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::New {
definition_account_id: public_mention(definition_account_id),
supply_account_id: public_mention(supply_account_id),
name: "TEST".to_owned(),
total_supply,
}),
create_token(
&mut ctx,
public_mention(definition_account_id),
public_mention(supply_account_id),
"TEST".to_owned(),
total_supply,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Derive ATA addresses
let ata_program_id = programs::ata().id();
let sender_ata_id = get_associated_token_account_id(
@ -252,23 +201,14 @@ async fn transfer_and_burn_via_ata() -> Result<()> {
// Fund sender's ATA from the supply account (direct token transfer)
let fund_amount = 200_u128;
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::Send {
from: public_mention(supply_account_id),
to: Some(public_mention(sender_ata_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: fund_amount,
}),
token_send(
&mut ctx,
public_mention(supply_account_id),
public_mention(sender_ata_id),
fund_amount,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Transfer from sender's ATA to recipient's ATA via the ATA program
let transfer_amount = 50_u128;
wallet::cli::execute_subcommand(
@ -286,7 +226,7 @@ async fn transfer_and_burn_via_ata() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Verify sender ATA balance decreased
let sender_ata_acc = ctx.sequencer_client().get_account(sender_ata_id).await?;
let sender_ata_acc = get_account(&ctx, sender_ata_id).await?;
let sender_holding = TokenHolding::try_from(&sender_ata_acc.data)?;
assert_eq!(
sender_holding,
@ -297,7 +237,7 @@ async fn transfer_and_burn_via_ata() -> Result<()> {
);
// Verify recipient ATA balance increased
let recipient_ata_acc = ctx.sequencer_client().get_account(recipient_ata_id).await?;
let recipient_ata_acc = get_account(&ctx, recipient_ata_id).await?;
let recipient_holding = TokenHolding::try_from(&recipient_ata_acc.data)?;
assert_eq!(
recipient_holding,
@ -323,7 +263,7 @@ async fn transfer_and_burn_via_ata() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Verify sender ATA balance after burn
let sender_ata_acc = ctx.sequencer_client().get_account(sender_ata_id).await?;
let sender_ata_acc = get_account(&ctx, sender_ata_id).await?;
let sender_holding = TokenHolding::try_from(&sender_ata_acc.data)?;
assert_eq!(
sender_holding,
@ -334,10 +274,7 @@ async fn transfer_and_burn_via_ata() -> Result<()> {
);
// Verify the token definition total_supply decreased by burn_amount
let definition_acc = ctx
.sequencer_client()
.get_account(definition_account_id)
.await?;
let definition_acc = get_account(&ctx, definition_account_id).await?;
let token_definition = TokenDefinition::try_from(&definition_acc.data)?;
assert_eq!(
token_definition,
@ -355,25 +292,20 @@ async fn transfer_and_burn_via_ata() -> Result<()> {
async fn create_ata_with_private_owner() -> Result<()> {
let mut ctx = TestContext::new().await?;
let definition_account_id = new_public_account(&mut ctx).await?;
let supply_account_id = new_public_account(&mut ctx).await?;
let owner_account_id = new_private_account(&mut ctx).await?;
let definition_account_id = new_account(&mut ctx, false, None).await?;
let supply_account_id = new_account(&mut ctx, false, None).await?;
let owner_account_id = new_account(&mut ctx, true, None).await?;
// Create a fungible token
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::New {
definition_account_id: public_mention(definition_account_id),
supply_account_id: public_mention(supply_account_id),
name: "TEST".to_owned(),
total_supply: 100,
}),
create_token(
&mut ctx,
public_mention(definition_account_id),
public_mention(supply_account_id),
"TEST".to_owned(),
100,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Create the ATA for the private owner + definition
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
@ -424,28 +356,23 @@ async fn create_ata_with_private_owner() -> Result<()> {
async fn transfer_via_ata_private_owner() -> Result<()> {
let mut ctx = TestContext::new().await?;
let definition_account_id = new_public_account(&mut ctx).await?;
let supply_account_id = new_public_account(&mut ctx).await?;
let sender_account_id = new_private_account(&mut ctx).await?;
let recipient_account_id = new_public_account(&mut ctx).await?;
let definition_account_id = new_account(&mut ctx, false, None).await?;
let supply_account_id = new_account(&mut ctx, false, None).await?;
let sender_account_id = new_account(&mut ctx, true, None).await?;
let recipient_account_id = new_account(&mut ctx, false, None).await?;
let total_supply = 1000_u128;
// Create a fungible token
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::New {
definition_account_id: public_mention(definition_account_id),
supply_account_id: public_mention(supply_account_id),
name: "TEST".to_owned(),
total_supply,
}),
create_token(
&mut ctx,
public_mention(definition_account_id),
public_mention(supply_account_id),
"TEST".to_owned(),
total_supply,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Derive ATA addresses
let ata_program_id = programs::ata().id();
let sender_ata_id = get_associated_token_account_id(
@ -480,23 +407,14 @@ async fn transfer_via_ata_private_owner() -> Result<()> {
// Fund sender's ATA from the supply account (direct token transfer)
let fund_amount = 200_u128;
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::Send {
from: public_mention(supply_account_id),
to: Some(public_mention(sender_ata_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: fund_amount,
}),
token_send(
&mut ctx,
public_mention(supply_account_id),
public_mention(sender_ata_id),
fund_amount,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Transfer from sender's ATA (private owner) to recipient's ATA
let transfer_amount = 50_u128;
wallet::cli::execute_subcommand(
@ -514,7 +432,7 @@ async fn transfer_via_ata_private_owner() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Verify sender ATA balance decreased
let sender_ata_acc = ctx.sequencer_client().get_account(sender_ata_id).await?;
let sender_ata_acc = get_account(&ctx, sender_ata_id).await?;
let sender_holding = TokenHolding::try_from(&sender_ata_acc.data)?;
assert_eq!(
sender_holding,
@ -525,7 +443,7 @@ async fn transfer_via_ata_private_owner() -> Result<()> {
);
// Verify recipient ATA balance increased
let recipient_ata_acc = ctx.sequencer_client().get_account(recipient_ata_id).await?;
let recipient_ata_acc = get_account(&ctx, recipient_ata_id).await?;
let recipient_holding = TokenHolding::try_from(&recipient_ata_acc.data)?;
assert_eq!(
recipient_holding,
@ -549,27 +467,22 @@ async fn transfer_via_ata_private_owner() -> Result<()> {
async fn burn_via_ata_private_owner() -> Result<()> {
let mut ctx = TestContext::new().await?;
let definition_account_id = new_public_account(&mut ctx).await?;
let supply_account_id = new_public_account(&mut ctx).await?;
let holder_account_id = new_private_account(&mut ctx).await?;
let definition_account_id = new_account(&mut ctx, false, None).await?;
let supply_account_id = new_account(&mut ctx, false, None).await?;
let holder_account_id = new_account(&mut ctx, true, None).await?;
let total_supply = 500_u128;
// Create a fungible token
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::New {
definition_account_id: public_mention(definition_account_id),
supply_account_id: public_mention(supply_account_id),
name: "TEST".to_owned(),
total_supply,
}),
create_token(
&mut ctx,
public_mention(definition_account_id),
public_mention(supply_account_id),
"TEST".to_owned(),
total_supply,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Derive holder's ATA address
let ata_program_id = programs::ata().id();
let holder_ata_id = get_associated_token_account_id(
@ -592,23 +505,14 @@ async fn burn_via_ata_private_owner() -> Result<()> {
// Fund holder's ATA from the supply account
let fund_amount = 300_u128;
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Token(TokenProgramAgnosticSubcommand::Send {
from: public_mention(supply_account_id),
to: Some(public_mention(holder_ata_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: fund_amount,
}),
token_send(
&mut ctx,
public_mention(supply_account_id),
public_mention(holder_ata_id),
fund_amount,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Burn from holder's ATA (private owner)
let burn_amount = 100_u128;
wallet::cli::execute_subcommand(
@ -625,7 +529,7 @@ async fn burn_via_ata_private_owner() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Verify holder ATA balance after burn
let holder_ata_acc = ctx.sequencer_client().get_account(holder_ata_id).await?;
let holder_ata_acc = get_account(&ctx, holder_ata_id).await?;
let holder_holding = TokenHolding::try_from(&holder_ata_acc.data)?;
assert_eq!(
holder_holding,
@ -636,10 +540,7 @@ async fn burn_via_ata_private_owner() -> Result<()> {
);
// Verify the token definition total_supply decreased by burn_amount
let definition_acc = ctx
.sequencer_client()
.get_account(definition_account_id)
.await?;
let definition_acc = get_account(&ctx, definition_account_id).await?;
let token_definition = TokenDefinition::try_from(&definition_acc.data)?;
assert_eq!(
token_definition,

View File

@ -3,17 +3,18 @@ use std::time::Duration;
use anyhow::{Context as _, Result};
use common::transaction::LeeTransaction;
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, fetch_privacy_preserving_tx, private_mention,
public_mention, verify_commitment_is_in_state,
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance,
assert_private_commitment_in_state, fetch_privacy_preserving_tx, get_account, new_account,
private_mention, public_mention, send, sync_private, verify_commitment_is_in_state,
};
use lee::{
AccountId, SharedSecretKey, execute_and_prove,
privacy_preserving_transaction::circuit::ProgramWithDependencies, program::Program,
AccountId, execute_and_prove, privacy_preserving_transaction::circuit::ProgramWithDependencies,
program::Program,
};
use lee_core::{
EncryptedAccountData, InputAccountIdentity, NullifierPublicKey,
account::AccountWithMetadata,
encryption::{EphemeralPublicKey, ViewingPublicKey},
DUMMY_COMMITMENT_HASH, InputAccountIdentity, Nullifier, NullifierPublicKey,
account::{Account, AccountWithMetadata},
encryption::ViewingPublicKey,
};
use log::info;
use sequencer_service_rpc::RpcClient as _;
@ -34,32 +35,13 @@ async fn private_transfer_to_owned_account() -> Result<()> {
let from: AccountId = ctx.existing_private_accounts()[0];
let to: AccountId = ctx.existing_private_accounts()[1];
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: private_mention(from),
to: Some(private_mention(to)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
send(&mut ctx, private_mention(from), private_mention(to), 100).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let new_commitment1 = ctx
.wallet()
.get_private_account_commitment(from)
.context("Failed to get private account commitment for sender")?;
assert!(verify_commitment_is_in_state(new_commitment1, ctx.sequencer_client()).await);
let new_commitment2 = ctx
.wallet()
.get_private_account_commitment(to)
.context("Failed to get private account commitment for receiver")?;
assert!(verify_commitment_is_in_state(new_commitment2, ctx.sequencer_client()).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");
@ -86,8 +68,8 @@ async fn private_transfer_to_foreign_account() -> Result<()> {
});
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = result else {
anyhow::bail!("Expected PrivacyPreservingTransfer return value");
let SubcommandReturnValue::TransactionExecuted { tx_hash } = result else {
anyhow::bail!("Expected TransactionExecuted return value");
};
info!("Waiting for next block creation");
@ -125,17 +107,7 @@ async fn deshielded_transfer_to_public_account() -> Result<()> {
.context("Failed to get sender's private account")?;
assert_eq!(from_acc.balance, 10000);
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: private_mention(from),
to: Some(public_mention(to)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
send(&mut ctx, private_mention(from), public_mention(to), 100).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
@ -144,13 +116,9 @@ async fn deshielded_transfer_to_public_account() -> Result<()> {
.wallet()
.get_account_private(from)
.context("Failed to get sender's private account")?;
let new_commitment = ctx
.wallet()
.get_private_account_commitment(from)
.context("Failed to get private account commitment")?;
assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await);
assert_private_commitment_in_state(&ctx, from, "sender").await?;
let acc_2_balance = ctx.sequencer_client().get_account_balance(to).await?;
let acc_2_balance = account_balance(&ctx, to).await?;
assert_eq!(from_acc.balance, 9900);
assert_eq!(acc_2_balance, 20100);
@ -167,18 +135,7 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> {
let from: AccountId = ctx.existing_private_accounts()[0];
// Create a new private account
let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
}));
let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::RegisterAccount {
account_id: to_account_id,
} = sub_ret
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let to_account_id = new_account(&mut ctx, true, None).await?;
// Get the keys for the newly created account
let to = ctx
@ -200,21 +157,20 @@ async fn private_transfer_to_owned_account_using_claiming_path() -> Result<()> {
});
let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = sub_ret else {
anyhow::bail!("Expected PrivacyPreservingTransfer return value");
let SubcommandReturnValue::TransactionExecuted { tx_hash } = sub_ret else {
anyhow::bail!("Expected TransactionExecuted return value");
};
let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await;
// Sync the wallet to claim the new account
let command = Command::Account(AccountSubcommand::SyncPrivate {});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
sync_private(&mut ctx).await?;
let new_commitment1 = ctx
let sender_commitment = ctx
.wallet()
.get_private_account_commitment(from)
.context("Failed to get private account commitment for sender")?;
assert_eq!(tx.message.new_commitments[0], new_commitment1);
assert_eq!(tx.message.new_commitments[0], sender_commitment);
assert_eq!(tx.message.new_commitments.len(), 2);
for commitment in tx.message.new_commitments {
@ -239,17 +195,7 @@ async fn shielded_transfer_to_owned_private_account() -> Result<()> {
let from: AccountId = ctx.existing_public_accounts()[0];
let to: AccountId = ctx.existing_private_accounts()[1];
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: public_mention(from),
to: Some(private_mention(to)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
send(&mut ctx, public_mention(from), private_mention(to), 100).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
@ -258,13 +204,9 @@ async fn shielded_transfer_to_owned_private_account() -> Result<()> {
.wallet()
.get_account_private(to)
.context("Failed to get receiver's private account")?;
let new_commitment = ctx
.wallet()
.get_private_account_commitment(to)
.context("Failed to get receiver's commitment")?;
assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await);
assert_private_commitment_in_state(&ctx, to, "receiver").await?;
let acc_from_balance = ctx.sequencer_client().get_account_balance(from).await?;
let acc_from_balance = account_balance(&ctx, from).await?;
assert_eq!(acc_from_balance, 9900);
assert_eq!(acc_to.balance, 20100);
@ -294,8 +236,8 @@ async fn shielded_transfer_to_foreign_account() -> Result<()> {
});
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = result else {
anyhow::bail!("Expected PrivacyPreservingTransfer return value");
let SubcommandReturnValue::TransactionExecuted { tx_hash } = result else {
anyhow::bail!("Expected TransactionExecuted return value");
};
info!("Waiting for next block creation");
@ -303,7 +245,7 @@ async fn shielded_transfer_to_foreign_account() -> Result<()> {
let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await;
let acc_1_balance = ctx.sequencer_client().get_account_balance(from).await?;
let acc_1_balance = account_balance(&ctx, from).await?;
assert!(
verify_commitment_is_in_state(
@ -332,18 +274,7 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> {
let from: AccountId = ctx.existing_private_accounts()[0];
// Create a new private account
let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
}));
let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::RegisterAccount {
account_id: to_account_id,
} = sub_ret
else {
anyhow::bail!("Failed to register account");
};
let to_account_id = new_account(&mut ctx, true, None).await?;
// Get the newly created account's keys
let to = ctx
@ -365,7 +296,7 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> {
});
let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = sub_ret else {
let SubcommandReturnValue::TransactionExecuted { tx_hash } = sub_ret else {
anyhow::bail!("Failed to send transaction");
};
@ -396,14 +327,7 @@ async fn private_transfer_to_owned_account_continuous_run_path() -> Result<()> {
async fn initialize_private_account() -> Result<()> {
let mut ctx = TestContext::new().await?;
let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
}));
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::RegisterAccount { account_id } = result else {
anyhow::bail!("Expected RegisterAccount return value");
};
let account_id = new_account(&mut ctx, true, None).await?;
let command = Command::AuthTransfer(AuthTransferSubcommand::Init {
account_id: private_mention(account_id),
@ -413,14 +337,9 @@ async fn initialize_private_account() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
info!("Syncing private accounts");
let command = Command::Account(AccountSubcommand::SyncPrivate {});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
sync_private(&mut ctx).await?;
let new_commitment = ctx
.wallet()
.get_private_account_commitment(account_id)
.context("Failed to get private account commitment")?;
assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await);
assert_private_commitment_in_state(&ctx, account_id, "account").await?;
let account = ctx
.wallet()
@ -455,32 +374,19 @@ async fn private_transfer_using_from_label() -> Result<()> {
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
// Send using the label instead of account ID
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: CliAccountMention::Label(label),
to: Some(private_mention(to)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
send(
&mut ctx,
CliAccountMention::Label(label),
private_mention(to),
100,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let new_commitment1 = ctx
.wallet()
.get_private_account_commitment(from)
.context("Failed to get private account commitment for sender")?;
assert!(verify_commitment_is_in_state(new_commitment1, ctx.sequencer_client()).await);
let new_commitment2 = ctx
.wallet()
.get_private_account_commitment(to)
.context("Failed to get private account commitment for receiver")?;
assert!(verify_commitment_is_in_state(new_commitment2, ctx.sequencer_client()).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");
@ -510,14 +416,9 @@ async fn initialize_private_account_using_label() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let command = Command::Account(AccountSubcommand::SyncPrivate {});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
sync_private(&mut ctx).await?;
let new_commitment = ctx
.wallet()
.get_private_account_commitment(account_id)
.context("Failed to get private account commitment")?;
assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await);
assert_private_commitment_in_state(&ctx, account_id, "account").await?;
let account = ctx
.wallet()
@ -593,21 +494,17 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> {
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::SyncPrivate {}),
)
.await?;
sync_private(&mut ctx).await?;
// Both accounts must be discovered with the correct balances.
let account_id_1 = AccountId::for_regular_private_account(&npk, identifier_1);
let account_id_1 = AccountId::for_regular_private_account(&npk, &vpk, identifier_1);
let acc_1 = ctx
.wallet()
.get_account_private(account_id_1)
.context("account for identifier 1 not found after sync")?;
assert_eq!(acc_1.balance, 100);
let account_id_2 = AccountId::for_regular_private_account(&npk, identifier_2);
let account_id_2 = AccountId::for_regular_private_account(&npk, &vpk, identifier_2);
let acc_2 = ctx
.wallet()
.get_account_private(account_id_2)
@ -663,25 +560,19 @@ async fn ppt_cant_chain_call_faucet() -> Result<()> {
let nsk: lee_core::NullifierSecretKey = [3; 32];
let npk = NullifierPublicKey::from(&nsk);
let vpk = ViewingPublicKey::from_bytes(vec![4_u8; 1184]).unwrap();
let ssk = SharedSecretKey([55_u8; 32]);
let epk = EphemeralPublicKey(vec![55_u8; 1088]);
let attacker_vault_id = {
let seed = vault_core::compute_vault_seed(attacker_id);
AccountId::for_private_pda(&vault_program_id, &seed, &npk, 1337)
AccountId::for_private_pda(&vault_program_id, &seed, &npk, &vpk, 1337)
};
let amount: u128 = 1;
let faucet_pre = AccountWithMetadata::new(
ctx.sequencer_client()
.get_account(faucet_account_id)
.await?,
get_account(&ctx, faucet_account_id).await?,
false,
faucet_account_id,
);
let vault_pda_pre = AccountWithMetadata::new(
ctx.sequencer_client()
.get_account(attacker_vault_id)
.await?,
get_account(&ctx, attacker_vault_id).await?,
false,
attacker_vault_id,
);
@ -705,11 +596,11 @@ async fn ppt_cant_chain_call_faucet() -> Result<()> {
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivatePdaInit {
epk,
view_tag: EncryptedAccountData::compute_view_tag(&npk, &vpk),
vpk,
random_seed: [0; 32],
npk,
ssk,
identifier: 1337,
commitment_root: DUMMY_COMMITMENT_HASH,
seed: None,
},
],
@ -720,3 +611,89 @@ async fn ppt_cant_chain_call_faucet() -> Result<()> {
Ok(())
}
async fn prove_init_with_commitment_root(
ctx: &TestContext,
commitment_root: lee_core::CommitmentSetDigest,
) -> Result<lee_core::PrivacyPreservingCircuitOutput> {
let program = programs::authenticated_transfer();
let sender_id = ctx.existing_public_accounts()[0];
let sender_pre = AccountWithMetadata::new(
ctx.sequencer_client().get_account(sender_id).await?,
true,
sender_id,
);
let nsk: lee_core::NullifierSecretKey = [7; 32];
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);
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id);
let (output, _) = execute_and_prove(
vec![sender_pre, recipient],
Program::serialize_instruction(authenticated_transfer_core::Instruction::Transfer {
amount: 1,
})?,
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
vpk,
random_seed: [0; 32],
npk,
identifier: 0,
commitment_root,
},
],
&program.into(),
)?;
Ok(output)
}
#[test]
async fn init_with_dummy_commitment_root_produces_valid_root() -> Result<()> {
let ctx = TestContext::new().await?;
let (_, expected_digest) = ctx.sequencer_client().get_proofs_and_root(vec![]).await?;
let nsk: lee_core::NullifierSecretKey = [7; 32];
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);
let output = prove_init_with_commitment_root(&ctx, expected_digest).await?;
assert_eq!(output.new_nullifiers.len(), 1);
let (nullifier, digest) = &output.new_nullifiers[0];
assert_eq!(
*nullifier,
Nullifier::for_account_initialization(&recipient_account_id)
);
assert_eq!(*digest, expected_digest);
assert_ne!(*digest, DUMMY_COMMITMENT_HASH);
Ok(())
}
#[test]
async fn init_nullifier_digest_is_bound_to_commitment_root() -> Result<()> {
let ctx = TestContext::new().await?;
let (_, expected_digest) = ctx.sequencer_client().get_proofs_and_root(vec![]).await?;
let output_with_root = prove_init_with_commitment_root(&ctx, expected_digest).await?;
let output_without_root = prove_init_with_commitment_root(&ctx, DUMMY_COMMITMENT_HASH).await?;
assert_eq!(output_with_root.new_nullifiers[0].1, expected_digest);
assert_eq!(
output_without_root.new_nullifiers[0].1,
DUMMY_COMMITMENT_HASH
);
assert_ne!(
output_with_root.new_nullifiers[0].1,
output_without_root.new_nullifiers[0].1,
);
Ok(())
}

View File

@ -2,7 +2,10 @@ use std::time::Duration;
use anyhow::Result;
use common::transaction::LeeTransaction;
use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, public_mention};
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, get_account, new_account,
public_mention, send,
};
use lee::public_transaction;
use log::info;
use sequencer_service_rpc::RpcClient as _;
@ -10,8 +13,7 @@ use tokio::test;
use wallet::{
account::Label,
cli::{
CliAccountMention, Command, SubcommandReturnValue,
account::{AccountSubcommand, NewSubcommand},
CliAccountMention, Command, account::AccountSubcommand,
programs::native_token_transfer::AuthTransferSubcommand,
},
};
@ -20,30 +22,22 @@ use wallet::{
async fn successful_transfer_to_existing_account() -> Result<()> {
let mut ctx = TestContext::new().await?;
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: public_mention(ctx.existing_public_accounts()[0]),
to: Some(public_mention(ctx.existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let sender = ctx.existing_public_accounts()[0];
let receiver = ctx.existing_public_accounts()[1];
send(
&mut ctx,
public_mention(sender),
public_mention(receiver),
100,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
info!("Checking correct balance move");
let acc_1_balance = ctx
.sequencer_client()
.get_account_balance(ctx.existing_public_accounts()[0])
.await?;
let acc_2_balance = ctx
.sequencer_client()
.get_account_balance(ctx.existing_public_accounts()[1])
.await?;
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:#?}");
@ -58,51 +52,23 @@ async fn successful_transfer_to_existing_account() -> Result<()> {
pub async fn successful_transfer_to_new_account() -> Result<()> {
let mut ctx = TestContext::new().await?;
let command = Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
}));
let new_persistent_account_id = new_account(&mut ctx, false, None).await?;
wallet::cli::execute_subcommand(ctx.wallet_mut(), command)
.await
.unwrap();
let new_persistent_account_id = ctx
.wallet()
.storage()
.key_chain()
.public_account_ids()
.map(|(account_id, _)| account_id)
.find(|acc_id| {
*acc_id != ctx.existing_public_accounts()[0]
&& *acc_id != ctx.existing_public_accounts()[1]
})
.expect("Failed to find newly created account in the wallet storage");
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: public_mention(ctx.existing_public_accounts()[0]),
to: Some(public_mention(new_persistent_account_id)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let sender = ctx.existing_public_accounts()[0];
send(
&mut ctx,
public_mention(sender),
public_mention(new_persistent_account_id),
100,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
info!("Checking correct balance move");
let acc_1_balance = ctx
.sequencer_client()
.get_account_balance(ctx.existing_public_accounts()[0])
.await?;
let acc_2_balance = ctx
.sequencer_client()
.get_account_balance(new_persistent_account_id)
.await?;
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:#?}");
@ -134,14 +100,8 @@ async fn failed_transfer_with_insufficient_balance() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
info!("Checking balances unchanged");
let acc_1_balance = ctx
.sequencer_client()
.get_account_balance(ctx.existing_public_accounts()[0])
.await?;
let acc_2_balance = ctx
.sequencer_client()
.get_account_balance(ctx.existing_public_accounts()[1])
.await?;
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:#?}");
@ -156,31 +116,24 @@ async fn failed_transfer_with_insufficient_balance() -> Result<()> {
async fn two_consecutive_successful_transfers() -> Result<()> {
let mut ctx = TestContext::new().await?;
// First transfer
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: public_mention(ctx.existing_public_accounts()[0]),
to: Some(public_mention(ctx.existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
let sender = ctx.existing_public_accounts()[0];
let receiver = ctx.existing_public_accounts()[1];
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
// First transfer
send(
&mut ctx,
public_mention(sender),
public_mention(receiver),
100,
)
.await?;
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");
let acc_1_balance = ctx
.sequencer_client()
.get_account_balance(ctx.existing_public_accounts()[0])
.await?;
let acc_2_balance = ctx
.sequencer_client()
.get_account_balance(ctx.existing_public_accounts()[1])
.await?;
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:#?}");
@ -191,30 +144,20 @@ async fn two_consecutive_successful_transfers() -> Result<()> {
info!("First TX Success!");
// Second transfer
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: public_mention(ctx.existing_public_accounts()[0]),
to: Some(public_mention(ctx.existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
send(
&mut ctx,
public_mention(sender),
public_mention(receiver),
100,
)
.await?;
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");
let acc_1_balance = ctx
.sequencer_client()
.get_account_balance(ctx.existing_public_accounts()[0])
.await?;
let acc_2_balance = ctx
.sequencer_client()
.get_account_balance(ctx.existing_public_accounts()[1])
.await?;
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:#?}");
@ -231,14 +174,7 @@ async fn two_consecutive_successful_transfers() -> Result<()> {
async fn initialize_public_account() -> Result<()> {
let mut ctx = TestContext::new().await?;
let command = Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
}));
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::RegisterAccount { account_id } = result else {
anyhow::bail!("Expected RegisterAccount return value");
};
let account_id = new_account(&mut ctx, false, None).await?;
let command = Command::AuthTransfer(AuthTransferSubcommand::Init {
account_id: public_mention(account_id),
@ -246,7 +182,7 @@ async fn initialize_public_account() -> Result<()> {
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
info!("Checking correct execution");
let account = ctx.sequencer_client().get_account(account_id).await?;
let account = get_account(&ctx, account_id).await?;
assert_eq!(
account.program_owner,
@ -274,30 +210,22 @@ async fn successful_transfer_using_from_label() -> Result<()> {
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
// Send using the label instead of account ID
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: CliAccountMention::Label(label),
to: Some(public_mention(ctx.existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let sender = ctx.existing_public_accounts()[0];
let receiver = ctx.existing_public_accounts()[1];
send(
&mut ctx,
CliAccountMention::Label(label),
public_mention(receiver),
100,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
info!("Checking correct balance move");
let acc_1_balance = ctx
.sequencer_client()
.get_account_balance(ctx.existing_public_accounts()[0])
.await?;
let acc_2_balance = ctx
.sequencer_client()
.get_account_balance(ctx.existing_public_accounts()[1])
.await?;
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);
@ -320,30 +248,22 @@ async fn successful_transfer_using_to_label() -> Result<()> {
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
// Send using the label for the recipient
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: public_mention(ctx.existing_public_accounts()[0]),
to: Some(CliAccountMention::Label(label)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let sender = ctx.existing_public_accounts()[0];
let receiver = ctx.existing_public_accounts()[1];
send(
&mut ctx,
public_mention(sender),
CliAccountMention::Label(label),
100,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
info!("Checking correct balance move");
let acc_1_balance = ctx
.sequencer_client()
.get_account_balance(ctx.existing_public_accounts()[0])
.await?;
let acc_2_balance = ctx
.sequencer_client()
.get_account_balance(ctx.existing_public_accounts()[1])
.await?;
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);
@ -359,14 +279,8 @@ async fn cannot_transfer_funds_from_system_faucet_account() -> Result<()> {
let faucet_account_id = system_accounts::faucet_account_id();
let recipient = ctx.existing_public_accounts()[0];
let recipient_balance_before = ctx
.sequencer_client()
.get_account_balance(recipient)
.await?;
let faucet_balance_before = ctx
.sequencer_client()
.get_account_balance(faucet_account_id)
.await?;
let recipient_balance_before = account_balance(&ctx, recipient).await?;
let faucet_balance_before = account_balance(&ctx, faucet_account_id).await?;
let amount = 1_u128;
let message = public_transaction::Message::try_new(
@ -387,14 +301,8 @@ async fn cannot_transfer_funds_from_system_faucet_account() -> Result<()> {
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let recipient_balance_after = ctx
.sequencer_client()
.get_account_balance(recipient)
.await?;
let faucet_balance_after = ctx
.sequencer_client()
.get_account_balance(faucet_account_id)
.await?;
let recipient_balance_after = account_balance(&ctx, recipient).await?;
let faucet_balance_after = account_balance(&ctx, faucet_account_id).await?;
let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?;
assert_eq!(recipient_balance_after, recipient_balance_before);
@ -413,14 +321,8 @@ async fn cannot_execute_faucet_program() -> Result<()> {
let vault_program_id = programs::vault().id();
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient);
let recipient_balance_before = ctx
.sequencer_client()
.get_account_balance(recipient)
.await?;
let faucet_balance_before = ctx
.sequencer_client()
.get_account_balance(faucet_account_id)
.await?;
let recipient_balance_before = account_balance(&ctx, recipient).await?;
let faucet_balance_before = account_balance(&ctx, faucet_account_id).await?;
let amount = 1_u128;
let message = public_transaction::Message::try_new(
@ -445,14 +347,8 @@ async fn cannot_execute_faucet_program() -> Result<()> {
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let recipient_balance_after = ctx
.sequencer_client()
.get_account_balance(recipient)
.await?;
let faucet_balance_after = ctx
.sequencer_client()
.get_account_balance(faucet_account_id)
.await?;
let recipient_balance_after = account_balance(&ctx, recipient).await?;
let faucet_balance_after = account_balance(&ctx, faucet_account_id).await?;
let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?;
assert_eq!(recipient_balance_after, recipient_balance_before);
@ -493,28 +389,16 @@ async fn user_tx_that_chain_calls_faucet_is_dropped() -> Result<()> {
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
));
let faucet_balance_before = ctx
.sequencer_client()
.get_account_balance(faucet_account_id)
.await?;
let vault_balance_before = ctx
.sequencer_client()
.get_account_balance(attacker_vault_id)
.await?;
let faucet_balance_before = account_balance(&ctx, faucet_account_id).await?;
let vault_balance_before = account_balance(&ctx, attacker_vault_id).await?;
let tx_hash = ctx.sequencer_client().send_transaction(attack_tx).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let faucet_balance_after = ctx
.sequencer_client()
.get_account_balance(faucet_account_id)
.await?;
let vault_balance_after = ctx
.sequencer_client()
.get_account_balance(attacker_vault_id)
.await?;
let faucet_balance_after = account_balance(&ctx, faucet_account_id).await?;
let vault_balance_after = account_balance(&ctx, attacker_vault_id).await?;
let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?;
assert_eq!(faucet_balance_after, faucet_balance_before);

View File

@ -11,7 +11,8 @@ use borsh::BorshSerialize;
use common::transaction::LeeTransaction;
use futures::StreamExt as _;
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, wait_for_indexer_to_catch_up,
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, get_account,
wait_for_indexer_to_catch_up,
};
use lee::{
AccountId, execute_and_prove, privacy_preserving_transaction, program::Program,
@ -65,27 +66,15 @@ async fn public_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
));
let bridge_balance_before = ctx
.sequencer_client()
.get_account_balance(bridge_account_id)
.await?;
let vault_balance_before = ctx
.sequencer_client()
.get_account_balance(recipient_vault_id)
.await?;
let bridge_balance_before = account_balance(&ctx, bridge_account_id).await?;
let vault_balance_before = account_balance(&ctx, recipient_vault_id).await?;
let tx_hash = ctx.sequencer_client().send_transaction(attack_tx).await?;
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let bridge_balance_after = ctx
.sequencer_client()
.get_account_balance(bridge_account_id)
.await?;
let vault_balance_after = ctx
.sequencer_client()
.get_account_balance(recipient_vault_id)
.await?;
let bridge_balance_after = account_balance(&ctx, bridge_account_id).await?;
let vault_balance_after = account_balance(&ctx, recipient_vault_id).await?;
let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?;
assert_eq!(bridge_balance_after, bridge_balance_before);
@ -169,16 +158,12 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
// Get pre-state of bridge and vault accounts
let bridge_pre = AccountWithMetadata::new(
ctx.sequencer_client()
.get_account(bridge_account_id)
.await?,
get_account(&ctx, bridge_account_id).await?,
false,
bridge_account_id,
);
let vault_pre = AccountWithMetadata::new(
ctx.sequencer_client()
.get_account(recipient_vault_id)
.await?,
get_account(&ctx, recipient_vault_id).await?,
false,
recipient_vault_id,
);
@ -229,27 +214,15 @@ async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> {
witness_set,
));
let bridge_balance_before = ctx
.sequencer_client()
.get_account_balance(bridge_account_id)
.await?;
let vault_balance_before = ctx
.sequencer_client()
.get_account_balance(recipient_vault_id)
.await?;
let bridge_balance_before = account_balance(&ctx, bridge_account_id).await?;
let vault_balance_before = account_balance(&ctx, recipient_vault_id).await?;
let tx_hash = ctx.sequencer_client().send_transaction(attack_tx).await?;
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let bridge_balance_after = ctx
.sequencer_client()
.get_account_balance(bridge_account_id)
.await?;
let vault_balance_after = ctx
.sequencer_client()
.get_account_balance(recipient_vault_id)
.await?;
let bridge_balance_after = account_balance(&ctx, bridge_account_id).await?;
let vault_balance_after = account_balance(&ctx, recipient_vault_id).await?;
let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?;
assert_eq!(bridge_balance_after, bridge_balance_before);
@ -421,7 +394,7 @@ async fn wait_for_vault_balance(
+ Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS);
tokio::time::timeout(timeout, async {
loop {
let balance = ctx.sequencer_client().get_account_balance(vault_id).await?;
let balance = account_balance(ctx, vault_id).await?;
if balance == expected_balance {
return Ok(());
}
@ -449,14 +422,8 @@ async fn bedrock_deposit_claim_and_withdraw_round_trip_succeeds() -> anyhow::Res
let vault_program_id = programs::vault().id();
let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id);
let vault_balance_before = ctx
.sequencer_client()
.get_account_balance(recipient_vault_id)
.await?;
let recipient_balance_before = ctx
.sequencer_client()
.get_account_balance(recipient_id)
.await?;
let vault_balance_before = account_balance(&ctx, recipient_vault_id).await?;
let recipient_balance_before = account_balance(&ctx, recipient_id).await?;
// Submit deposit to Bedrock
submit_bedrock_deposit(ctx.bedrock_addr(), bedrock_account_pk, recipient_id, amount)
@ -507,14 +474,8 @@ async fn bedrock_deposit_claim_and_withdraw_round_trip_succeeds() -> anyhow::Res
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let claim_on_chain = ctx.sequencer_client().get_transaction(claim_hash).await?;
let vault_balance_after_claim = ctx
.sequencer_client()
.get_account_balance(recipient_vault_id)
.await?;
let recipient_balance_after_claim = ctx
.sequencer_client()
.get_account_balance(recipient_id)
.await?;
let vault_balance_after_claim = account_balance(&ctx, recipient_vault_id).await?;
let recipient_balance_after_claim = account_balance(&ctx, recipient_id).await?;
assert!(
claim_on_chain.is_some(),
@ -543,7 +504,7 @@ async fn bedrock_deposit_claim_and_withdraw_round_trip_succeeds() -> anyhow::Res
account_id.into(),
)
.await?;
let sequencer_account = ctx.sequencer_client().get_account(account_id).await?;
let sequencer_account = get_account(&ctx, account_id).await?;
assert_eq!(
indexer_account,
sequencer_account.into(),

View File

@ -0,0 +1,197 @@
#![expect(
clippy::tests_outside_test_module,
reason = "top-level test functions are conventional for integration tests"
)]
//! Demo 2: a wrapped-token bridge over the cross-zone spine. A holder locks part
//! of their bridgeable balance on zone A; the watcher carries the emitted mint to
//! zone B, where the indexer re-derives and verifies it (Option B) before the
//! 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.
use std::{net::SocketAddr, time::Duration};
use anyhow::{Context as _, Result};
use common::transaction::LeeTransaction;
use cross_zone_outbox_core::outbox_pda;
use integration_tests::{
config::{self, SequencerPartialConfig},
indexer_client::IndexerClient,
setup::{setup_bedrock_node, setup_indexer, setup_sequencer},
};
use lee::{
AccountId, PrivateKey, PublicKey, PublicTransaction,
public_transaction::{Message, WitnessSet},
};
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer, GenesisAction};
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use tokio::test;
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600);
const INITIAL_BALANCE: u128 = 100;
const LOCK_AMOUNT: u128 = 30;
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();
let zone_b: [u8; 32] = *channel_b.as_ref();
let holder_key = PrivateKey::try_new([7; 32]).expect("valid key");
let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key));
let wrapped_token_id = programs::wrapped_token().id();
let cross_zone = CrossZoneConfig {
peers: vec![CrossZonePeer {
channel_id: *channel_a.as_ref(),
allowed_targets: vec![wrapped_token_id],
expected_block_signing_pubkey: None,
}],
};
// Zone A seeds the holder's bridgeable balance. Zone B runs the watcher on its
// sequencer and the verifier on its indexer.
let genesis_a = vec![GenesisAction::SupplyBridgeLockHolding {
holder: holder_id,
amount: INITIAL_BALANCE,
}];
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, genesis_a, channel_a, None)
.await
.context("Failed to set up zone A sequencer")?;
let (_seq_b, _seq_b_home) = setup_sequencer(
partial,
bedrock_addr,
vec![],
channel_b,
Some(cross_zone.clone()),
)
.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")?;
// 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())?
.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_url = config::addr_to_url(config::UrlProtocol::Ws, idx_b.addr())
.context("Failed to build indexer URL")?;
let indexer = IndexerClient::new(&indexer_url)
.await
.context("Failed to build indexer client")?;
let minted = wait_for_mint(&indexer, holding_id).await?;
assert_eq!(
minted, LOCK_AMOUNT,
"zone B must mint exactly the locked amount"
);
// 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 = bridge_lock_core::read_balance(
&seq_a_client.get_account(escrow_id).await?.data.into_inner(),
);
assert_eq!(
escrowed, LOCK_AMOUNT,
"zone A escrow must hold the locked amount"
);
let remaining = bridge_lock_core::read_balance(
&seq_a_client.get_account(holder_id).await?.data.into_inner(),
);
assert_eq!(
remaining,
INITIAL_BALANCE - LOCK_AMOUNT,
"zone A holder must be debited by the locked amount"
);
Ok(())
}
/// Builds a signed `bridge_lock` Lock that forwards a wrapped-token Mint of the
/// locked amount to the recipient on the target zone.
fn build_lock_tx(
holder_key: &PrivateKey,
holder_id: AccountId,
target_zone: [u8; 32],
) -> LeeTransaction {
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 ordinal = 0;
let mint = wrapped_token_core::Instruction::Mint {
recipient: RECIPIENT,
amount: LOCK_AMOUNT,
};
let words = risc0_zkvm::serde::to_vec(&mint).expect("serialize mint");
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
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,
target_program_id: wrapped_token_id,
target_accounts,
payload,
outbox_program_id: outbox_id,
ordinal,
};
let accounts = vec![
holder_id,
bridge_lock_core::escrow_account_id(bridge_lock_id),
outbox_pda(outbox_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)
.expect("build lock message");
let witness = WitnessSet::for_message(&message, &[holder_key]);
LeeTransaction::Public(PublicTransaction::new(message, witness))
}
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
.context("Failed to build sequencer URL")?;
SequencerClientBuilder::default()
.build(url)
.context("Failed to build sequencer client")
}
/// Polls zone B's indexer until the recipient's wrapped holding is non-zero.
async fn wait_for_mint(indexer: &IndexerClient, holding_id: AccountId) -> Result<u128> {
let account_id = indexer_service_protocol::AccountId {
value: holding_id.into_value(),
};
let wait = async {
loop {
let account =
indexer_service_rpc::RpcClient::get_account(&**indexer, account_id).await?;
let balance = wrapped_token_core::read_balance(&account.data.0);
if balance != 0 {
return Ok::<u128, anyhow::Error>(balance);
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
};
tokio::time::timeout(DELIVERY_TIMEOUT, wait)
.await
.context("Zone B's indexer did not mint the wrapped token in time")?
}

View File

@ -0,0 +1,80 @@
#![expect(
clippy::tests_outside_test_module,
reason = "We don't care about these in tests"
)]
//! M6 ingress guard: the cross-zone inbox is sequencer-only. Only the watcher
//! injects inbox dispatches; a user must not be able to invoke the inbox through
//! the public RPC, or anyone could forge an inbound cross-zone delivery. The
//! 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 std::net::SocketAddr;
use anyhow::{Context as _, 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::{setup_bedrock_node, setup_sequencer},
};
use lee::{
PublicTransaction,
public_transaction::{Message, WitnessSet},
};
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
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) = setup_sequencer(partial, bedrock_addr, vec![], channel, None)
.await
.context("Failed to set up sequencer")?;
// 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_tx_index: 0,
src_program_id: [9; 8],
target_program_id: programs::ping_receiver().id(),
payload: vec![],
l1_inclusion_witness: None,
};
let seen_id = inbox_seen_shard_account_id(inbox_id, &msg.src_zone, msg.src_block_id);
let message = Message::try_new(
inbox_id,
vec![inbox_config_account_id(inbox_id), seen_id],
vec![],
Instruction::Dispatch(msg),
)
.expect("build dispatch message");
let tx = LeeTransaction::Public(PublicTransaction::new(
message,
WitnessSet::from_raw_parts(vec![]),
));
let result = sequencer_client(seq.addr())?.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"),
"rejection should cite the sequencer-only guard, got: {err}"
);
Ok(())
}
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
.context("Failed to build sequencer URL")?;
SequencerClientBuilder::default()
.build(url)
.context("Failed to build sequencer client")
}

View File

@ -0,0 +1,143 @@
#![expect(
clippy::tests_outside_test_module,
reason = "top-level test functions are conventional for integration tests"
)]
//! End-to-end cross-zone round trip: a ping submitted on zone A is delivered by
//! zone B's watcher to `ping_receiver` on zone B, which records the payload.
//!
//! Two sequencers share one Bedrock node (no indexers): zone A publishes the
//! ping to Bedrock, zone B's watcher reads zone A's finalized blocks, injects the
//! inbox dispatch, and zone B's sequencer delivers it. This is the M3 milestone,
//! sequencer-trusted, with no indexer re-derivation (that is M4).
use std::{net::SocketAddr, 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::{setup_bedrock_node, setup_sequencer},
};
use lee::{AccountId, PublicTransaction, public_transaction::Message};
use lee_core::program::ProgramId;
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer};
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use tokio::test;
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(480);
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();
let zone_a: [u8; 32] = *channel_a.as_ref();
let zone_b: [u8; 32] = *channel_b.as_ref();
let receiver_id = programs::ping_receiver().id();
// Zone B watches zone A and allows delivery only to ping_receiver.
let cross_zone = CrossZoneConfig {
peers: vec![CrossZonePeer {
channel_id: zone_a,
allowed_targets: vec![receiver_id],
expected_block_signing_pubkey: None,
}],
};
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None)
.await
.context("Failed to set up zone A sequencer")?;
let (seq_b, _seq_b_home) =
setup_sequencer(partial, bedrock_addr, vec![], channel_b, Some(cross_zone))
.await
.context("Failed to set up zone B sequencer")?;
// 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())?
.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?;
assert_eq!(
delivered, PING_PAYLOAD,
"Zone B must record the payload delivered from zone A"
);
Ok(())
}
/// Builds a top-level `ping_sender` transaction that chains into the outbox to emit
/// a message carrying a `ping_receiver::Record` instruction for the target zone.
fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransaction {
let outbox_id = programs::cross_zone_outbox().id();
let ordinal = 0;
// The payload is the ping_receiver instruction, serialized as risc0 words in
// little-endian bytes (the contract the inbox reverses when forwarding).
let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record {
payload: PING_PAYLOAD.to_vec(),
})
.expect("serialize ping instruction");
let payload: Vec<u8> = 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()],
payload,
ordinal,
};
let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal);
let message = Message::try_new(
programs::ping_sender().id(),
vec![outbox_account],
vec![],
send,
)
.expect("build ping message");
LeeTransaction::Public(PublicTransaction::new(
message,
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
))
}
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
.context("Failed to build sequencer URL")?;
SequencerClientBuilder::default()
.build(url)
.context("Failed to build sequencer client")
}
/// Polls zone B's sequencer until the ping record PDA holds a payload.
async fn wait_for_delivery(client: SequencerClient, record_id: AccountId) -> Result<Vec<u8>> {
let wait = async {
loop {
let account = client.get_account(record_id).await?;
let data = account.data.into_inner();
if !data.is_empty() {
return Ok::<Vec<u8>, anyhow::Error>(data);
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
};
tokio::time::timeout(DELIVERY_TIMEOUT, wait)
.await
.context("Zone B did not record the cross-zone payload in time")?
}

View File

@ -0,0 +1,373 @@
#![expect(
clippy::tests_outside_test_module,
reason = "top-level test functions are conventional for integration tests"
)]
//! Single-zone state-machine tests for cross-zone delivery (ping demo) and the
//! wrapped-token bridge (Demo 2). They drive the guests in isolation, no watcher
//! or Bedrock: a hand-built `cross_zone_inbox::Dispatch` (as the watcher would
//! inject) and the source `bridge_lock::Lock` (which escrows and chains
//! `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, InboxConfig, Instruction as InboxInstruction, SeenShard,
inbox_config_account_id, inbox_seen_shard_account_id, message_key,
};
use cross_zone_outbox_core::{OutboxRecord, outbox_pda};
use lee::{
AccountId, PrivateKey, PublicKey, PublicTransaction, V03State, ValidatedStateDiff,
public_transaction::{Message, WitnessSet},
};
use lee_core::account::Account;
use ping_core::{ReceiverInstruction, ping_record_pda};
const INITIAL_BALANCE: u128 = 100;
const LOCK_AMOUNT: u128 = 30;
const RECIPIENT: [u8; 32] = [9; 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_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],
target: lee_core::program::ProgramId,
) {
let inbox_id = programs::cross_zone_inbox().id();
let mut allowed_targets = BTreeMap::new();
allowed_targets.insert(src_zone, vec![target]);
let config = InboxConfig {
self_zone,
allowed_peers: BTreeMap::new(),
allowed_targets,
};
state.insert_genesis_account(
inbox_config_account_id(inbox_id),
Account {
program_owner: inbox_id,
balance: 0,
data: config
.to_bytes()
.try_into()
.expect("config fits in account data"),
nonce: 0_u128.into(),
},
);
}
/// 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) {
let wrapped_token_id = programs::wrapped_token().id();
state.insert_genesis_account(
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()
.try_into()
.expect("minter id fits in account data"),
..Default::default()
},
);
}
/// The wrapped-token `Mint` the bridge forwards, serialized as the cross-zone
/// payload (risc0 words, little-endian bytes).
fn mint_payload() -> Vec<u8> {
let mint = wrapped_token_core::Instruction::Mint {
recipient: RECIPIENT,
amount: LOCK_AMOUNT,
};
let words = risc0_zkvm::serde::to_vec(&mint).expect("serialize mint");
words.iter().flat_map(|word| word.to_le_bytes()).collect()
}
/// 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.
#[test]
fn inbox_dispatch_delivers_payload_to_ping_receiver() {
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 mut state = base_state();
seed_inbox_config(&mut state, self_zone, src_zone, receiver_id);
// The payload is the ping_receiver instruction, serialized as risc0 words in
// little-endian bytes (the contract the inbox reverses when forwarding).
let inner = b"hello-cross-zone".to_vec();
let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record {
payload: inner.clone(),
})
.expect("serialize ping instruction");
let payload: Vec<u8> = words.iter().flat_map(|word| word.to_le_bytes()).collect();
let msg = CrossZoneMessage {
src_zone,
src_block_id,
src_tx_index: 0,
src_program_id: [9_u32; 8],
target_program_id: receiver_id,
payload,
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],
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 record = diff
.public_diff()
.get(&record_id)
.expect("ping record account must change")
.clone();
assert_eq!(
record.data.into_inner(),
inner,
"ping_receiver must record the delivered payload"
);
}
/// Drives `bridge_lock::Lock` and asserts it debits the holder, credits the
/// escrow, and records the forwarded mint in the outbox PDA.
#[test]
fn lock_escrows_balance_and_emits_to_outbox() {
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 mut state = base_state();
let holder_key = PrivateKey::try_new([7; 32]).expect("valid key");
let holder_id = AccountId::from(&PublicKey::new_from_private_key(&holder_key));
state.insert_genesis_account(
holder_id,
Account {
program_owner: bridge_lock_id,
balance: 0,
data: bridge_lock_core::balance_bytes(INITIAL_BALANCE)
.to_vec()
.try_into()
.expect("balance fits in account data"),
nonce: 0_u128.into(),
},
);
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 diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0)
.expect("lock must validate and execute");
let public_diff = diff.public_diff();
let holder_after =
bridge_lock_core::read_balance(&public_diff[&holder_id].data.clone().into_inner());
assert_eq!(
holder_after,
INITIAL_BALANCE - LOCK_AMOUNT,
"holder debited"
);
let escrow_after =
bridge_lock_core::read_balance(&public_diff[&escrow_id].data.clone().into_inner());
assert_eq!(escrow_after, LOCK_AMOUNT, "escrow credited");
let record =
OutboxRecord::from_bytes(&public_diff[&outbox_record_id].data.clone().into_inner())
.expect("outbox PDA holds an OutboxRecord");
assert_eq!(record.target_zone, zone_b);
assert_eq!(record.target_program_id, wrapped_token_id);
assert_eq!(
record.payload, payload,
"emitted payload is the wrapped mint"
);
}
/// 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();
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, src_zone, wrapped_token_id);
seed_wrapped_config(&mut state);
let msg = CrossZoneMessage {
src_zone,
src_block_id,
src_tx_index: 0,
src_program_id: [9_u32; 8],
target_program_id: wrapped_token_id,
payload: mint_payload(),
l1_inclusion_witness: None,
};
let seen_id = inbox_seen_shard_account_id(inbox_id, &src_zone, src_block_id);
let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id);
let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT);
let message = Message::try_new(
inbox_id,
vec![
inbox_config_account_id(inbox_id),
seen_id,
wrapped_config_id,
holding_id,
],
vec![],
InboxInstruction::Dispatch(msg),
)
.expect("build dispatch message");
let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![]));
let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0)
.expect("dispatch must validate and execute");
let minted = wrapped_token_core::read_balance(
&diff.public_diff()[&holding_id].data.clone().into_inner(),
);
assert_eq!(
minted, LOCK_AMOUNT,
"recipient holding minted the locked amount"
);
}
/// A dispatch whose message key is already in the seen-shard is an idempotent
/// no-op: the inbox makes no chained call, so the wrapped token is not minted a
/// second time. This is the bridge's replay defense.
#[test]
fn mint_replay_rejected() {
let inbox_id = programs::cross_zone_inbox().id();
let wrapped_token_id = programs::wrapped_token().id();
let self_zone = [1_u8; 32];
let src_zone = [2_u8; 32];
let src_block_id = 5;
let src_tx_index = 0;
let mut state = base_state();
seed_inbox_config(&mut state, self_zone, src_zone, wrapped_token_id);
seed_wrapped_config(&mut state);
// 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.
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));
state.insert_genesis_account(
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 msg = CrossZoneMessage {
src_zone,
src_block_id,
src_tx_index,
src_program_id: [9_u32; 8],
target_program_id: wrapped_token_id,
payload: mint_payload(),
l1_inclusion_witness: None,
};
let wrapped_config_id = wrapped_token_core::config_account_id(wrapped_token_id);
let holding_id = wrapped_token_core::holding_account_id(wrapped_token_id, &RECIPIENT);
let message = Message::try_new(
inbox_id,
vec![
inbox_config_account_id(inbox_id),
seen_id,
wrapped_config_id,
holding_id,
],
vec![],
InboxInstruction::Dispatch(msg),
)
.expect("build dispatch message");
let tx = PublicTransaction::new(message, WitnessSet::from_raw_parts(vec![]));
let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0)
.expect("a replayed dispatch is a valid no-op, not an error");
let public_diff = diff.public_diff();
// No mint: the holding is never credited on replay.
let minted = public_diff.get(&holding_id).map_or(0, |account| {
wrapped_token_core::read_balance(&account.data.clone().into_inner())
});
assert_eq!(minted, 0, "a replayed message must not mint again");
// The seen-shard is untouched by the no-op.
if let Some(seen) = public_diff.get(&seen_id) {
let shard_after =
SeenShard::from_bytes(&seen.data.clone().into_inner()).expect("seen shard decodes");
assert_eq!(shard_after, shard, "replay must not modify the seen-shard");
}
}

View File

@ -0,0 +1,162 @@
#![expect(
clippy::tests_outside_test_module,
reason = "top-level test functions are conventional for integration tests"
)]
//! Cross-zone round trip with the indexer in the loop (Option B). A ping on zone
//! A is delivered to zone B, and zone B's indexer independently re-derives the
//! injected dispatch from zone A's finalized blocks before applying it. The
//! payload landing in the indexer's state proves verification passed; a forgery
//! would have halted the indexer instead.
use std::{net::SocketAddr, time::Duration};
use anyhow::{Context as _, Result};
use common::transaction::LeeTransaction;
use cross_zone_outbox_core::outbox_pda;
use integration_tests::{
config::{self, SequencerPartialConfig},
indexer_client::IndexerClient,
setup::{setup_bedrock_node, setup_indexer, setup_sequencer},
};
use lee::{AccountId, PublicTransaction, public_transaction::Message};
use lee_core::program::ProgramId;
use ping_core::{ReceiverInstruction, SenderInstruction, ping_record_pda};
use sequencer_core::config::{CrossZoneConfig, CrossZonePeer};
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
use tokio::test;
const DELIVERY_TIMEOUT: Duration = Duration::from_secs(600);
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();
let zone_a: [u8; 32] = *channel_a.as_ref();
let zone_b: [u8; 32] = *channel_b.as_ref();
let receiver_id = programs::ping_receiver().id();
let cross_zone = CrossZoneConfig {
peers: vec![CrossZonePeer {
channel_id: zone_a,
allowed_targets: vec![receiver_id],
expected_block_signing_pubkey: None,
}],
};
// Zone A: source. Zone B: destination, with the watcher on its sequencer and
// the verifier on its indexer.
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None)
.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) = setup_sequencer(
partial,
bedrock_addr,
vec![],
channel_b,
Some(cross_zone.clone()),
)
.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")?;
// 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())?
.send_transaction(ping)
.await
.context("Failed to submit ping on zone A")?;
// 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_url = config::addr_to_url(config::UrlProtocol::Ws, idx_b.addr())
.context("Failed to build indexer URL")?;
let indexer = IndexerClient::new(&indexer_url)
.await
.context("Failed to build indexer client")?;
let delivered = wait_for_indexer_delivery(&indexer, record_id).await?;
assert_eq!(
delivered, PING_PAYLOAD,
"Zone B's indexer must record the verified cross-zone payload"
);
Ok(())
}
fn build_ping_tx(target_zone: [u8; 32], receiver_id: ProgramId) -> LeeTransaction {
let outbox_id = programs::cross_zone_outbox().id();
let ordinal = 0;
let words = risc0_zkvm::serde::to_vec(&ReceiverInstruction::Record {
payload: PING_PAYLOAD.to_vec(),
})
.expect("serialize ping instruction");
let payload: Vec<u8> = 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()],
payload,
ordinal,
};
let outbox_account = outbox_pda(outbox_id, &target_zone, ordinal);
let message = Message::try_new(
programs::ping_sender().id(),
vec![outbox_account],
vec![],
send,
)
.expect("build ping message");
LeeTransaction::Public(PublicTransaction::new(
message,
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
))
}
fn sequencer_client(addr: SocketAddr) -> Result<SequencerClient> {
let url = config::addr_to_url(config::UrlProtocol::Http, addr)
.context("Failed to build sequencer URL")?;
SequencerClientBuilder::default()
.build(url)
.context("Failed to build sequencer client")
}
/// Polls zone B's indexer until the ping record PDA holds a payload.
async fn wait_for_indexer_delivery(
indexer: &IndexerClient,
record_id: AccountId,
) -> Result<Vec<u8>> {
let account_id = indexer_service_protocol::AccountId {
value: record_id.into_value(),
};
let wait = async {
loop {
let account =
indexer_service_rpc::RpcClient::get_account(&**indexer, account_id).await?;
let data = account.data.0;
if !data.is_empty() {
return Ok::<Vec<u8>, anyhow::Error>(data);
}
tokio::time::sleep(Duration::from_secs(3)).await;
}
};
tokio::time::timeout(DELIVERY_TIMEOUT, wait)
.await
.context("Zone B's indexer did not record the payload in time")?
}

View File

@ -50,8 +50,12 @@ pub fn setup_indexer_ffi(bedrock_addr: SocketAddr) -> Result<(IndexerServiceFFI,
temp_indexer_dir.path().display()
);
let indexer_config = integration_tests::config::indexer_config(bedrock_addr)
.context("Failed to create Indexer config")?;
let indexer_config = integration_tests::config::indexer_config(
bedrock_addr,
integration_tests::config::bedrock_channel_id(),
None,
)
.context("Failed to create Indexer config")?;
let config_json = serde_json::to_vec(&indexer_config)?;
let config_path = temp_indexer_dir.path().join("indexer_config.json");

View File

@ -0,0 +1,54 @@
#![expect(
clippy::tests_outside_test_module,
reason = "We don't care about these in tests"
)]
use std::time::Duration;
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);
/// Test that the indexer status RPC reports caught-up with no stall after a clean run.
///
/// The sequencer keeps producing blocks while we assert, so the status is polled until a
/// `CaughtUp` snapshot is observed and the indexed tip is checked as a lower bound.
///
/// TODO: Integration-level park testing (publishing a bad block to force a stall) is a follow-up
/// needing fault injection support in the test harness.
#[tokio::test]
async fn indexer_status_rpc_reports_caught_up_with_no_stall() -> Result<()> {
let ctx = TestContext::new().await?;
let indexer_tip = wait_for_indexer_to_catch_up(&ctx).await?;
let status = tokio::time::timeout(CAUGHT_UP_STATUS_TIMEOUT, async {
loop {
let status = ctx.indexer_client().get_status().await?;
if status.state == IndexerSyncState::CaughtUp {
return anyhow::Ok(status);
}
info!("Waiting for caught-up indexer status, got {status:?}");
tokio::time::sleep(Duration::from_millis(500)).await;
}
})
.await
.context("Timed out waiting for indexer status to report caught-up")??;
assert!(
status.stall_reason.is_none(),
"indexer should have no stall reason after a clean run, got {status:?}"
);
// test for >= here because the sequencer keeps producing blocks while we assert,
// so the indexed tip may be ahead of the tip we observed when we waited for caught-up.
assert!(
status.indexed_block_id >= Some(indexer_tip),
"status indexed_block_id should be at least the caught-up tip {indexer_tip}, got {status:?}"
);
Ok(())
}

View File

@ -1,51 +1,36 @@
#![expect(
clippy::shadow_unrelated,
clippy::tests_outside_test_module,
reason = "We don't care about these in tests"
)]
use std::time::Duration;
use anyhow::{Context as _, Result};
use anyhow::Result;
use indexer_service_rpc::RpcClient as _;
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention,
verify_commitment_is_in_state, wait_for_indexer_to_catch_up,
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance,
assert_private_commitment_in_state, get_account, private_mention, public_mention, send,
wait_for_indexer_to_catch_up,
};
use lee::AccountId;
use log::info;
use wallet::cli::{Command, programs::native_token_transfer::AuthTransferSubcommand};
#[tokio::test]
async fn indexer_state_consistency() -> Result<()> {
let mut ctx = TestContext::new().await?;
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: public_mention(ctx.existing_public_accounts()[0]),
to: Some(public_mention(ctx.existing_public_accounts()[1])),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let (acc0, acc1) = (
ctx.existing_public_accounts()[0],
ctx.existing_public_accounts()[1],
);
send(&mut ctx, public_mention(acc0), public_mention(acc1), 100).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
info!("Checking correct balance move");
let acc_1_balance = sequencer_service_rpc::RpcClient::get_account_balance(
ctx.sequencer_client(),
ctx.existing_public_accounts()[0],
)
.await?;
let acc_2_balance = sequencer_service_rpc::RpcClient::get_account_balance(
ctx.sequencer_client(),
ctx.existing_public_accounts()[1],
)
.await?;
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:#?}");
@ -56,32 +41,13 @@ async fn indexer_state_consistency() -> Result<()> {
let from: AccountId = ctx.existing_private_accounts()[0];
let to: AccountId = ctx.existing_private_accounts()[1];
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: private_mention(from),
to: Some(private_mention(to)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
send(&mut ctx, private_mention(from), private_mention(to), 100).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let new_commitment1 = ctx
.wallet()
.get_private_account_commitment(from)
.context("Failed to get private account commitment for sender")?;
assert!(verify_commitment_is_in_state(new_commitment1, ctx.sequencer_client()).await);
let new_commitment2 = ctx
.wallet()
.get_private_account_commitment(to)
.context("Failed to get private account commitment for receiver")?;
assert!(verify_commitment_is_in_state(new_commitment2, ctx.sequencer_client()).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");
@ -100,16 +66,8 @@ async fn indexer_state_consistency() -> Result<()> {
.unwrap();
info!("Checking correct state transition");
let acc1_seq_state = sequencer_service_rpc::RpcClient::get_account(
ctx.sequencer_client(),
ctx.existing_public_accounts()[0],
)
.await?;
let acc2_seq_state = sequencer_service_rpc::RpcClient::get_account(
ctx.sequencer_client(),
ctx.existing_public_accounts()[1],
)
.await?;
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?;
assert_eq!(acc1_ind_state, acc1_seq_state.into());
assert_eq!(acc2_ind_state, acc2_seq_state.into());

View File

@ -9,12 +9,13 @@ use std::time::Duration;
use anyhow::Result;
use indexer_service_rpc::RpcClient as _;
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, public_mention, wait_for_indexer_to_catch_up,
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, programs::native_token_transfer::AuthTransferSubcommand},
cli::{CliAccountMention, Command},
};
#[tokio::test]
@ -38,31 +39,19 @@ async fn indexer_state_consistency_with_labels() -> Result<()> {
wallet::cli::execute_subcommand(ctx.wallet_mut(), label_cmd).await?;
// Send using labels instead of account IDs
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: CliAccountMention::Label(from_label),
to: Some(CliAccountMention::Label(to_label)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
send(
&mut ctx,
CliAccountMention::Label(from_label),
CliAccountMention::Label(to_label),
100,
)
.await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let acc_1_balance = sequencer_service_rpc::RpcClient::get_account_balance(
ctx.sequencer_client(),
ctx.existing_public_accounts()[0],
)
.await?;
let acc_2_balance = sequencer_service_rpc::RpcClient::get_account_balance(
ctx.sequencer_client(),
ctx.existing_public_accounts()[1],
)
.await?;
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?;
assert_eq!(acc_1_balance, 9900);
assert_eq!(acc_2_balance, 20100);
@ -75,11 +64,7 @@ async fn indexer_state_consistency_with_labels() -> Result<()> {
.get_account(ctx.existing_public_accounts()[0].into())
.await
.unwrap();
let acc1_seq_state = sequencer_service_rpc::RpcClient::get_account(
ctx.sequencer_client(),
ctx.existing_public_accounts()[0],
)
.await?;
let acc1_seq_state = get_account(&ctx, ctx.existing_public_accounts()[0]).await?;
assert_eq!(acc1_ind_state, acc1_seq_state.into());

View File

@ -8,8 +8,9 @@ use std::{str::FromStr as _, time::Duration};
use anyhow::{Context as _, Result};
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, fetch_privacy_preserving_tx, private_mention,
public_mention, verify_commitment_is_in_state,
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, assert_public_account_restored,
fetch_privacy_preserving_tx, new_account, private_mention, public_mention,
restored_private_account, send, verify_commitment_is_in_state,
};
use key_protocol::key_management::key_tree::chain_index::ChainIndex;
use lee::AccountId;
@ -17,8 +18,7 @@ use log::info;
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
use wallet::cli::{
Command, SubcommandReturnValue,
account::{AccountSubcommand, NewSubcommand},
Command, SubcommandReturnValue, account::AccountSubcommand,
programs::native_token_transfer::AuthTransferSubcommand,
};
@ -28,35 +28,12 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> {
let from: AccountId = ctx.existing_private_accounts()[0];
// Create a new private account
let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
}));
// Key Tree shift — create 3 accounts to advance the key index
for _ in 0..3 {
// Key Tree shift
// This way we have account with child index > 0.
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount { account_id: _ } = result else {
anyhow::bail!("Expected RegisterAccount return value");
};
new_account(&mut ctx, true, None).await?;
}
let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::RegisterAccount {
account_id: to_account_id,
} = sub_ret
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let to_account_id = new_account(&mut ctx, true, None).await?;
// Get the keys for the newly created account
let to_account = ctx
@ -80,8 +57,8 @@ async fn sync_private_account_with_non_zero_chain_index() -> Result<()> {
});
let sub_ret = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash } = sub_ret else {
anyhow::bail!("Expected PrivacyPreservingTransfer return value");
let SubcommandReturnValue::TransactionExecuted { tx_hash } = sub_ret else {
anyhow::bail!("Expected TransactionExecuted return value");
};
let tx = fetch_privacy_preserving_tx(ctx.sequencer_client(), tx_hash).await;
@ -118,107 +95,47 @@ async fn restore_keys_from_seed() -> Result<()> {
let from: AccountId = ctx.existing_private_accounts()[0];
// Create first private account at root
let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: Some(ChainIndex::root()),
label: None,
}));
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::RegisterAccount {
account_id: to_account_id1,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
// Create private accounts at root and /0
let to_account_id1 = new_account(&mut ctx, true, Some(ChainIndex::root())).await?;
let to_account_id2 = new_account(&mut ctx, true, Some(ChainIndex::from_str("/0")?)).await?;
// Create second private account at /0
let command = Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: Some(ChainIndex::from_str("/0")?),
label: None,
}));
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::RegisterAccount {
account_id: to_account_id2,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
// Send to first private account
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: private_mention(from),
to: Some(private_mention(to_account_id1)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 100,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
// Send to second private account
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: private_mention(from),
to: Some(private_mention(to_account_id2)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 101,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
// Send to both private accounts
send(
&mut ctx,
private_mention(from),
private_mention(to_account_id1),
100,
)
.await?;
send(
&mut ctx,
private_mention(from),
private_mention(to_account_id2),
101,
)
.await?;
let from: AccountId = ctx.existing_public_accounts()[0];
// Create first public account at root
let command = Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: Some(ChainIndex::root()),
label: None,
}));
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::RegisterAccount {
account_id: to_account_id3,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
// Create public accounts at root and /0
let to_account_id3 = new_account(&mut ctx, false, Some(ChainIndex::root())).await?;
let to_account_id4 = new_account(&mut ctx, false, Some(ChainIndex::from_str("/0")?)).await?;
// Create second public account at /0
let command = Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: Some(ChainIndex::from_str("/0")?),
label: None,
}));
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::RegisterAccount {
account_id: to_account_id4,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
// Send to first public account
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: public_mention(from),
to: Some(public_mention(to_account_id3)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 102,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
// Send to second public account
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: public_mention(from),
to: Some(public_mention(to_account_id4)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 103,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
// Send to both public accounts
send(
&mut ctx,
public_mention(from),
public_mention(to_account_id3),
102,
)
.await?;
send(
&mut ctx,
public_mention(from),
public_mention(to_account_id4),
103,
)
.await?;
info!("Preparation complete, performing keys restoration");
@ -226,34 +143,12 @@ async fn restore_keys_from_seed() -> Result<()> {
wallet::cli::execute_keys_restoration(ctx.wallet_mut(), 10).await?;
// Verify restored private accounts
let acc1 = ctx
.wallet()
.storage()
.key_chain()
.private_account(to_account_id1)
.expect("Acc 1 should be restored");
let acc2 = ctx
.wallet()
.storage()
.key_chain()
.private_account(to_account_id2)
.expect("Acc 2 should be restored");
let acc1 = restored_private_account(&ctx, to_account_id1, "Acc 1");
let acc2 = restored_private_account(&ctx, to_account_id2, "Acc 2");
// Verify restored public accounts
let _acc3 = ctx
.wallet()
.storage()
.key_chain()
.pub_account_signing_key(to_account_id3)
.expect("Acc 3 should be restored");
let _acc4 = ctx
.wallet()
.storage()
.key_chain()
.pub_account_signing_key(to_account_id4)
.expect("Acc 4 should be restored");
assert_public_account_restored(&ctx, to_account_id3, "Acc 3");
assert_public_account_restored(&ctx, to_account_id4, "Acc 4");
assert_eq!(
acc1.account.program_owner,
@ -270,27 +165,20 @@ async fn restore_keys_from_seed() -> Result<()> {
info!("Tree checks passed, testing restored accounts can transact");
// Test that restored accounts can send transactions
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: private_mention(to_account_id1),
to: Some(private_mention(to_account_id2)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 10,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let command = Command::AuthTransfer(AuthTransferSubcommand::Send {
from: public_mention(to_account_id3),
to: Some(public_mention(to_account_id4)),
to_npk: None,
to_vpk: None,
to_keys: None,
to_identifier: Some(0),
amount: 11,
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
send(
&mut ctx,
private_mention(to_account_id1),
private_mention(to_account_id2),
10,
)
.await?;
send(
&mut ctx,
public_mention(to_account_id3),
public_mention(to_account_id4),
11,
)
.await?;
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;

View File

@ -8,15 +8,13 @@ use std::time::Duration;
use anyhow::{Context as _, Result};
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention,
verify_commitment_is_in_state,
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, new_account, private_mention,
public_mention, sync_private, verify_commitment_is_in_state, wait_for_indexer_to_catch_up,
};
use log::info;
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
use wallet::cli::{
Command, SubcommandReturnValue,
account::{AccountSubcommand, NewSubcommand},
programs::{
native_token_transfer::AuthTransferSubcommand, pinata::PinataProgramAgnosticSubcommand,
},
@ -26,25 +24,9 @@ use wallet::cli::{
async fn claim_pinata_to_uninitialized_public_account_fails_fast() -> Result<()> {
let mut ctx = TestContext::new().await?;
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: winner_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let winner_account_id = new_account(&mut ctx, false, None).await?;
let pinata_balance_pre = ctx
.sequencer_client()
.get_account_balance(system_accounts::pinata_account_id())
.await?;
let pinata_balance_pre = account_balance(&ctx, system_accounts::pinata_account_id()).await?;
let claim_result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
@ -64,10 +46,7 @@ async fn claim_pinata_to_uninitialized_public_account_fails_fast() -> Result<()>
"Expected init guidance, got: {err}",
);
let pinata_balance_post = ctx
.sequencer_client()
.get_account_balance(system_accounts::pinata_account_id())
.await?;
let pinata_balance_post = account_balance(&ctx, system_accounts::pinata_account_id()).await?;
assert_eq!(pinata_balance_post, pinata_balance_pre);
@ -78,25 +57,9 @@ async fn claim_pinata_to_uninitialized_public_account_fails_fast() -> Result<()>
async fn claim_pinata_to_uninitialized_private_account_fails_fast() -> Result<()> {
let mut ctx = TestContext::new().await?;
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: winner_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let winner_account_id = new_account(&mut ctx, true, None).await?;
let pinata_balance_pre = ctx
.sequencer_client()
.get_account_balance(system_accounts::pinata_account_id())
.await?;
let pinata_balance_pre = account_balance(&ctx, system_accounts::pinata_account_id()).await?;
let claim_result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
@ -116,10 +79,7 @@ async fn claim_pinata_to_uninitialized_private_account_fails_fast() -> Result<()
"Expected init guidance, got: {err}",
);
let pinata_balance_post = ctx
.sequencer_client()
.get_account_balance(system_accounts::pinata_account_id())
.await?;
let pinata_balance_post = account_balance(&ctx, system_accounts::pinata_account_id()).await?;
assert_eq!(pinata_balance_post, pinata_balance_pre);
@ -135,10 +95,7 @@ async fn claim_pinata_to_existing_public_account() -> Result<()> {
to: public_mention(ctx.existing_public_accounts()[0]),
});
let pinata_balance_pre = ctx
.sequencer_client()
.get_account_balance(system_accounts::pinata_account_id())
.await?;
let pinata_balance_pre = account_balance(&ctx, system_accounts::pinata_account_id()).await?;
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
@ -146,15 +103,9 @@ async fn claim_pinata_to_existing_public_account() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
info!("Checking correct balance move");
let pinata_balance_post = ctx
.sequencer_client()
.get_account_balance(system_accounts::pinata_account_id())
.await?;
let pinata_balance_post = account_balance(&ctx, system_accounts::pinata_account_id()).await?;
let winner_balance_post = ctx
.sequencer_client()
.get_account_balance(ctx.existing_public_accounts()[0])
.await?;
let winner_balance_post = account_balance(&ctx, ctx.existing_public_accounts()[0]).await?;
assert_eq!(pinata_balance_post, pinata_balance_pre - pinata_prize);
assert_eq!(winner_balance_post, 10000 + pinata_prize);
@ -164,6 +115,41 @@ async fn claim_pinata_to_existing_public_account() -> Result<()> {
Ok(())
}
#[test]
async fn claim_pinata_indexer_keeps_up() -> Result<()> {
let mut ctx = TestContext::new().await?;
let command = Command::Pinata(PinataProgramAgnosticSubcommand::Claim {
to: public_mention(ctx.existing_public_accounts()[0]),
});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
info!("Waiting for indexer to parse blocks");
wait_for_indexer_to_catch_up(&ctx).await?;
let winner_ind_state = indexer_service_rpc::RpcClient::get_account(
&**ctx.indexer_client(),
ctx.existing_public_accounts()[0].into(),
)
.await
.unwrap();
let winner_seq_state = sequencer_service_rpc::RpcClient::get_account(
ctx.sequencer_client(),
ctx.existing_public_accounts()[0],
)
.await?;
assert_eq!(winner_ind_state, winner_seq_state.into());
info!("Indexer correctly indexed the pinata claim");
Ok(())
}
#[test]
async fn claim_pinata_to_existing_private_account() -> Result<()> {
let mut ctx = TestContext::new().await?;
@ -173,22 +159,18 @@ async fn claim_pinata_to_existing_private_account() -> Result<()> {
to: private_mention(ctx.existing_private_accounts()[0]),
});
let pinata_balance_pre = ctx
.sequencer_client()
.get_account_balance(system_accounts::pinata_account_id())
.await?;
let pinata_balance_pre = account_balance(&ctx, system_accounts::pinata_account_id()).await?;
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::PrivacyPreservingTransfer { tx_hash: _ } = result else {
anyhow::bail!("Expected PrivacyPreservingTransfer return value");
let SubcommandReturnValue::TransactionExecuted { tx_hash: _ } = result else {
anyhow::bail!("Expected TransactionExecuted return value");
};
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
info!("Syncing private accounts");
let command = Command::Account(AccountSubcommand::SyncPrivate {});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
sync_private(&mut ctx).await?;
let new_commitment = ctx
.wallet()
@ -196,10 +178,7 @@ async fn claim_pinata_to_existing_private_account() -> Result<()> {
.context("Failed to get private account commitment")?;
assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await);
let pinata_balance_post = ctx
.sequencer_client()
.get_account_balance(system_accounts::pinata_account_id())
.await?;
let pinata_balance_post = account_balance(&ctx, system_accounts::pinata_account_id()).await?;
assert_eq!(pinata_balance_post, pinata_balance_pre - pinata_prize);
@ -215,20 +194,7 @@ async fn claim_pinata_to_new_private_account() -> Result<()> {
let pinata_prize = 150;
// Create new private account
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: winner_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let winner_account_id = new_account(&mut ctx, true, None).await?;
// Initialize account under auth transfer program
let command = Command::AuthTransfer(AuthTransferSubcommand::Init {
@ -250,10 +216,7 @@ async fn claim_pinata_to_new_private_account() -> Result<()> {
to: private_mention(winner_account_id),
});
let pinata_balance_pre = ctx
.sequencer_client()
.get_account_balance(system_accounts::pinata_account_id())
.await?;
let pinata_balance_pre = account_balance(&ctx, system_accounts::pinata_account_id()).await?;
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
@ -266,10 +229,7 @@ async fn claim_pinata_to_new_private_account() -> Result<()> {
.context("Failed to get private account commitment")?;
assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await);
let pinata_balance_post = ctx
.sequencer_client()
.get_account_balance(system_accounts::pinata_account_id())
.await?;
let pinata_balance_post = account_balance(&ctx, system_accounts::pinata_account_id()).await?;
assert_eq!(pinata_balance_post, pinata_balance_pre - pinata_prize);

View File

@ -9,9 +9,8 @@ use anyhow::{Context as _, Result};
use authenticated_transfer_core::Instruction as AuthTransferInstruction;
use common::transaction::LeeTransaction;
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, verify_commitment_is_in_state,
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, sync_private, verify_commitment_is_in_state,
};
use key_protocol::key_management::ephemeral_key_holder::EphemeralKeyHolder;
use lee::{
AccountId, PrivacyPreservingTransaction, ProgramId,
privacy_preserving_transaction::{
@ -22,7 +21,7 @@ use lee::{
program::Program,
};
use lee_core::{
EncryptedAccountData, InputAccountIdentity, NullifierPublicKey,
DUMMY_COMMITMENT_HASH, InputAccountIdentity, NullifierPublicKey,
account::{Account, AccountWithMetadata},
encryption::ViewingPublicKey,
program::PdaSeed,
@ -30,10 +29,7 @@ use lee_core::{
use log::info;
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
use wallet::{
AccountIdentity, WalletCore,
cli::{Command, account::AccountSubcommand},
};
use wallet::{AccountIdentity, WalletCore};
/// Funds a private PDA by calling `auth_transfer` directly.
#[expect(
@ -51,7 +47,8 @@ async fn fund_private_pda(
amount: u128,
auth_transfer: &ProgramWithDependencies,
) -> Result<()> {
let pda_account_id = AccountId::for_private_pda(&authority_program_id, &seed, &npk, identifier);
let pda_account_id =
AccountId::for_private_pda(&authority_program_id, &seed, &npk, &vpk, identifier);
let sender_account = wallet
.get_account_public(sender)
.await
@ -63,21 +60,17 @@ async fn fund_private_pda(
let sender_pre = AccountWithMetadata::new(sender_account.clone(), true, sender);
let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_account_id);
let eph_holder = EphemeralKeyHolder::new(&vpk);
let ssk = eph_holder.calculate_shared_secret_sender();
let epk = eph_holder.ephemeral_public_key().clone();
let instruction = Program::serialize_instruction(AuthTransferInstruction::Transfer { amount })
.context("failed to serialize auth_transfer instruction")?;
let account_identities = vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivatePdaInit {
epk,
view_tag: EncryptedAccountData::compute_view_tag(&npk, &vpk),
vpk,
random_seed: [0; 32],
npk,
ssk,
identifier,
commitment_root: DUMMY_COMMITMENT_HASH,
seed: Some((seed, authority_program_id)),
},
];
@ -175,8 +168,8 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> {
let spend_program =
ProgramWithDependencies::new(proxy, [(auth_transfer_id, auth_transfer)].into());
let alice_pda_0_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, 0);
let alice_pda_1_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, 1);
let alice_pda_0_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, &alice_vpk, 0);
let alice_pda_1_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, &alice_vpk, 1);
// Use two different public senders to avoid nonce conflicts between the back-to-back txs.
let senders = ctx.existing_public_accounts();
@ -217,11 +210,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Sync so alice's wallet discovers and stores both PDAs.
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::SyncPrivate {}),
)
.await?;
sync_private(&mut ctx).await?;
// Both PDAs must be discoverable and have the correct balance.
let pda_0_account = ctx
@ -300,11 +289,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> {
info!("Waiting for block");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::SyncPrivate {}),
)
.await?;
sync_private(&mut ctx).await?;
// After spending, PDAs should have the remaining balance.
let pda_0_spent = ctx

View File

@ -7,14 +7,11 @@ use std::{io::Write as _, time::Duration};
use anyhow::Result;
use common::transaction::LeeTransaction;
use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext};
use integration_tests::{TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, get_account, new_account};
use log::info;
use sequencer_service_rpc::RpcClient as _;
use tokio::test;
use wallet::cli::{
Command, SubcommandReturnValue,
account::{AccountSubcommand, NewSubcommand},
};
use wallet::{cli::Command, config::WalletConfigOverrides};
#[test]
async fn deploy_and_execute_program() -> Result<()> {
@ -32,20 +29,7 @@ async fn deploy_and_execute_program() -> Result<()> {
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let SubcommandReturnValue::RegisterAccount { account_id } = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?
else {
panic!("Expected RegisterAccount return value");
};
let account_id = new_account(&mut ctx, false, None).await?;
let nonces = ctx.wallet().get_accounts_nonces(vec![account_id]).await?;
let private_key = ctx
@ -66,7 +50,7 @@ async fn deploy_and_execute_program() -> Result<()> {
// block
tokio::time::sleep(Duration::from_secs(2 * TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let post_state_account = ctx.sequencer_client().get_account(account_id).await?;
let post_state_account = get_account(&ctx, account_id).await?;
let expected_data: &[u8] = &[];
assert_eq!(post_state_account.program_owner, claimer.id());
@ -78,3 +62,37 @@ async fn deploy_and_execute_program() -> Result<()> {
Ok(())
}
#[test]
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()
})
.build()
.await?;
let mut tempfile = tempfile::NamedTempFile::new()?;
tempfile.write_all(b"this is not a valid program binary")?;
let command = Command::DeployProgram {
binary_filepath: tempfile.path().to_owned(),
};
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await;
assert!(
result.is_err(),
"Deploying an invalid program should fail, but got: {result:?}"
);
info!("Deploying an invalid program failed as expected");
Ok(())
}

View File

@ -19,7 +19,7 @@ use std::time::Duration;
use anyhow::{Context as _, Result};
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention,
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention, sync_private,
};
use log::info;
use tokio::test;
@ -197,8 +197,7 @@ async fn fund_shared_account_from_public() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Sync private accounts
let command = Command::Account(AccountSubcommand::SyncPrivate);
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
sync_private(&mut ctx).await?;
// Fund from a public account
let from_public = ctx.existing_public_accounts()[0];
@ -216,8 +215,7 @@ async fn fund_shared_account_from_public() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Sync private accounts
let command = Command::Account(AccountSubcommand::SyncPrivate);
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
sync_private(&mut ctx).await?;
// Verify the shared account was updated
let entry = ctx

View File

@ -8,12 +8,11 @@ use std::time::Duration;
use anyhow::{Context as _, Result};
use integration_tests::{
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, private_mention, public_mention,
verify_commitment_is_in_state,
TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, get_account, new_account, private_mention,
public_mention, sync_private, verify_commitment_is_in_state,
};
use key_protocol::key_management::key_tree::chain_index::ChainIndex;
use log::info;
use sequencer_service_rpc::RpcClient as _;
use token_core::{TokenDefinition, TokenHolding};
use tokio::test;
use wallet::{
@ -30,52 +29,13 @@ async fn create_and_transfer_public_token() -> Result<()> {
let mut ctx = TestContext::new().await?;
// Create new account for the token definition
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: definition_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let definition_account_id = new_account(&mut ctx, false, None).await?;
// Create new account for the token supply holder
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: supply_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let supply_account_id = new_account(&mut ctx, false, None).await?;
// Create new account for receiving a token transaction
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: recipient_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let recipient_account_id = new_account(&mut ctx, false, None).await?;
// Create new token
let name = "A NAME".to_owned();
@ -92,10 +52,7 @@ async fn create_and_transfer_public_token() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Check the status of the token definition account
let definition_acc = ctx
.sequencer_client()
.get_account(definition_account_id)
.await?;
let definition_acc = get_account(&ctx, definition_account_id).await?;
let token_definition = TokenDefinition::try_from(&definition_acc.data)?;
assert_eq!(definition_acc.program_owner, programs::token().id());
@ -109,10 +66,7 @@ async fn create_and_transfer_public_token() -> Result<()> {
);
// Check the status of the token holding account with the total supply
let supply_acc = ctx
.sequencer_client()
.get_account(supply_account_id)
.await?;
let supply_acc = get_account(&ctx, supply_account_id).await?;
// The account must be owned by the token program
assert_eq!(supply_acc.program_owner, programs::token().id());
@ -143,10 +97,7 @@ async fn create_and_transfer_public_token() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Check the status of the supply account after transfer
let supply_acc = ctx
.sequencer_client()
.get_account(supply_account_id)
.await?;
let supply_acc = get_account(&ctx, supply_account_id).await?;
assert_eq!(supply_acc.program_owner, programs::token().id());
let token_holding = TokenHolding::try_from(&supply_acc.data)?;
assert_eq!(
@ -158,10 +109,7 @@ async fn create_and_transfer_public_token() -> Result<()> {
);
// Check the status of the recipient account after transfer
let recipient_acc = ctx
.sequencer_client()
.get_account(recipient_account_id)
.await?;
let recipient_acc = get_account(&ctx, recipient_account_id).await?;
assert_eq!(recipient_acc.program_owner, programs::token().id());
let token_holding = TokenHolding::try_from(&recipient_acc.data)?;
assert_eq!(
@ -186,10 +134,7 @@ async fn create_and_transfer_public_token() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Check the status of the token definition account after burn
let definition_acc = ctx
.sequencer_client()
.get_account(definition_account_id)
.await?;
let definition_acc = get_account(&ctx, definition_account_id).await?;
let token_definition = TokenDefinition::try_from(&definition_acc.data)?;
assert_eq!(
@ -202,10 +147,7 @@ async fn create_and_transfer_public_token() -> Result<()> {
);
// Check the status of the recipient account after burn
let recipient_acc = ctx
.sequencer_client()
.get_account(recipient_account_id)
.await?;
let recipient_acc = get_account(&ctx, recipient_account_id).await?;
let token_holding = TokenHolding::try_from(&recipient_acc.data)?;
assert_eq!(
@ -234,10 +176,7 @@ async fn create_and_transfer_public_token() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Check the status of the token definition account after mint
let definition_acc = ctx
.sequencer_client()
.get_account(definition_account_id)
.await?;
let definition_acc = get_account(&ctx, definition_account_id).await?;
let token_definition = TokenDefinition::try_from(&definition_acc.data)?;
assert_eq!(
@ -250,10 +189,7 @@ async fn create_and_transfer_public_token() -> Result<()> {
);
// Check the status of the recipient account after mint
let recipient_acc = ctx
.sequencer_client()
.get_account(recipient_account_id)
.await?;
let recipient_acc = get_account(&ctx, recipient_account_id).await?;
let token_holding = TokenHolding::try_from(&recipient_acc.data)?;
assert_eq!(
@ -274,52 +210,13 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> {
let mut ctx = TestContext::new().await?;
// Create new account for the token definition (public)
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: definition_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let definition_account_id = new_account(&mut ctx, false, None).await?;
// Create new account for the token supply holder (private)
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: supply_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let supply_account_id = new_account(&mut ctx, true, None).await?;
// Create new account for receiving a token transaction (private)
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: recipient_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let recipient_account_id = new_account(&mut ctx, true, None).await?;
// Create new token
let name = "A NAME".to_owned();
@ -337,10 +234,7 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Check the status of the token definition account
let definition_acc = ctx
.sequencer_client()
.get_account(definition_account_id)
.await?;
let definition_acc = get_account(&ctx, definition_account_id).await?;
let token_definition = TokenDefinition::try_from(&definition_acc.data)?;
assert_eq!(definition_acc.program_owner, programs::token().id());
@ -402,10 +296,7 @@ async fn create_and_transfer_token_with_private_supply() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Check the token definition account after burn
let definition_acc = ctx
.sequencer_client()
.get_account(definition_account_id)
.await?;
let definition_acc = get_account(&ctx, definition_account_id).await?;
let token_definition = TokenDefinition::try_from(&definition_acc.data)?;
assert_eq!(
@ -448,36 +339,10 @@ async fn create_token_with_private_definition() -> Result<()> {
let mut ctx = TestContext::new().await?;
// Create token definition account (private)
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: Some(ChainIndex::root()),
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: definition_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let definition_account_id = new_account(&mut ctx, true, Some(ChainIndex::root())).await?;
// Create supply account (public)
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: Some(ChainIndex::root()),
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: supply_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let supply_account_id = new_account(&mut ctx, false, Some(ChainIndex::root())).await?;
// Create token with private definition
let name = "A NAME".to_owned();
@ -502,10 +367,7 @@ async fn create_token_with_private_definition() -> Result<()> {
assert!(verify_commitment_is_in_state(new_commitment, ctx.sequencer_client()).await);
// Verify supply account
let supply_acc = ctx
.sequencer_client()
.get_account(supply_account_id)
.await?;
let supply_acc = get_account(&ctx, supply_account_id).await?;
assert_eq!(supply_acc.program_owner, programs::token().id());
let token_holding = TokenHolding::try_from(&supply_acc.data)?;
@ -518,36 +380,10 @@ async fn create_token_with_private_definition() -> Result<()> {
);
// Create private recipient account
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: recipient_account_id_private,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let recipient_account_id_private = new_account(&mut ctx, true, None).await?;
// Create public recipient account
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: recipient_account_id_public,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let recipient_account_id_public = new_account(&mut ctx, false, None).await?;
// Mint to public account
let mint_amount_public = 10;
@ -583,10 +419,7 @@ async fn create_token_with_private_definition() -> Result<()> {
);
// Verify public recipient received tokens
let recipient_acc = ctx
.sequencer_client()
.get_account(recipient_account_id_public)
.await?;
let recipient_acc = get_account(&ctx, recipient_account_id_public).await?;
let token_holding = TokenHolding::try_from(&recipient_acc.data)?;
assert_eq!(
@ -646,36 +479,10 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> {
let mut ctx = TestContext::new().await?;
// Create token definition account (private)
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: definition_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let definition_account_id = new_account(&mut ctx, true, None).await?;
// Create supply account (private)
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: supply_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let supply_account_id = new_account(&mut ctx, true, None).await?;
// Create token with both private definition and supply
let name = "A NAME".to_owned();
@ -722,20 +529,7 @@ async fn create_token_with_private_definition_and_supply() -> Result<()> {
);
// Create recipient account
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: recipient_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let recipient_account_id = new_account(&mut ctx, true, None).await?;
// Transfer tokens
let transfer_amount = 7;
@ -804,52 +598,13 @@ async fn shielded_token_transfer() -> Result<()> {
let mut ctx = TestContext::new().await?;
// Create token definition account (public)
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: definition_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let definition_account_id = new_account(&mut ctx, false, None).await?;
// Create supply account (public)
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: supply_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let supply_account_id = new_account(&mut ctx, false, None).await?;
// Create recipient account (private) for shielded transfer
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: recipient_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let recipient_account_id = new_account(&mut ctx, true, None).await?;
// Create token
let name = "A NAME".to_owned();
@ -884,10 +639,7 @@ async fn shielded_token_transfer() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Verify supply account balance
let supply_acc = ctx
.sequencer_client()
.get_account(supply_account_id)
.await?;
let supply_acc = get_account(&ctx, supply_account_id).await?;
let token_holding = TokenHolding::try_from(&supply_acc.data)?;
assert_eq!(
token_holding,
@ -928,52 +680,13 @@ async fn deshielded_token_transfer() -> Result<()> {
let mut ctx = TestContext::new().await?;
// Create token definition account (public)
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: definition_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let definition_account_id = new_account(&mut ctx, false, None).await?;
// Create supply account (private)
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: supply_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let supply_account_id = new_account(&mut ctx, true, None).await?;
// Create recipient account (public) for deshielded transfer
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: recipient_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let recipient_account_id = new_account(&mut ctx, false, None).await?;
// Create token with private supply
let name = "A NAME".to_owned();
@ -1029,10 +742,7 @@ async fn deshielded_token_transfer() -> Result<()> {
);
// Verify recipient balance
let recipient_acc = ctx
.sequencer_client()
.get_account(recipient_account_id)
.await?;
let recipient_acc = get_account(&ctx, recipient_account_id).await?;
let token_holding = TokenHolding::try_from(&recipient_acc.data)?;
assert_eq!(
token_holding,
@ -1052,36 +762,10 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> {
let mut ctx = TestContext::new().await?;
// Create token definition account (private)
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: definition_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let definition_account_id = new_account(&mut ctx, true, None).await?;
// Create supply account (private)
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: supply_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let supply_account_id = new_account(&mut ctx, true, None).await?;
// Create token
let name = "A NAME".to_owned();
@ -1099,20 +783,7 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Create new private account for claiming path
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Private {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: recipient_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let recipient_account_id = new_account(&mut ctx, true, None).await?;
// Get keys for foreign mint (claiming path)
let holder = ctx
@ -1143,8 +814,7 @@ async fn token_claiming_path_with_private_accounts() -> Result<()> {
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Sync to claim the account
let command = Command::Account(AccountSubcommand::SyncPrivate {});
wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
sync_private(&mut ctx).await?;
// Verify commitment exists
let recipient_commitment = ctx
@ -1224,10 +894,7 @@ async fn create_token_using_labels() -> Result<()> {
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let definition_acc = ctx
.sequencer_client()
.get_account(definition_account_id)
.await?;
let definition_acc = get_account(&ctx, definition_account_id).await?;
let token_definition = TokenDefinition::try_from(&definition_acc.data)?;
assert_eq!(definition_acc.program_owner, programs::token().id());
@ -1240,10 +907,7 @@ async fn create_token_using_labels() -> Result<()> {
}
);
let supply_acc = ctx
.sequencer_client()
.get_account(supply_account_id)
.await?;
let supply_acc = get_account(&ctx, supply_account_id).await?;
let token_holding = TokenHolding::try_from(&supply_acc.data)?;
assert_eq!(
token_holding,
@ -1263,20 +927,7 @@ async fn transfer_token_using_from_label() -> Result<()> {
let mut ctx = TestContext::new().await?;
// Create definition account
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: definition_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let definition_account_id = new_account(&mut ctx, false, None).await?;
// Create supply account with a label
let supply_label = Label::new("token-supply-sender");
@ -1296,20 +947,7 @@ async fn transfer_token_using_from_label() -> Result<()> {
};
// Create recipient account
let result = wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::New(NewSubcommand::Public {
cci: None,
label: None,
})),
)
.await?;
let SubcommandReturnValue::RegisterAccount {
account_id: recipient_account_id,
} = result
else {
anyhow::bail!("Expected RegisterAccount return value");
};
let recipient_account_id = new_account(&mut ctx, false, None).await?;
// Create token
let total_supply = 50;
@ -1340,10 +978,7 @@ async fn transfer_token_using_from_label() -> Result<()> {
info!("Waiting for next block creation");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
let recipient_acc = ctx
.sequencer_client()
.get_account(recipient_account_id)
.await?;
let recipient_acc = get_account(&ctx, recipient_account_id).await?;
let token_holding = TokenHolding::try_from(&recipient_acc.data)?;
assert_eq!(
token_holding,

View File

@ -15,7 +15,6 @@ use anyhow::{Context as _, Result};
use bytesize::ByteSize;
use common::transaction::LeeTransaction;
use integration_tests::{TestContext, config::SequencerPartialConfig};
use key_protocol::key_management::ephemeral_key_holder::EphemeralKeyHolder;
use lee::{
Account, AccountId, PrivacyPreservingTransaction, PrivateKey, PublicKey, PublicTransaction,
privacy_preserving_transaction::{self as pptx, circuit},
@ -23,7 +22,7 @@ use lee::{
public_transaction as putx,
};
use lee_core::{
EncryptedAccountData, InputAccountIdentity, MembershipProof, NullifierPublicKey,
DUMMY_COMMITMENT_HASH, InputAccountIdentity, MembershipProof, NullifierPublicKey,
account::{AccountWithMetadata, Nonce, data::Data},
encryption::ViewingPublicKey,
};
@ -266,7 +265,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction {
data: Data::default(),
},
true,
AccountId::for_regular_private_account(&sender_npk, 0),
AccountId::for_regular_private_account(&sender_npk, &sender_vpk, 0),
);
let recipient_nsk = [2; 32];
let recipient_vpk = ViewingPublicKey::from_seed(&[101_u8; 32], &[102_u8; 32]);
@ -274,17 +273,9 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction {
let recipient_pre = AccountWithMetadata::new(
Account::default(),
false,
AccountId::for_regular_private_account(&recipient_npk, 0),
AccountId::for_regular_private_account(&recipient_npk, &recipient_vpk, 0),
);
let eph_holder_from = EphemeralKeyHolder::new(&sender_vpk);
let sender_ss = eph_holder_from.calculate_shared_secret_sender();
let sender_epk = eph_holder_from.ephemeral_public_key().clone();
let eph_holder_to = EphemeralKeyHolder::new(&recipient_vpk);
let recipient_ss = eph_holder_to.calculate_shared_secret_sender();
let recipient_epk = eph_holder_to.ephemeral_public_key().clone();
let balance_to_move: u128 = 1;
let proof: MembershipProof = (
1,
@ -301,19 +292,18 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction {
.unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
epk: sender_epk,
view_tag: EncryptedAccountData::compute_view_tag(&sender_npk, &sender_vpk),
ssk: sender_ss,
vpk: sender_vpk,
random_seed: [0; 32],
nsk: sender_nsk,
membership_proof: proof,
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
epk: recipient_epk,
view_tag: EncryptedAccountData::compute_view_tag(&recipient_npk, &recipient_vpk),
vpk: recipient_vpk,
random_seed: [0; 32],
npk: recipient_npk,
ssk: recipient_ss,
identifier: 0,
commitment_root: DUMMY_COMMITMENT_HASH,
},
],
&program.into(),

View File

@ -0,0 +1,110 @@
#![expect(
clippy::tests_outside_test_module,
reason = "top-level test functions are conventional for integration tests"
)]
//! 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 anyhow::{Context as _, Result};
use indexer_service_rpc::RpcClient as _;
use integration_tests::{
config::{self, SequencerPartialConfig},
indexer_client::IndexerClient,
setup::{setup_bedrock_node, setup_indexer, setup_sequencer},
};
use sequencer_service_rpc::{RpcClient as _, SequencerClientBuilder};
use tokio::test;
const ZONE_LIVE_TIMEOUT: Duration = Duration::from_secs(360);
// Genesis is block 1, so reaching 2 means a block was produced past it.
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();
// Empty genesis is enough: the clock transaction drives block production.
let (seq_a, _seq_a_home) = setup_sequencer(partial, bedrock_addr, vec![], channel_a, None)
.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) = setup_sequencer(partial, bedrock_addr, vec![], channel_b, None)
.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 (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()),
)?;
assert!(
height_a >= MIN_BLOCK_ID,
"Zone A indexer only reached block {height_a}, expected >= {MIN_BLOCK_ID}"
);
assert!(
height_b >= MIN_BLOCK_ID,
"Zone B indexer only reached block {height_b}, expected >= {MIN_BLOCK_ID}"
);
Ok(())
}
/// Wait for the sequencer to produce past genesis and the indexer to finalize up
/// to it. Returns the indexer's finalized block id.
async fn wait_until_zone_live(
label: &str,
sequencer_addr: SocketAddr,
indexer_addr: SocketAddr,
) -> Result<u64> {
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 {
break;
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
let target = sequencer.get_last_block_id().await?;
loop {
let finalized = indexer.get_last_finalized_block_id().await?.unwrap_or(0);
if finalized >= target {
log::info!(
"Zone {label} live: sequencer at {target}, indexer finalized {finalized}"
);
return Ok::<u64, anyhow::Error>(finalized);
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
};
tokio::time::timeout(ZONE_LIVE_TIMEOUT, wait)
.await
.with_context(|| format!("Zone {label} did not become live within {ZONE_LIVE_TIMEOUT:?}"))?
}

View File

@ -30,7 +30,7 @@ async fn public_transfer_and_public_claim() -> Result<()> {
.get_account_balance(recipient_vault_id)
.await?;
let transfer_result = wallet::cli::execute_subcommand(
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Vault(VaultSubcommand::Transfer {
from: public_mention(sender),
@ -39,10 +39,6 @@ async fn public_transfer_and_public_claim() -> Result<()> {
}),
)
.await?;
assert!(
matches!(transfer_result, SubcommandReturnValue::Empty),
"Expected Empty return value for public vault transfer"
);
let sender_balance_after_transfer = ctx.sequencer_client().get_account_balance(sender).await?;
let recipient_balance_after_transfer = ctx
@ -64,7 +60,7 @@ async fn public_transfer_and_public_claim() -> Result<()> {
recipient_vault_balance_before + amount
);
let claim_result = wallet::cli::execute_subcommand(
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Vault(VaultSubcommand::Claim {
account_id: public_mention(recipient),
@ -72,10 +68,6 @@ async fn public_transfer_and_public_claim() -> Result<()> {
}),
)
.await?;
assert!(
matches!(claim_result, SubcommandReturnValue::Empty),
"Expected Empty return value for public vault claim"
);
let sender_balance_after_claim = ctx.sequencer_client().get_account_balance(sender).await?;
let recipient_balance_after_claim = ctx
@ -138,9 +130,9 @@ async fn private_transfer_and_private_claim() -> Result<()> {
assert!(
matches!(
transfer_result,
SubcommandReturnValue::PrivacyPreservingTransfer { .. }
SubcommandReturnValue::TransactionExecuted { .. }
),
"Expected PrivacyPreservingTransfer return value for private vault transfer"
"Expected TransactionExecuted return value for private vault transfer"
);
let sender_balance_after_transfer = ctx
@ -179,9 +171,9 @@ async fn private_transfer_and_private_claim() -> Result<()> {
assert!(
matches!(
claim_result,
SubcommandReturnValue::PrivacyPreservingTransfer { .. }
SubcommandReturnValue::TransactionExecuted { .. }
),
"Expected PrivacyPreservingTransfer return value for private vault claim"
"Expected TransactionExecuted return value for private vault claim"
);
let sender_balance_after_claim = ctx

View File

@ -1012,7 +1012,11 @@ fn test_wallet_ffi_transfer_shielded() -> Result<()> {
let (to, to_keys) = unsafe {
let mut out_keys = FfiPrivateAccountKeys::default();
wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys).unwrap();
let account_id = lee::AccountId::for_regular_private_account(&out_keys.npk(), 0_u128);
let account_id = lee::AccountId::for_regular_private_account(
&out_keys.npk(),
&out_keys.vpk().unwrap(),
0_u128,
);
let to: FfiBytes32 = account_id.into();
(to, out_keys)
};
@ -1155,7 +1159,11 @@ fn test_wallet_ffi_transfer_private() -> Result<()> {
let (to, to_keys) = unsafe {
let mut out_keys = FfiPrivateAccountKeys::default();
wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys).unwrap();
let account_id = lee::AccountId::for_regular_private_account(&out_keys.npk(), 0_u128);
let account_id = lee::AccountId::for_regular_private_account(
&out_keys.npk(),
&out_keys.vpk().unwrap(),
0_u128,
);
let to: FfiBytes32 = account_id.into();
(to, out_keys)
};
@ -1236,7 +1244,11 @@ fn restore_keys_from_seed_ffi() -> Result<()> {
let (private_account_id_1, private_account_1_keys) = unsafe {
let mut out_keys = FfiPrivateAccountKeys::default();
wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys).unwrap();
let account_id = lee::AccountId::for_regular_private_account(&out_keys.npk(), 0_u128);
let account_id = lee::AccountId::for_regular_private_account(
&out_keys.npk(),
&out_keys.vpk().unwrap(),
0_u128,
);
let to: FfiBytes32 = account_id.into();
(to, out_keys)
};
@ -1244,7 +1256,11 @@ fn restore_keys_from_seed_ffi() -> Result<()> {
let (private_account_id_2, private_account_2_keys) = unsafe {
let mut out_keys = FfiPrivateAccountKeys::default();
wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys).unwrap();
let account_id = lee::AccountId::for_regular_private_account(&out_keys.npk(), 0_u128);
let account_id = lee::AccountId::for_regular_private_account(
&out_keys.npk(),
&out_keys.vpk().unwrap(),
0_u128,
);
let to: FfiBytes32 = account_id.into();
(to, out_keys)
};

View File

@ -48,14 +48,3 @@ impl EphemeralKeyHolder {
self.shared_secret
}
}
/// Encapsulates a fresh shared secret toward `vpk` and returns `(shared_secret, ciphertext)`.
///
/// Used when the local side is acting as an "ephemeral receiver" — i.e. generating a
/// one-sided encryption that only the holder of the VSK can decrypt.
#[must_use]
pub fn produce_one_sided_shared_secret_receiver(
vpk: &ViewingPublicKey,
) -> (SharedSecretKey, EphemeralPublicKey) {
SharedSecretKey::encapsulate(vpk)
}

View File

@ -1,7 +1,7 @@
use aes_gcm::{Aes256Gcm, KeyInit as _, aead::Aead as _};
use lee_core::{
SharedSecretKey,
encryption::{EphemeralPublicKey, ViewingPublicKey},
Identifier, SharedSecretKey,
encryption::{EphemeralPublicKey, ML_KEM_768_CIPHERTEXT_LEN, ViewingPublicKey},
program::{PdaSeed, ProgramId},
};
use rand::{RngCore as _, rngs::OsRng};
@ -146,11 +146,28 @@ impl GroupKeyHolder {
SecretSpendingKey(hasher.finalize_fixed().into()).produce_private_key_holder(None)
}
/// Derive keys for a shared regular account from its `identifier`.
///
/// Computes the derivation seed via the `SharedAccountTag` domain separator, then delegates
/// to [`Self::derive_keys_for_shared_account`].
#[must_use]
pub fn derive_regular_shared_account_keys_from_identifier(
&self,
identifier: Identifier,
) -> PrivateKeyHolder {
const PREFIX: &[u8; 32] = b"/LEE/v0.3/SharedAccountTag/\x00\x00\x00\x00\x00";
let mut hasher = sha2::Sha256::new();
hasher.update(PREFIX);
hasher.update(identifier.to_le_bytes());
let derivation_seed: [u8; 32] = hasher.finalize().into();
self.derive_keys_for_shared_account(&derivation_seed)
}
/// Encrypts this holder's GMS under the recipient's [`SealingPublicKey`].
///
/// Uses ML-KEM-768 encapsulation to derive a shared secret, then AES-256-GCM to encrypt
/// the payload. The returned bytes are
/// `kem_ciphertext (1088) || nonce (12) || ciphertext+tag (48)` = 1148 bytes.
/// `kem_ciphertext (ML_KEM_768_CIPHERTEXT_LEN) || nonce (12) || ciphertext+tag (48)`.
///
/// Each call generates a fresh KEM encapsulation, so two seals of the same holder produce
/// different ciphertexts.
@ -170,7 +187,7 @@ impl GroupKeyHolder {
.encrypt(&nonce, self.gms.as_ref())
.expect("AES-GCM encryption should not fail with valid key/nonce");
let capacity = 1088_usize
let capacity = ML_KEM_768_CIPHERTEXT_LEN
.checked_add(12)
.and_then(|n| n.checked_add(ciphertext.len()))
.expect("seal capacity overflow");
@ -186,21 +203,21 @@ impl GroupKeyHolder {
/// Returns `Err` if the ciphertext is too short or the AES-GCM authentication tag
/// doesn't verify (wrong key or tampered data).
pub fn unseal(sealed: &[u8], own_key: &SealingSecretKey) -> Result<Self, SealError> {
// kem_ciphertext (1088) + nonce (12) = header, then AES-GCM tag (16) minimum.
const KEM_CT_LEN: usize = 1088;
const HEADER_LEN: usize = KEM_CT_LEN + 12;
// kem_ciphertext (ML_KEM_768_CIPHERTEXT_LEN) + nonce (12) = header, then AES-GCM tag (16)
// minimum.
const HEADER_LEN: usize = ML_KEM_768_CIPHERTEXT_LEN + 12;
const MIN_LEN: usize = HEADER_LEN + 16;
if sealed.len() < MIN_LEN {
return Err(SealError::TooShort);
}
let kem_ct = EphemeralPublicKey(sealed[..KEM_CT_LEN].to_vec());
let nonce = aes_gcm::Nonce::from_slice(&sealed[KEM_CT_LEN..HEADER_LEN]);
let kem_ct = EphemeralPublicKey(sealed[..ML_KEM_768_CIPHERTEXT_LEN].to_vec());
let nonce = aes_gcm::Nonce::from_slice(&sealed[ML_KEM_768_CIPHERTEXT_LEN..HEADER_LEN]);
let ciphertext = &sealed[HEADER_LEN..];
let shared = SharedSecretKey::decapsulate(&kem_ct, &own_key.d, &own_key.z)
.expect("key_protocol::group_key_holder::GroupKeyHolder::unseal: KEM_CT_LEN guarantees exactly 1088 bytes");
.expect("key_protocol::group_key_holder::GroupKeyHolder::unseal: ML_KEM_768_CIPHERTEXT_LEN guarantees exactly 1088 bytes");
let aes_key = Self::seal_kdf(&shared);
let cipher = Aes256Gcm::new(&aes_key.into());
@ -334,10 +351,10 @@ mod tests {
let program_id: ProgramId = [9; 8];
let holder = GroupKeyHolder::from_gms(gms);
let npk = holder
.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed)
.generate_nullifier_public_key();
let account_id = AccountId::for_private_pda(&program_id, &seed, &npk, u128::MAX);
let keys = holder.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed);
let npk = keys.generate_nullifier_public_key();
let vpk = keys.generate_viewing_public_key();
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,
@ -346,7 +363,7 @@ mod tests {
// 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.
let expected_account_id =
AccountId::for_private_pda(&program_id, &seed, &expected_npk, u128::MAX);
AccountId::for_private_pda(&program_id, &seed, &expected_npk, &vpk, u128::MAX);
assert_eq!(npk, expected_npk);
assert_eq!(account_id, expected_account_id);
@ -543,13 +560,16 @@ mod tests {
let bob_holder =
GroupKeyHolder::unseal(&sealed, &bob_vsk).expect("Bob should unseal the GMS");
let bob_npk = bob_holder
.derive_keys_for_pda(&TEST_PROGRAM_ID, &pda_seed)
.generate_nullifier_public_key();
let bob_group_keys = bob_holder.derive_keys_for_pda(&TEST_PROGRAM_ID, &pda_seed);
let bob_npk = bob_group_keys.generate_nullifier_public_key();
assert_eq!(alice_npk, bob_npk);
let alice_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &alice_npk, 0);
let bob_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &bob_npk, 0);
let alice_vpk = alice_keys.generate_viewing_public_key();
let bob_group_vpk = bob_group_keys.generate_viewing_public_key();
let alice_account_id =
AccountId::for_private_pda(&program_id, &pda_seed, &alice_npk, &alice_vpk, 0);
let bob_account_id =
AccountId::for_private_pda(&program_id, &pda_seed, &bob_npk, &bob_group_vpk, 0);
assert_eq!(alice_account_id, bob_account_id);
}

View File

@ -139,7 +139,7 @@ impl ChainIndex {
.map(|item| Self(item.into_iter().copied().collect()))
}
pub fn chain_ids_at_depth(depth: usize) -> impl Iterator<Item = Self> {
fn collect_chain_ids_at_depth(depth: usize) -> Vec<Self> {
let mut stack = vec![Self(vec![0; depth])];
let mut cumulative_stack = vec![Self(vec![0; depth])];
@ -152,23 +152,18 @@ impl ChainIndex {
}
}
cumulative_stack.into_iter().unique()
cumulative_stack
}
pub fn chain_ids_at_depth(depth: usize) -> impl Iterator<Item = Self> {
Self::collect_chain_ids_at_depth(depth).into_iter().unique()
}
pub fn chain_ids_at_depth_rev(depth: usize) -> impl Iterator<Item = Self> {
let mut stack = vec![Self(vec![0; depth])];
let mut cumulative_stack = vec![Self(vec![0; depth])];
while let Some(top_id) = stack.pop() {
if let Some(collapsed_id) = top_id.collapse_back() {
for id in collapsed_id.shuffle_iter() {
stack.push(id.clone());
cumulative_stack.push(id);
}
}
}
cumulative_stack.into_iter().rev().unique()
Self::collect_chain_ids_at_depth(depth)
.into_iter()
.rev()
.unique()
}
}

View File

@ -6,7 +6,7 @@ use sha2::Digest as _;
use crate::key_management::{
KeyChain,
key_tree::traits::KeyTreeNode,
key_tree::{split_hash, traits::KeyTreeNode},
secret_holders::{PrivateKeyHolder, SecretSpendingKey},
};
@ -23,38 +23,11 @@ impl ChildKeysPrivate {
#[must_use]
pub fn root(seed: [u8; 64]) -> Self {
let hash_value = hmac_sha512::HMAC::mac(seed, b"LEE_master_priv");
let (first, ccc) = split_hash(&hash_value);
let ssk = SecretSpendingKey(
*hash_value
.first_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get first 32"),
);
let ccc = *hash_value
.last_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get last 32");
let ssk = SecretSpendingKey(first);
let nsk = ssk.generate_nullifier_secret_key(None);
let vsk = ssk.generate_viewing_secret_seed_key(None);
let npk = NullifierPublicKey::from(&nsk);
let vpk = ViewingPublicKey::from(&vsk);
Self {
value: (
KeyChain {
secret_spending_key: ssk,
nullifier_public_key: npk,
viewing_public_key: vpk,
private_key_holder: PrivateKeyHolder {
nullifier_secret_key: nsk,
viewing_secret_key: vsk,
},
},
BTreeMap::from_iter([(PrivateAccountKind::Regular(0), lee::Account::default())]),
),
ccc,
cci: None,
}
Self::from_ssk_and_ccc(ssk, ccc, None)
}
#[must_use]
@ -77,18 +50,16 @@ impl ChildKeysPrivate {
input.extend_from_slice(&cci.to_be_bytes());
let hash_value = hmac_sha512::HMAC::mac(input, self.ccc);
let (first, ccc) = split_hash(&hash_value);
let ssk = SecretSpendingKey(
*hash_value
.first_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get first 32"),
);
let ccc = *hash_value
.last_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get last 32");
let ssk = SecretSpendingKey(first);
let nsk = ssk.generate_nullifier_secret_key(Some(cci));
let vsk = ssk.generate_viewing_secret_seed_key(Some(cci));
Self::from_ssk_and_ccc(ssk, ccc, Some(cci))
}
fn from_ssk_and_ccc(ssk: SecretSpendingKey, ccc: [u8; 32], cci: Option<u32>) -> Self {
let nsk = ssk.generate_nullifier_secret_key(cci);
let vsk = ssk.generate_viewing_secret_seed_key(cci);
let npk = NullifierPublicKey::from(&nsk);
let vpk = ViewingPublicKey::from(&vsk);
@ -107,7 +78,7 @@ impl ChildKeysPrivate {
BTreeMap::from_iter([(PrivateAccountKind::Regular(0), lee::Account::default())]),
),
ccc,
cci: Some(cci),
cci,
}
}
}
@ -123,10 +94,11 @@ impl KeyTreeNode for ChildKeysPrivate {
fn account_ids(&self) -> impl Iterator<Item = lee::AccountId> {
let npk = self.value.0.nullifier_public_key;
let vpk = self.value.0.viewing_public_key.clone();
self.value
.1
.keys()
.map(move |kind| lee::AccountId::for_private_account(&npk, kind))
.map(move |kind| lee::AccountId::for_private_account(&npk, &vpk, kind))
}
}
@ -137,16 +109,16 @@ mod tests {
use super::*;
use crate::key_management::{self, secret_holders::ViewingSecretKey};
const SEED: [u8; 64] = [
252, 56, 204, 83, 232, 123, 209, 188, 187, 167, 39, 213, 71, 39, 58, 65, 125, 134, 255, 49,
43, 108, 92, 53, 173, 164, 94, 142, 150, 74, 21, 163, 43, 144, 226, 87, 199, 18, 129, 223,
176, 198, 5, 150, 157, 70, 210, 254, 14, 105, 89, 191, 246, 27, 52, 170, 56, 114, 39, 38,
118, 197, 205, 225,
];
#[test]
fn master_key_generation() {
let seed: [u8; 64] = [
252, 56, 204, 83, 232, 123, 209, 188, 187, 167, 39, 213, 71, 39, 58, 65, 125, 134, 255,
49, 43, 108, 92, 53, 173, 164, 94, 142, 150, 74, 21, 163, 43, 144, 226, 87, 199, 18,
129, 223, 176, 198, 5, 150, 157, 70, 210, 254, 14, 105, 89, 191, 246, 27, 52, 170, 56,
114, 39, 38, 118, 197, 205, 225,
];
let keys = ChildKeysPrivate::root(seed);
let keys = ChildKeysPrivate::root(SEED);
let expected_ssk = key_management::secret_holders::SecretSpendingKey([
246, 79, 26, 124, 135, 95, 52, 51, 201, 27, 48, 194, 2, 144, 51, 219, 245, 128, 139,
@ -179,6 +151,7 @@ mod tests {
],
);
// Length matches MlKem768EncapsulationKey::LEN.
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,
@ -253,14 +226,7 @@ mod tests {
#[test]
fn child_keys_generation() {
let seed: [u8; 64] = [
252, 56, 204, 83, 232, 123, 209, 188, 187, 167, 39, 213, 71, 39, 58, 65, 125, 134, 255,
49, 43, 108, 92, 53, 173, 164, 94, 142, 150, 74, 21, 163, 43, 144, 226, 87, 199, 18,
129, 223, 176, 198, 5, 150, 157, 70, 210, 254, 14, 105, 89, 191, 246, 27, 52, 170, 56,
114, 39, 38, 118, 197, 205, 225,
];
let root_node = ChildKeysPrivate::root(seed);
let root_node = ChildKeysPrivate::root(SEED);
let child_node = ChildKeysPrivate::nth_child(&root_node, 42_u32);
let expected_ssk = key_management::secret_holders::SecretSpendingKey([
@ -293,6 +259,7 @@ mod tests {
],
);
// Length matches MlKem768EncapsulationKey::LEN.
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,

View File

@ -1,7 +1,7 @@
use k256::elliptic_curve::PrimeField as _;
use serde::{Deserialize, Serialize};
use crate::key_management::key_tree::traits::KeyTreeNode;
use crate::key_management::key_tree::{split_hash, traits::KeyTreeNode};
#[derive(Debug, Serialize, Deserialize, Clone)]
#[cfg_attr(any(test, feature = "test_utils"), derive(PartialEq, Eq))]
@ -21,52 +21,32 @@ impl ChildKeysPublic {
#[must_use]
pub fn root(seed: [u8; 64]) -> Self {
let hash_value = hmac_sha512::HMAC::mac(seed, "LEE_master_pub");
let (first, cc) = split_hash(&hash_value);
let sk = lee::PrivateKey::try_new(
*hash_value
.first_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get first 32"),
)
.expect("Expect a valid Private Key");
let ssk = lee::PrivateKey::tweak(sk.value()).expect("`key_protocol::key_management::keys_public::root()`: Invalid private key produced from `tweak`");
let sk = lee::PrivateKey::try_new(first).expect("Expect a valid Private Key");
let cc = *hash_value
.last_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get last 32");
let pk = lee::PublicKey::new_from_private_key(&ssk);
Self {
sk,
ssk,
pk,
cc,
cci: None,
}
Self::from_sk_and_cc(sk, cc, None)
}
#[must_use]
pub fn nth_child(&self, cci: u32) -> Self {
let hash_value = self.compute_hash_value(cci);
let (first, cc) = split_hash(&hash_value);
let lhs = k256::Scalar::from_repr(
(*hash_value
.first_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get first 32"))
.into(),
)
.expect("Expect a valid k256 scalar");
let lhs = k256::Scalar::from_repr(first.into()).expect("Expect a valid k256 scalar");
let rhs =
k256::Scalar::from_repr((*self.sk.value()).into()).expect("Expect a valid k256 scalar");
let sk = lee::PrivateKey::try_new(lhs.add(&rhs).to_bytes().into())
.expect("Expect a valid private key");
let ssk = lee::PrivateKey::tweak(sk.value()).expect("`key_protocol::key_management::keys_public::nth_child()`: Invalid private key produced from `tweak`");
let cc = *hash_value
.last_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get last 32");
Self::from_sk_and_cc(sk, cc, Some(cci))
}
fn from_sk_and_cc(sk: lee::PrivateKey, cc: [u8; 32], cci: Option<u32>) -> Self {
let ssk = lee::PrivateKey::tweak(sk.value()).expect(
"`key_protocol::key_management::keys_public::ChildKeysPublic`: Invalid private key produced from `tweak`",
);
let pk = lee::PublicKey::new_from_private_key(&ssk);
Self {
@ -74,7 +54,7 @@ impl ChildKeysPublic {
ssk,
pk,
cc,
cci: Some(cci),
cci,
}
}
@ -128,15 +108,16 @@ mod tests {
use super::*;
const SEED: [u8; 64] = [
88, 189, 37, 237, 199, 125, 151, 226, 69, 153, 165, 113, 191, 69, 188, 221, 9, 34, 173,
134, 61, 109, 34, 103, 121, 39, 237, 14, 107, 194, 24, 194, 191, 14, 237, 185, 12, 87, 22,
227, 38, 71, 17, 144, 251, 118, 217, 115, 33, 222, 201, 61, 203, 246, 121, 214, 6, 187,
148, 92, 44, 253, 210, 37,
];
#[test]
fn master_keys_generation() {
let seed = [
88, 189, 37, 237, 199, 125, 151, 226, 69, 153, 165, 113, 191, 69, 188, 221, 9, 34, 173,
134, 61, 109, 34, 103, 121, 39, 237, 14, 107, 194, 24, 194, 191, 14, 237, 185, 12, 87,
22, 227, 38, 71, 17, 144, 251, 118, 217, 115, 33, 222, 201, 61, 203, 246, 121, 214, 6,
187, 148, 92, 44, 253, 210, 37,
];
let keys = ChildKeysPublic::root(seed);
let keys = ChildKeysPublic::root(SEED);
let expected_cc = [
238, 94, 84, 154, 56, 224, 80, 218, 133, 249, 179, 222, 9, 24, 17, 252, 120, 127, 222,
@ -169,13 +150,7 @@ mod tests {
#[test]
fn child_keys_generation() {
let seed = [
88, 189, 37, 237, 199, 125, 151, 226, 69, 153, 165, 113, 191, 69, 188, 221, 9, 34, 173,
134, 61, 109, 34, 103, 121, 39, 237, 14, 107, 194, 24, 194, 191, 14, 237, 185, 12, 87,
22, 227, 38, 71, 17, 144, 251, 118, 217, 115, 33, 222, 201, 61, 203, 246, 121, 214, 6,
187, 148, 92, 44, 253, 210, 37,
];
let root_keys = ChildKeysPublic::root(seed);
let root_keys = ChildKeysPublic::root(SEED);
let cci = (2_u32).pow(31) + 13;
let child_keys = ChildKeysPublic::nth_child(&root_keys, cci);

View File

@ -39,16 +39,7 @@ impl<N: KeyTreeNode> KeyTree<N> {
.try_into()
.expect("SeedHolder seed is 64 bytes long");
let root_keys = N::from_seed(seed_fit);
let account_id_map = root_keys
.account_ids()
.map(|id| (id, ChainIndex::root()))
.collect();
Self {
key_map: BTreeMap::from_iter([(ChainIndex::root(), root_keys)]),
account_id_map,
}
Self::new_from_root(N::from_seed(seed_fit))
}
pub fn new_from_root(root: N) -> Self {
@ -63,6 +54,15 @@ impl<N: KeyTreeNode> KeyTree<N> {
}
}
fn insert_child(&mut self, child_keys: N, chain_index: ChainIndex) -> ChainIndex {
for account_id in child_keys.account_ids() {
self.account_id_map.insert(account_id, chain_index.clone());
}
self.key_map.insert(chain_index.clone(), child_keys);
chain_index
}
pub fn generate_new_node(&mut self, parent_cci: &ChainIndex) -> Option<ChainIndex> {
let parent_keys = self.key_map.get(parent_cci)?;
let next_child_id = self
@ -71,14 +71,8 @@ impl<N: KeyTreeNode> KeyTree<N> {
let next_cci = parent_cci.nth_child(next_child_id);
let child_keys = parent_keys.derive_child(next_child_id);
let account_ids = child_keys.account_ids();
for account_id in account_ids {
self.account_id_map.insert(account_id, next_cci.clone());
}
self.key_map.insert(next_cci.clone(), child_keys);
Some(next_cci)
Some(self.insert_child(child_keys, next_cci))
}
pub fn fill_node(&mut self, chain_index: &ChainIndex) -> Option<ChainIndex> {
@ -86,14 +80,8 @@ impl<N: KeyTreeNode> KeyTree<N> {
let child_id = *chain_index.chain().last()?;
let child_keys = parent_keys.derive_child(child_id);
let account_ids = child_keys.account_ids();
for account_id in account_ids {
self.account_id_map.insert(account_id, chain_index.clone());
}
self.key_map.insert(chain_index.clone(), child_keys);
Some(chain_index.clone())
Some(self.insert_child(child_keys, chain_index.clone()))
}
#[must_use]
@ -200,24 +188,27 @@ impl<N: KeyTreeNode> KeyTree<N> {
}
impl KeyTree<ChildKeysPublic> {
/// Pairs `cci` with the account ID of the node stored at it.
fn account_id_for_cci(&self, cci: ChainIndex) -> Option<(lee::AccountId, ChainIndex)> {
let node = self.key_map.get(&cci)?;
let account_id = node.account_ids().next()?;
Some((account_id, cci))
}
/// Generate a new public key node, returning the account ID and chain index.
pub fn generate_new_public_node(
&mut self,
parent_cci: &ChainIndex,
) -> Option<(lee::AccountId, ChainIndex)> {
let cci = self.generate_new_node(parent_cci)?;
let node = self.key_map.get(&cci)?;
let account_id = node.account_ids().next()?;
Some((account_id, cci))
self.account_id_for_cci(cci)
}
/// Generate a new public key node using layered placement, returning the account ID and chain
/// index.
pub fn generate_new_public_node_layered(&mut self) -> Option<(lee::AccountId, ChainIndex)> {
let cci = self.generate_new_node_layered()?;
let node = self.key_map.get(&cci)?;
let account_id = node.account_ids().next()?;
Some((account_id, cci))
self.account_id_for_cci(cci)
}
/// Cleanup of non-initialized accounts in a public tree.
@ -277,6 +268,7 @@ impl KeyTree<ChildKeysPrivate> {
let node = self.key_map.get(cci)?;
let account_id = lee::AccountId::for_regular_private_account(
&node.value.0.nullifier_public_key,
&node.value.0.viewing_public_key,
identifier,
);
if self.account_id_map.contains_key(&account_id) {
@ -322,6 +314,16 @@ impl KeyTree<ChildKeysPrivate> {
}
}
const fn split_hash(hash_value: &[u8; 64]) -> ([u8; 32], [u8; 32]) {
let first = *hash_value
.first_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get first 32");
let last = *hash_value
.last_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get last 32");
(first, last)
}
#[cfg(test)]
mod tests {
#![expect(clippy::shadow_unrelated, reason = "We don't care about this in tests")]
@ -339,6 +341,18 @@ mod tests {
}
}
#[test]
fn split_hash_splits_into_first_and_last_32_bytes() {
let mut hash_value = [0_u8; 64];
hash_value[..32].fill(0xAA);
hash_value[32..].fill(0xBB);
let (first, last) = split_hash(&hash_value);
assert_eq!(first, [0xAA; 32]);
assert_eq!(last, [0xBB; 32]);
}
#[test]
fn simple_key_tree() {
let seed_holder = seed_holder_for_tests();

View File

@ -25,8 +25,22 @@ impl KeyChain {
#[must_use]
pub fn new_os_random() -> Self {
// Currently dropping SeedHolder at the end of initialization.
// Now entirely sure if we need it in the future.
// Not entirely sure if we need it in the future.
let seed_holder = SeedHolder::new_os_random();
Self::from_seed_holder(&seed_holder)
}
#[must_use]
pub fn new_mnemonic(passphrase: &str) -> (Self, bip39::Mnemonic) {
// Currently dropping SeedHolder at the end of initialization.
// Not entirely sure if we need it in the future.
let (seed_holder, mnemonic) = SeedHolder::new_mnemonic(passphrase);
(Self::from_seed_holder(&seed_holder), mnemonic)
}
fn from_seed_holder(seed_holder: &SeedHolder) -> Self {
let secret_spending_key = seed_holder.produce_top_secret_key_holder();
let private_key_holder = secret_spending_key.produce_private_key_holder(None);
@ -42,29 +56,6 @@ impl KeyChain {
}
}
#[must_use]
pub fn new_mnemonic(passphrase: &str) -> (Self, bip39::Mnemonic) {
// Currently dropping SeedHolder at the end of initialization.
// Not entirely sure if we need it in the future.
let (seed_holder, mnemonic) = SeedHolder::new_mnemonic(passphrase);
let secret_spending_key = seed_holder.produce_top_secret_key_holder();
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();
(
Self {
secret_spending_key,
private_key_holder,
nullifier_public_key,
viewing_public_key,
},
mnemonic,
)
}
#[must_use]
pub fn calculate_shared_secret_receiver(
&self,

View File

@ -43,16 +43,7 @@ pub struct PrivateKeyHolder {
impl SeedHolder {
#[must_use]
pub fn new_os_random() -> Self {
let mut enthopy_bytes: [u8; 32] = [0; 32];
OsRng.fill_bytes(&mut enthopy_bytes);
let mnemonic = Mnemonic::from_entropy(&enthopy_bytes)
.expect("Enthropy must be a multiple of 32 bytes");
let seed_wide = mnemonic.to_seed("mnemonic");
Self {
seed: seed_wide.to_vec(),
}
Self::new_mnemonic("mnemonic").0
}
#[must_use]
@ -62,14 +53,8 @@ impl SeedHolder {
let mnemonic =
Mnemonic::from_entropy(&entropy_bytes).expect("Entropy must be a multiple of 32 bytes");
let seed_wide = mnemonic.to_seed(passphrase);
(
Self {
seed: seed_wide.to_vec(),
},
mnemonic,
)
(Self::from_mnemonic(&mnemonic, passphrase), mnemonic)
}
#[must_use]
@ -107,10 +92,7 @@ impl SecretSpendingKey {
const SUFFIX_1: &[u8; 1] = &[1];
const SUFFIX_2: &[u8; 19] = &[0; 19];
let index = match index {
None => 0_u32,
_ => index.expect("Expect a valid u32"),
};
let index = index.unwrap_or(0);
let mut hasher = sha2::Sha256::new();
hasher.update(PREFIX);
@ -129,10 +111,7 @@ impl SecretSpendingKey {
const SUFFIX_1: &[u8; 1] = &[2];
const SUFFIX_2: &[u8; 19] = &[0; 19];
let index = match index {
None => 0_u32,
_ => index.expect("Expect a valid u32"),
};
let index = index.unwrap_or(0);
let mut bytes: Vec<u8> = Vec::with_capacity(64);
bytes.extend_from_slice(PREFIX);
@ -146,14 +125,7 @@ impl SecretSpendingKey {
let full_seed = hmac_sha512::HMAC::mac(bytes, b"LEE_viewing_seed");
ViewingSecretKey::new(
*full_seed
.first_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get first 32"),
*full_seed
.last_chunk::<32>()
.expect("hash_value is 64 bytes, must be safe to get last 32"),
)
Self::generate_viewing_secret_key(full_seed)
}
#[must_use]
@ -181,7 +153,7 @@ impl From<&ViewingSecretKey> for ViewingPublicKey {
seed_bytes[32..].copy_from_slice(&sk.z);
let dk = <MlKem768 as Kem>::DecapsulationKey::from_seed(Seed::from(seed_bytes));
Self::from_bytes(dk.encapsulation_key().to_bytes().to_vec())
.expect("key_protocol::secret_holders::From<&ViewingSecretKey>: ML-KEM-768 encapsulation key is always 1184 bytes")
.expect("key_protocol::secret_holders::From<&ViewingSecretKey>: ML-KEM-768 encapsulation key is always ViewingPublicKey::LEN bytes")
}
}
@ -201,7 +173,6 @@ impl PrivateKeyHolder {
mod tests {
use super::*;
// TODO? are these necessary?
#[test]
fn seed_generation_test() {
let seed_holder = SeedHolder::new_os_random();

View File

@ -6,6 +6,7 @@ use std::{
use lee_core::{
Identifier, InputAccountIdentity, NullifierPublicKey,
account::{Account, AccountId, AccountWithMetadata},
encryption::ViewingPublicKey,
program::{
AccountPostState, BlockValidityWindow, ChainedCall, Claim, DEFAULT_PROGRAM_ID,
MAX_NUMBER_CHAINED_CALLS, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow,
@ -21,7 +22,7 @@ pub struct ExecutionState {
block_validity_window: BlockValidityWindow,
timestamp_validity_window: TimestampValidityWindow,
/// Positions (in `pre_states`) of private-PDA accounts whose supplied npk has been bound to
/// their `AccountId` via a proven `AccountId::for_private_pda(program_id, seed, npk,
/// their `AccountId` via a proven `AccountId::for_private_pda(program_id, seed, npk, vpk,
/// identifier)` check.
/// Two proof paths populate this set: a `Claim::Pda(seed)` in a program's `post_state` on
/// that `pre_state`, or a caller's `ChainedCall.pda_seeds` entry matching that `pre_state`
@ -43,12 +44,13 @@ pub struct ExecutionState {
/// `AccountId` entry or as an equality check against the existing one, making the rule: one
/// `(program, seed)` → one account per tx.
pda_family_binding: HashMap<(ProgramId, PdaSeed), AccountId>,
/// Map from a private-PDA `pre_state`'s position in `account_identities` to the (npk,
/// Map from a private-PDA `pre_state`'s position in `account_identities` to the (npk, vpk,
/// identifier) supplied for that position. Built once in `derive_from_outputs` by walking
/// `account_identities` and consulting `npk_if_private_pda`. Used later by the claim and
/// `account_identities` and consulting `npk_vpk_if_private_pda`. Used later by the claim and
/// caller-seeds authorization paths to verify
/// `AccountId::for_private_pda(program_id, seed, npk, identifier) == pre_state.account_id`.
private_pda_npk_by_position: HashMap<usize, (NullifierPublicKey, Identifier)>,
/// `AccountId::for_private_pda(program_id, seed, npk, vpk, identifier) ==
/// pre_state.account_id`.
private_pda_by_position: HashMap<usize, (NullifierPublicKey, ViewingPublicKey, Identifier)>,
authorized_accounts: HashSet<AccountId>,
}
@ -63,11 +65,13 @@ impl ExecutionState {
// in `account_identities`. The vec is documented as 1:1 with the program's pre_state
// order, so position here matches `pre_state_position` used downstream in
// `validate_and_sync_states`.
let mut private_pda_npk_by_position: HashMap<usize, (NullifierPublicKey, Identifier)> =
HashMap::new();
let mut private_pda_by_position: HashMap<
usize,
(NullifierPublicKey, ViewingPublicKey, Identifier),
> = HashMap::new();
for (pos, account_identity) in account_identities.iter().enumerate() {
if let Some((npk, identifier)) = account_identity.npk_if_private_pda() {
private_pda_npk_by_position.insert(pos, (npk, identifier));
if let Some((npk, vpk, identifier)) = account_identity.npk_vpk_if_private_pda() {
private_pda_by_position.insert(pos, (npk, vpk, identifier));
}
}
@ -107,7 +111,7 @@ impl ExecutionState {
timestamp_validity_window,
private_pda_bound_positions: HashMap::new(),
pda_family_binding: HashMap::new(),
private_pda_npk_by_position,
private_pda_by_position,
authorized_accounts: HashSet::new(),
};
@ -289,7 +293,7 @@ impl ExecutionState {
let is_authorized = resolve_authorization_and_record_bindings(
&mut self.pda_family_binding,
&mut self.private_pda_bound_positions,
&self.private_pda_npk_by_position,
&self.private_pda_by_position,
&mut self.authorized_accounts,
pre_account_id,
pre_state_position,
@ -309,6 +313,7 @@ impl ExecutionState {
let external_seed = match account_identities.get(pre_state_position) {
Some(InputAccountIdentity::PrivatePdaInit {
npk,
vpk,
identifier,
seed: Some((seed, authority_program_id)),
..
@ -317,6 +322,7 @@ impl ExecutionState {
authority_program_id,
seed,
npk,
vpk,
*identifier,
);
assert_eq!(
@ -327,6 +333,7 @@ impl ExecutionState {
}
Some(InputAccountIdentity::PrivatePdaUpdate {
nsk,
vpk,
identifier,
seed: Some((seed, authority_program_id)),
..
@ -336,6 +343,7 @@ impl ExecutionState {
authority_program_id,
seed,
&npk,
vpk,
*identifier,
);
assert_eq!(
@ -416,14 +424,19 @@ impl ExecutionState {
match claim {
Claim::Authorized => {}
Claim::Pda(seed) => {
let (npk, identifier) = self
.private_pda_npk_by_position
let (npk, vpk, identifier) = self
.private_pda_by_position
.get(&pre_state_position)
.expect(
"private PDA pre_state must have an npk in the position map",
);
let pda =
AccountId::for_private_pda(&program_id, &seed, npk, *identifier);
let pda = AccountId::for_private_pda(
&program_id,
&seed,
npk,
vpk,
*identifier,
);
assert_eq!(
pre_account_id, pda,
"Invalid private PDA claim for account {pre_account_id}"
@ -548,7 +561,7 @@ fn bind_private_pda_position(
fn resolve_authorization_and_record_bindings(
pda_family_binding: &mut HashMap<(ProgramId, PdaSeed), AccountId>,
private_pda_bound_positions: &mut HashMap<usize, (ProgramId, PdaSeed)>,
private_pda_npk_by_position: &HashMap<usize, (NullifierPublicKey, Identifier)>,
private_pda_by_position: &HashMap<usize, (NullifierPublicKey, ViewingPublicKey, Identifier)>,
authorized_accounts: &mut HashSet<AccountId>,
pre_account_id: AccountId,
pre_state_position: usize,
@ -562,9 +575,10 @@ fn resolve_authorization_and_record_bindings(
if AccountId::for_public_pda(&caller, seed) == pre_account_id {
return Some((*seed, false, caller));
}
if let Some((npk, identifier)) =
private_pda_npk_by_position.get(&pre_state_position)
&& AccountId::for_private_pda(&caller, seed, npk, *identifier) == pre_account_id
if let Some((npk, vpk, identifier)) =
private_pda_by_position.get(&pre_state_position)
&& AccountId::for_private_pda(&caller, seed, npk, vpk, *identifier)
== pre_account_id
{
return Some((*seed, true, caller));
}

View File

@ -1,9 +1,10 @@
use lee_core::{
Commitment, CommitmentSetDigest, DUMMY_COMMITMENT_HASH, EncryptedAccountData, EncryptionScheme,
EphemeralPublicKey, InputAccountIdentity, MembershipProof, Nullifier, NullifierPublicKey,
NullifierSecretKey, PrivacyPreservingCircuitOutput, PrivateAccountKind, SharedSecretKey,
Commitment, CommitmentSetDigest, EncryptedAccountData, EncryptionScheme, EphemeralSecretKey,
InputAccountIdentity, MembershipProof, Nullifier, NullifierPublicKey, NullifierSecretKey,
PrivacyPreservingCircuitOutput, PrivateAccountKind, SharedSecretKey,
account::{Account, AccountId, Nonce},
compute_digest_for_path,
encryption::ViewingPublicKey,
};
use crate::execution_state::ExecutionState;
@ -40,14 +41,14 @@ pub fn compute_circuit_output(
output.public_post_states.push(post_state);
}
InputAccountIdentity::PrivateAuthorizedInit {
epk,
view_tag,
ssk,
vpk,
random_seed,
nsk,
identifier,
commitment_root,
} => {
let npk = NullifierPublicKey::from(nsk);
let account_id = AccountId::for_regular_private_account(&npk, *identifier);
let account_id = AccountId::for_regular_private_account(&npk, vpk, *identifier);
assert_eq!(account_id, pre_state.account_id, "AccountId mismatch");
assert!(
@ -62,7 +63,7 @@ pub fn compute_circuit_output(
let new_nullifier = (
Nullifier::for_account_initialization(&account_id),
DUMMY_COMMITMENT_HASH,
*commitment_root,
);
let new_nonce = Nonce::private_account_nonce_init(&account_id);
@ -72,23 +73,22 @@ pub fn compute_circuit_output(
post_state,
&account_id,
&PrivateAccountKind::Regular(*identifier),
ssk,
epk,
*view_tag,
&npk,
vpk,
random_seed,
new_nullifier,
new_nonce,
);
}
InputAccountIdentity::PrivateAuthorizedUpdate {
epk,
view_tag,
ssk,
vpk,
random_seed,
nsk,
membership_proof,
identifier,
} => {
let npk = NullifierPublicKey::from(nsk);
let account_id = AccountId::for_regular_private_account(&npk, *identifier);
let account_id = AccountId::for_regular_private_account(&npk, vpk, *identifier);
assert_eq!(account_id, pre_state.account_id, "AccountId mismatch");
assert!(
@ -110,21 +110,21 @@ pub fn compute_circuit_output(
post_state,
&account_id,
&PrivateAccountKind::Regular(*identifier),
ssk,
epk,
*view_tag,
&npk,
vpk,
random_seed,
new_nullifier,
new_nonce,
);
}
InputAccountIdentity::PrivateUnauthorized {
epk,
view_tag,
vpk,
random_seed,
npk,
ssk,
identifier,
commitment_root,
} => {
let account_id = AccountId::for_regular_private_account(npk, *identifier);
let account_id = AccountId::for_regular_private_account(npk, vpk, *identifier);
assert_eq!(account_id, pre_state.account_id, "AccountId mismatch");
assert_eq!(
@ -139,7 +139,7 @@ pub fn compute_circuit_output(
let new_nullifier = (
Nullifier::for_account_initialization(&account_id),
DUMMY_COMMITMENT_HASH,
*commitment_root,
);
let new_nonce = Nonce::private_account_nonce_init(&account_id);
@ -149,25 +149,25 @@ pub fn compute_circuit_output(
post_state,
&account_id,
&PrivateAccountKind::Regular(*identifier),
ssk,
epk,
*view_tag,
npk,
vpk,
random_seed,
new_nullifier,
new_nonce,
);
}
InputAccountIdentity::PrivatePdaInit {
epk,
view_tag,
npk: _,
ssk,
vpk,
random_seed,
npk,
identifier,
commitment_root,
seed: _,
} => {
// The npk-to-account_id binding is established upstream in
// `validate_and_sync_states` via `Claim::Pda(seed)` or a caller `pda_seeds`
// match. Here we only enforce the init pre-conditions. The supplied npk on
// the variant has been recorded into `private_pda_npk_by_position` and used
// the variant has been recorded into `private_pda_by_position` and used
// for the binding check; we use `pre_state.account_id` directly for nullifier
// and commitment derivation.
assert!(
@ -182,7 +182,7 @@ pub fn compute_circuit_output(
let new_nullifier = (
Nullifier::for_account_initialization(&pre_state.account_id),
DUMMY_COMMITMENT_HASH,
*commitment_root,
);
let new_nonce = Nonce::private_account_nonce_init(&pre_state.account_id);
@ -200,17 +200,16 @@ pub fn compute_circuit_output(
seed: *seed,
identifier: *identifier,
},
ssk,
epk,
*view_tag,
npk,
vpk,
random_seed,
new_nullifier,
new_nonce,
);
}
InputAccountIdentity::PrivatePdaUpdate {
epk,
view_tag,
ssk,
vpk,
random_seed,
nsk,
membership_proof,
identifier,
@ -235,6 +234,7 @@ pub fn compute_circuit_output(
let new_nonce = pre_state.account.nonce.private_account_nonce_increment(nsk);
let account_id = pre_state.account_id;
let npk = NullifierPublicKey::from(nsk);
let (authority_program_id, seed) = pda_seed_by_position
.get(&pos)
.expect("PrivatePdaUpdate position must be in pda_seed_by_position");
@ -248,9 +248,9 @@ pub fn compute_circuit_output(
seed: *seed,
identifier: *identifier,
},
ssk,
epk,
*view_tag,
&npk,
vpk,
random_seed,
new_nullifier,
new_nonce,
);
@ -271,9 +271,9 @@ fn emit_private_output(
post_state: Account,
account_id: &AccountId,
kind: &PrivateAccountKind,
shared_secret: &SharedSecretKey,
epk: &EphemeralPublicKey,
view_tag: u8,
npk: &NullifierPublicKey,
vpk: &ViewingPublicKey,
random_seed: &[u8; 32],
new_nullifier: (Nullifier, CommitmentSetDigest),
new_nonce: Nonce,
) {
@ -283,10 +283,23 @@ fn emit_private_output(
post_with_updated_nonce.nonce = new_nonce;
let commitment_post = Commitment::new(account_id, &post_with_updated_nonce);
let esk = EphemeralSecretKey::new(account_id, random_seed, &new_nonce);
let (shared_secret, epk) = SharedSecretKey::encapsulate_deterministic(vpk, &esk);
// Currently the view tag is properlty generated for all accounts.
// To increase privacy, this will be changed in the later version
// to only be generated explicitly for initialized accounts and
// fed by the prover directly for updated accounts.
//
// See issue 573:
// https://github.com/logos-blockchain/logos-execution-zone/issues/573
let view_tag = EncryptedAccountData::compute_view_tag(npk, vpk);
let encrypted_account = EncryptionScheme::encrypt(
&post_with_updated_nonce,
kind,
shared_secret,
&shared_secret,
&commitment_post,
*output_index,
);
@ -296,7 +309,7 @@ fn emit_private_output(
.encrypted_private_post_states
.push(EncryptedAccountData {
ciphertext: encrypted_account,
epk: epk.clone(),
epk,
view_tag,
});
*output_index = output_index

View File

@ -28,10 +28,8 @@ build_utils.workspace = true
[dev-dependencies]
lee_core = { workspace = true, features = ["test_utils"] }
token_core.workspace = true
test_methods = { path = "test_methods" }
env_logger.workspace = true
hex-literal = "1.0.0"
test-case = "3.3.1"

View File

@ -16,7 +16,7 @@ thiserror.workspace = true
bytemuck.workspace = true
bytesize.workspace = true
base58.workspace = true
ml-kem = { workspace = true, optional = true, features = ["getrandom"] }
ml-kem = { workspace = true }
chacha20 = { version = "0.10" }
[dev-dependencies]
@ -24,5 +24,5 @@ serde_json.workspace = true
[features]
default = []
host = ["dep:ml-kem"]
host = ["ml-kem/getrandom"]
test_utils = ["host"]

View File

@ -2,9 +2,9 @@ use serde::{Deserialize, Serialize};
use crate::{
Commitment, CommitmentSetDigest, Identifier, MembershipProof, Nullifier, NullifierPublicKey,
NullifierSecretKey, SharedSecretKey,
NullifierSecretKey,
account::{Account, AccountWithMetadata},
encryption::{EncryptedAccountData, EphemeralPublicKey, ViewTag},
encryption::{EncryptedAccountData, ViewingPublicKey},
program::{BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow},
};
@ -14,15 +14,13 @@ pub struct PrivacyPreservingCircuitInput {
pub program_outputs: Vec<ProgramOutput>,
/// One entry per `pre_state`, in the same order as the program's `pre_states`.
/// Length must equal the number of `pre_states` derived from `program_outputs`.
/// The guest's `private_pda_npk_by_position` and `private_pda_bound_positions`
/// The guest's `private_pda_by_position` and `private_pda_bound_positions`
/// rely on this position alignment.
pub account_identities: Vec<InputAccountIdentity>,
/// Program ID.
pub program_id: ProgramId,
}
/// Per-account input to the privacy-preserving circuit. Each variant carries exactly the fields
/// the guest needs for that account's code path.
#[derive(Serialize, Deserialize, Clone)]
pub enum InputAccountIdentity {
/// Public account. The guest reads pre/post state from `program_outputs` and emits no
@ -30,21 +28,20 @@ pub enum InputAccountIdentity {
Public,
/// Init of an authorized standalone private account: no membership proof. The `pre_state`
/// must be `Account::default()`. The `account_id` is derived as
/// `AccountId::for_regular_private_account(&NullifierPublicKey::from(nsk), identifier)` and
/// matched against `pre_state.account_id`.
/// `AccountId::for_regular_private_account(&NullifierPublicKey::from(nsk), vpk, identifier)`
/// and matched against `pre_state.account_id`.
PrivateAuthorizedInit {
epk: EphemeralPublicKey,
view_tag: ViewTag,
ssk: SharedSecretKey,
vpk: ViewingPublicKey,
random_seed: [u8; 32],
nsk: NullifierSecretKey,
identifier: Identifier,
commitment_root: CommitmentSetDigest,
},
/// Update of an authorized standalone private account: existing on-chain commitment, with
/// membership proof.
PrivateAuthorizedUpdate {
epk: EphemeralPublicKey,
view_tag: ViewTag,
ssk: SharedSecretKey,
vpk: ViewingPublicKey,
random_seed: [u8; 32],
nsk: NullifierSecretKey,
membership_proof: MembershipProof,
identifier: Identifier,
@ -52,25 +49,25 @@ pub enum InputAccountIdentity {
/// Init of a standalone private account the caller does not own (e.g. a recipient who
/// doesn't yet exist on chain). No `nsk`, no membership proof.
PrivateUnauthorized {
epk: EphemeralPublicKey,
view_tag: ViewTag,
vpk: ViewingPublicKey,
random_seed: [u8; 32],
npk: NullifierPublicKey,
ssk: SharedSecretKey,
identifier: Identifier,
commitment_root: CommitmentSetDigest,
},
/// Init of a private PDA, unauthorized. 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.
PrivatePdaInit {
epk: EphemeralPublicKey,
view_tag: ViewTag,
vpk: ViewingPublicKey,
random_seed: [u8; 32],
npk: NullifierPublicKey,
ssk: SharedSecretKey,
identifier: Identifier,
commitment_root: CommitmentSetDigest,
/// When `Some((seed, authority_program_id))`, the circuit binds this position via the
/// external derivation check
/// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) ==
/// `AccountId::for_private_pda(authority_program_id, seed, npk, vpk, identifier) ==
/// pre_state.account_id` rather than requiring a `Claim::Pda` or caller
/// `pda_seeds` to establish the binding. The `pre_state` must have `is_authorized
/// == false`.
@ -80,15 +77,14 @@ pub enum InputAccountIdentity {
/// from `nsk`. Authorization may be established upstream by a caller `pda_seeds` match or a
/// previously-seen authorization in a chained call.
PrivatePdaUpdate {
epk: EphemeralPublicKey,
view_tag: ViewTag,
ssk: SharedSecretKey,
vpk: ViewingPublicKey,
random_seed: [u8; 32],
nsk: NullifierSecretKey,
membership_proof: MembershipProof,
identifier: Identifier,
/// When `Some((seed, authority_program_id))`, the circuit binds this position via the
/// external derivation check
/// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) ==
/// `AccountId::for_private_pda(authority_program_id, seed, npk, vpk, identifier) ==
/// pre_state.account_id` rather than requiring a caller `pda_seeds` to establish
/// the binding. The `pre_state` must have `is_authorized == false`.
seed: Option<(PdaSeed, ProgramId)>,
@ -109,17 +105,23 @@ impl InputAccountIdentity {
)
}
/// For private PDA variants, return the `(npk, identifier)` pair. `Init` carries both
/// directly; `Update` derives `npk` from `nsk`. For non-PDA variants returns `None`.
#[must_use]
pub fn npk_if_private_pda(&self) -> Option<(NullifierPublicKey, Identifier)> {
pub fn npk_vpk_if_private_pda(
&self,
) -> Option<(NullifierPublicKey, ViewingPublicKey, Identifier)> {
match self {
Self::PrivatePdaInit {
npk, identifier, ..
} => Some((*npk, *identifier)),
npk,
vpk,
identifier,
..
} => Some((*npk, vpk.clone(), *identifier)),
Self::PrivatePdaUpdate {
nsk, identifier, ..
} => Some((NullifierPublicKey::from(nsk), *identifier)),
nsk,
vpk,
identifier,
..
} => Some((NullifierPublicKey::from(nsk), vpk.clone(), *identifier)),
Self::Public
| Self::PrivateAuthorizedInit { .. }
| Self::PrivateAuthorizedUpdate { .. }
@ -158,7 +160,7 @@ mod tests {
use crate::{
Commitment, Nullifier,
account::{Account, AccountId, AccountWithMetadata, Nonce},
encryption::Ciphertext,
encryption::{Ciphertext, EphemeralPublicKey},
};
#[test]

View File

@ -7,7 +7,7 @@ use std::io::Read as _;
#[cfg(feature = "host")]
use crate::Nullifier;
#[cfg(feature = "host")]
use crate::encryption::EphemeralPublicKey;
use crate::encryption::{EphemeralPublicKey, ML_KEM_768_CIPHERTEXT_LEN};
#[cfg(feature = "host")]
use crate::error::LeeCoreError;
use crate::{
@ -168,7 +168,7 @@ impl EphemeralPublicKey {
/// Deserializes an ML-KEM-768 ciphertext from a cursor.
/// Reads exactly 1088 bytes — the fixed ciphertext size for ML-KEM-768.
pub fn from_cursor(cursor: &mut Cursor<&[u8]>) -> Result<Self, LeeCoreError> {
let mut value = vec![0_u8; 1088];
let mut value = vec![0_u8; ML_KEM_768_CIPHERTEXT_LEN];
cursor.read_exact(&mut value)?;
Ok(Self(value))
}

View File

@ -5,20 +5,46 @@ use chacha20::{
};
use risc0_zkvm::sha::{Impl, Sha256 as _};
use serde::{Deserialize, Serialize};
#[cfg(feature = "host")]
pub use shared_key_derivation::{MlKem768EncapsulationKey, ViewingPublicKey};
use crate::{Commitment, account::Account, program::PrivateAccountKind};
#[cfg(feature = "host")]
pub mod shared_key_derivation;
/// Length in bytes of an ML-KEM-768 ciphertext (the `EphemeralPublicKey` payload).
pub const ML_KEM_768_CIPHERTEXT_LEN: usize = 1088;
pub type Scalar = [u8; 32];
#[derive(Serialize, Deserialize, Clone, Copy)]
pub struct EphemeralSecretKey(pub [u8; 32]);
impl EphemeralSecretKey {
/// Derives an ephemeral secret key from OS randomness and account-specific values.
///
/// For updates, `nonce` carries `nsk`-derived entropy, making `esk` strong even
/// with a compromised RNG. For inits, `nonce` is deterministic, so `random_seed`
/// is the sole entropy source.
#[must_use]
pub fn new(
account_id: &crate::account::AccountId,
random_seed: &[u8; 32],
nonce: &crate::account::Nonce,
) -> Self {
const PREFIX: &[u8; 14] = b"/LEE/v0.3/esk/";
let mut input = [0_u8; 14 + 32 + 32 + 16];
input[0..14].copy_from_slice(PREFIX);
input[14..46].copy_from_slice(account_id.value());
input[46..78].copy_from_slice(random_seed);
input[78..94].copy_from_slice(&nonce.0.to_le_bytes());
Self(Impl::hash_bytes(&input).as_bytes().try_into().unwrap())
}
}
#[derive(Serialize, Deserialize, Clone, Copy)]
pub struct SharedSecretKey(pub [u8; 32]);
/// The ML-KEM-768 ciphertext produced during encapsulation; transmitted on-wire in place of the
/// former ECDH ephemeral public key. Always 1088 bytes for ML-KEM-768.
/// former ECDH ephemeral public key. Always `ML_KEM_768_CIPHERTEXT_LEN` (1088) bytes.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct EphemeralPublicKey(pub Vec<u8>);
@ -52,6 +78,18 @@ pub struct EncryptedAccountData {
pub view_tag: ViewTag,
}
impl EncryptedAccountData {
#[must_use]
pub fn compute_view_tag(npk: &crate::NullifierPublicKey, vpk: &ViewingPublicKey) -> ViewTag {
const PREFIX: &[u8; 18] = b"/LEE/v0.3/ViewTag/";
let mut bytes = [0_u8; 18 + 32 + ViewingPublicKey::LEN];
bytes[0..18].copy_from_slice(PREFIX);
bytes[18..50].copy_from_slice(&npk.to_byte_array());
bytes[50..].copy_from_slice(vpk.to_bytes());
Impl::hash_bytes(&bytes).as_bytes()[0]
}
}
#[cfg(feature = "host")]
impl EncryptedAccountData {
#[must_use]
@ -68,16 +106,6 @@ impl EncryptedAccountData {
view_tag,
}
}
/// Computes the tag as the first byte of SHA256("/LEE/v0.3/ViewTag/" || npk || vpk).
#[must_use]
pub fn compute_view_tag(npk: &crate::NullifierPublicKey, vpk: &ViewingPublicKey) -> ViewTag {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"/LEE/v0.3/ViewTag/");
bytes.extend_from_slice(&npk.to_byte_array());
bytes.extend_from_slice(vpk.to_bytes());
Impl::hash_bytes(&bytes).as_bytes()[0]
}
}
impl EncryptionScheme {
@ -113,12 +141,12 @@ impl EncryptionScheme {
commitment: &Commitment,
output_index: u32,
) -> [u8; 32] {
let mut bytes = Vec::new();
bytes.extend_from_slice(b"LEE/v0.2/KDF-SHA256/");
bytes.extend_from_slice(&shared_secret.0);
bytes.extend_from_slice(&commitment.to_byte_array());
bytes.extend_from_slice(&output_index.to_le_bytes());
const PREFIX: &[u8; 20] = b"LEE/v0.2/KDF-SHA256/";
let mut bytes = [0_u8; 20 + 32 + 32 + 4];
bytes[0..20].copy_from_slice(PREFIX);
bytes[20..52].copy_from_slice(&shared_secret.0);
bytes[52..84].copy_from_slice(&commitment.to_byte_array());
bytes[84..88].copy_from_slice(&output_index.to_le_bytes());
Impl::hash_bytes(&bytes).as_bytes().try_into().unwrap()
}
@ -235,4 +263,41 @@ mod tests {
"wrong shared secret must not produce the correct plaintext"
);
}
#[test]
fn esk_is_deterministic() {
let account_id = AccountId::new([1_u8; 32]);
let random_seed = [2_u8; 32];
let nonce = crate::account::Nonce(42);
let esk1 = EphemeralSecretKey::new(&account_id, &random_seed, &nonce);
let esk2 = EphemeralSecretKey::new(&account_id, &random_seed, &nonce);
assert_eq!(esk1.0, esk2.0);
}
#[test]
fn esk_differs_for_different_account_id() {
let random_seed = [2_u8; 32];
let nonce = crate::account::Nonce(42);
let esk_a = EphemeralSecretKey::new(&AccountId::new([0_u8; 32]), &random_seed, &nonce);
let esk_b = EphemeralSecretKey::new(&AccountId::new([1_u8; 32]), &random_seed, &nonce);
assert_ne!(esk_a.0, esk_b.0);
}
#[test]
fn esk_differs_for_different_random_seed() {
let account_id = AccountId::new([1_u8; 32]);
let nonce = crate::account::Nonce(42);
let esk_a = EphemeralSecretKey::new(&account_id, &[0_u8; 32], &nonce);
let esk_b = EphemeralSecretKey::new(&account_id, &[1_u8; 32], &nonce);
assert_ne!(esk_a.0, esk_b.0);
}
#[test]
fn esk_differs_for_different_nonce() {
let account_id = AccountId::new([1_u8; 32]);
let random_seed = [2_u8; 32];
let esk_a = EphemeralSecretKey::new(&account_id, &random_seed, &crate::account::Nonce(0));
let esk_b = EphemeralSecretKey::new(&account_id, &random_seed, &crate::account::Nonce(1));
assert_ne!(esk_a.0, esk_b.0);
}
}

View File

@ -1,5 +1,7 @@
use borsh::{BorshDeserialize, BorshSerialize};
use ml_kem::{Decapsulate as _, Encapsulate as _, KeyExport as _, Seed};
#[cfg(feature = "host")]
use ml_kem::Encapsulate as _;
use ml_kem::{Decapsulate as _, KeyExport as _, Seed};
use serde::{Deserialize, Serialize};
use crate::{EphemeralPublicKey, SharedSecretKey};
@ -26,6 +28,7 @@ impl MlKem768EncapsulationKey {
pub const LEN: usize = 1184;
/// Construct from raw bytes, returning an error if the length is not [`Self::LEN`].
#[cfg(feature = "host")]
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self, crate::error::LeeCoreError> {
if bytes.len() != Self::LEN {
return Err(crate::error::LeeCoreError::DeserializationError(format!(
@ -59,12 +62,13 @@ impl SharedSecretKey {
/// Returns `(shared_secret, ciphertext)`. The ciphertext must be included in the transaction
/// as the `EphemeralPublicKey`; the receiver recovers the same shared secret via
/// [`Self::decapsulate`].
#[cfg(feature = "host")]
#[must_use]
pub fn encapsulate(ek: &MlKem768EncapsulationKey) -> (Self, EphemeralPublicKey) {
let ek_bytes: ml_kem::kem::Key<ml_kem::EncapsulationKey768> =
ek.0.as_slice()
.try_into()
.expect("MlKem768EncapsulationKey must be 1184 bytes");
.expect("MlKem768EncapsulationKey must be MlKem768EncapsulationKey::LEN bytes");
let ek_obj = ml_kem::EncapsulationKey768::new(&ek_bytes).expect(
"MlKem768EncapsulationKey bytes must encode a valid ML-KEM-768 encapsulation key",
);
@ -76,35 +80,23 @@ impl SharedSecretKey {
(Self(ss_bytes), EphemeralPublicKey(ct.to_vec()))
}
/// Deterministically encapsulate a shared secret toward `ek` for use in tests.
/// Deterministically encapsulate a shared secret toward `ek` using a
/// pre-derived `esk` as the ML-KEM encapsulation randomness.
///
/// The shared secret has no secret entropy — it is fully determined by `ek`,
/// `message_hash`, and `output_index`, all of which are public. This makes it
/// unsuitable for real encryption but useful for producing stable, reproducible
/// shared secrets in unit tests. Use a distinct `output_index` per output to
/// avoid EPK collisions across multiple outputs in the same test.
///
/// For production use [`Self::encapsulate`], which draws randomness from the OS.
#[cfg(any(test, feature = "test_utils"))]
/// The `esk` must be derived via `derive_esk(account_id, random_seed, nonce)`
/// which binds it to the account and incorporates OS entropy.
#[must_use]
pub fn encapsulate_deterministic(
ek: &MlKem768EncapsulationKey,
message_hash: &[u8; 32],
output_index: u32,
esk: &crate::encryption::EphemeralSecretKey,
) -> (Self, EphemeralPublicKey) {
use risc0_zkvm::sha::{Impl, Sha256 as _};
let mut input = Vec::with_capacity(36);
input.extend_from_slice(message_hash);
input.extend_from_slice(&output_index.to_le_bytes());
let hash = Impl::hash_bytes(&input);
let m: ml_kem::B32 =
ml_kem::array::Array::try_from(hash.as_bytes()).expect("SHA-256 output is 32 bytes");
let m: ml_kem::B32 = ml_kem::array::Array::try_from(esk.0.as_slice())
.expect("EphemeralSecretKey is 32 bytes");
let ek_bytes: ml_kem::kem::Key<ml_kem::EncapsulationKey768> =
ek.0.as_slice()
.try_into()
.expect("MlKem768EncapsulationKey must be 1184 bytes");
.expect("MlKem768EncapsulationKey must be MlKem768EncapsulationKey::LEN bytes");
let ek_obj = ml_kem::EncapsulationKey768::new(&ek_bytes).expect(
"MlKem768EncapsulationKey bytes must encode a valid ML-KEM-768 encapsulation key",
);
@ -118,8 +110,9 @@ impl SharedSecretKey {
/// Receiver: decapsulate the shared secret from a KEM ciphertext.
///
/// Returns `None` if the `EphemeralPublicKey` is not exactly 1088 bytes — callers on
/// the wallet scan path should skip the output rather than panic on malformed chain data.
/// Returns `None` if the `EphemeralPublicKey` is not exactly [`ML_KEM_768_CIPHERTEXT_LEN`]
/// bytes — callers on the wallet scan path should skip the output rather than panic on
/// malformed chain data.
///
/// `d` and `z` are the two 32-byte halves of the FIPS 203 `ViewingSecretKey` seed.
#[must_use]
@ -146,6 +139,7 @@ mod tests {
use ml_kem::KeyExport as _;
use super::*;
use crate::ML_KEM_768_CIPHERTEXT_LEN;
#[test]
fn encapsulate_decapsulate_round_trip() {
@ -164,11 +158,15 @@ mod tests {
let receiver_ss = SharedSecretKey::decapsulate(&epk, &d, &z).unwrap();
assert_eq!(sender_ss.0, receiver_ss.0, "shared secrets must match");
assert_eq!(epk.0.len(), 1088, "ML-KEM-768 ciphertext is 1088 bytes");
assert_eq!(
epk.0.len(),
ML_KEM_768_CIPHERTEXT_LEN,
"ML-KEM-768 ciphertext length"
);
assert_eq!(
ek.0.len(),
1184,
"ML-KEM-768 encapsulation key is 1184 bytes"
MlKem768EncapsulationKey::LEN,
"ML-KEM-768 encapsulation key length"
);
}
@ -177,15 +175,15 @@ mod tests {
let d = [1_u8; 32];
let z = [2_u8; 32];
// Too short — 100 bytes instead of 1088.
// Too short — 100 bytes instead of ML_KEM_768_CIPHERTEXT_LEN.
let short_epk = EphemeralPublicKey(vec![42_u8; 100]);
assert!(
SharedSecretKey::decapsulate(&short_epk, &d, &z).is_none(),
"short EphemeralPublicKey must return None"
);
// Too long — 1089 bytes instead of 1088.
let long_epk = EphemeralPublicKey(vec![42_u8; 1089]);
// Too long — ML_KEM_768_CIPHERTEXT_LEN + 1.
let long_epk = EphemeralPublicKey(vec![42_u8; ML_KEM_768_CIPHERTEXT_LEN + 1]);
assert!(
SharedSecretKey::decapsulate(&long_epk, &d, &z).is_none(),
"long EphemeralPublicKey must return None"

View File

@ -11,7 +11,8 @@ pub use commitment::{
compute_digest_for_path,
};
pub use encryption::{
EncryptedAccountData, EncryptionScheme, EphemeralPublicKey, SharedSecretKey, ViewTag,
EncryptedAccountData, EncryptionScheme, EphemeralPublicKey, EphemeralSecretKey,
ML_KEM_768_CIPHERTEXT_LEN, SharedSecretKey, ViewTag,
};
pub use nullifier::{Identifier, Nullifier, NullifierPublicKey, NullifierSecretKey};
pub use program::PrivateAccountKind;

View File

@ -2,7 +2,7 @@ use borsh::{BorshDeserialize, BorshSerialize};
use risc0_zkvm::sha::{Impl, Sha256 as _};
use serde::{Deserialize, Serialize};
use crate::{Commitment, account::AccountId};
use crate::{Commitment, account::AccountId, encryption::ViewingPublicKey};
const PRIVATE_ACCOUNT_ID_PREFIX: &[u8; 32] = b"/LEE/v0.3/AccountId/Private/\x00\x00\x00\x00";
@ -16,12 +16,16 @@ impl AccountId {
/// Derives an [`AccountId`] for a regular (non-PDA) private account from the nullifier public
/// key and identifier.
#[must_use]
pub fn for_regular_private_account(npk: &NullifierPublicKey, identifier: Identifier) -> Self {
// 32 bytes prefix || 32 bytes npk || 16 bytes identifier
let mut bytes = [0; 80];
pub fn for_regular_private_account(
npk: &NullifierPublicKey,
vpk: &ViewingPublicKey,
identifier: Identifier,
) -> Self {
let mut bytes = [0_u8; 32 + 32 + ViewingPublicKey::LEN + 16];
bytes[0..32].copy_from_slice(PRIVATE_ACCOUNT_ID_PREFIX);
bytes[32..64].copy_from_slice(&npk.0);
bytes[64..80].copy_from_slice(&identifier.to_le_bytes());
bytes[64..64 + ViewingPublicKey::LEN].copy_from_slice(vpk.to_bytes());
bytes[64 + ViewingPublicKey::LEN..].copy_from_slice(&identifier.to_le_bytes());
Self::new(
Impl::hash_bytes(&bytes)
@ -32,9 +36,9 @@ impl AccountId {
}
}
impl From<(&NullifierPublicKey, Identifier)> for AccountId {
fn from((npk, identifier): (&NullifierPublicKey, Identifier)) -> Self {
Self::for_regular_private_account(npk, identifier)
impl From<(&NullifierPublicKey, &ViewingPublicKey, Identifier)> for AccountId {
fn from((npk, vpk, identifier): (&NullifierPublicKey, &ViewingPublicKey, Identifier)) -> Self {
Self::for_regular_private_account(npk, vpk, identifier)
}
}
@ -158,12 +162,13 @@ mod tests {
196, 134, 22, 224, 211, 237, 120, 136, 225, 188, 220, 249, 28,
];
let npk = NullifierPublicKey::from(&nsk);
let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]);
let expected_account_id = AccountId::new([
165, 52, 40, 32, 231, 171, 113, 10, 65, 241, 156, 72, 154, 207, 122, 192, 15, 46, 50,
253, 105, 164, 89, 84, 40, 191, 182, 119, 64, 255, 67, 142,
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,
]);
let account_id = AccountId::for_regular_private_account(&npk, 0);
let account_id = AccountId::for_regular_private_account(&npk, &vpk, 0);
assert_eq!(account_id, expected_account_id);
}
@ -175,12 +180,13 @@ mod tests {
196, 134, 22, 224, 211, 237, 120, 136, 225, 188, 220, 249, 28,
];
let npk = NullifierPublicKey::from(&nsk);
let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]);
let expected_account_id = AccountId::new([
203, 201, 109, 245, 40, 54, 195, 12, 55, 33, 0, 86, 245, 65, 70, 156, 24, 249, 26, 95,
56, 247, 99, 121, 165, 182, 234, 255, 19, 127, 191, 72,
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,
]);
let account_id = AccountId::for_regular_private_account(&npk, 1);
let account_id = AccountId::for_regular_private_account(&npk, &vpk, 1);
assert_eq!(account_id, expected_account_id);
}
@ -193,12 +199,13 @@ mod tests {
196, 134, 22, 224, 211, 237, 120, 136, 225, 188, 220, 249, 28,
];
let npk = NullifierPublicKey::from(&nsk);
let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]);
let expected_account_id = AccountId::new([
178, 16, 226, 206, 217, 38, 38, 45, 155, 240, 226, 253, 168, 87, 146, 70, 72, 32, 174,
19, 245, 25, 214, 162, 209, 135, 252, 82, 27, 2, 174, 196,
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,
]);
let account_id = AccountId::for_regular_private_account(&npk, identifier);
let account_id = AccountId::for_regular_private_account(&npk, &vpk, identifier);
assert_eq!(account_id, expected_account_id);
}

View File

@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
use crate::{
BlockId, Identifier, NullifierPublicKey, Timestamp,
account::{Account, AccountId, AccountWithMetadata},
encryption::ViewingPublicKey,
};
pub const DEFAULT_PROGRAM_ID: ProgramId = [0; 8];
@ -154,19 +155,21 @@ impl AccountId {
program_id: &ProgramId,
seed: &PdaSeed,
npk: &NullifierPublicKey,
vpk: &ViewingPublicKey,
identifier: Identifier,
) -> Self {
use risc0_zkvm::sha::{Impl, Sha256 as _};
const PRIVATE_PDA_PREFIX: &[u8; 32] = b"/LEE/v0.3/AccountId/PrivatePDA/\x00";
let mut bytes = [0_u8; 144];
let mut bytes = [0_u8; 32 + 32 + 32 + 32 + ViewingPublicKey::LEN + 16];
bytes[0..32].copy_from_slice(PRIVATE_PDA_PREFIX);
let program_id_bytes: &[u8] =
bytemuck::try_cast_slice(program_id).expect("ProgramId should be castable to &[u8]");
bytes[32..64].copy_from_slice(program_id_bytes);
bytes[64..96].copy_from_slice(&seed.0);
bytes[96..128].copy_from_slice(&npk.to_byte_array());
bytes[128..144].copy_from_slice(&identifier.to_le_bytes());
bytes[128..128 + ViewingPublicKey::LEN].copy_from_slice(vpk.to_bytes());
bytes[128 + ViewingPublicKey::LEN..].copy_from_slice(&identifier.to_le_bytes());
Self::new(
Impl::hash_bytes(&bytes)
.as_bytes()
@ -177,16 +180,20 @@ impl AccountId {
/// Derives the [`AccountId`] for a private account from the nullifier public key and kind.
#[must_use]
pub fn for_private_account(npk: &NullifierPublicKey, kind: &PrivateAccountKind) -> Self {
pub fn for_private_account(
npk: &NullifierPublicKey,
vpk: &ViewingPublicKey,
kind: &PrivateAccountKind,
) -> Self {
match kind {
PrivateAccountKind::Regular(identifier) => {
Self::for_regular_private_account(npk, *identifier)
Self::for_regular_private_account(npk, vpk, *identifier)
}
PrivateAccountKind::Pda {
program_id,
seed,
identifier,
} => Self::for_private_pda(program_id, seed, npk, *identifier),
} => Self::for_private_pda(program_id, seed, npk, vpk, *identifier),
}
}
}
@ -230,7 +237,7 @@ impl ChainedCall {
/// Represents the final state of an `Account` after a program execution.
///
/// A post state may optionally request that the executing program
/// becomes the owner of the account (a “claim”). This is used to signal
/// becomes the owner of the account (a "claim"). This is used to signal
/// that the program intends to take ownership of the account.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(any(feature = "host", test), derive(PartialEq, Eq))]
@ -317,7 +324,7 @@ impl AccountPostState {
pub type BlockValidityWindow = ValidityWindow<BlockId>;
pub type TimestampValidityWindow = ValidityWindow<Timestamp>;
#[derive(Clone, Copy, Serialize, Deserialize)]
#[derive(Clone, Copy, Default, Serialize, Deserialize)]
#[cfg_attr(
any(feature = "host", test),
derive(Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)
@ -766,338 +773,4 @@ fn validate_uniqueness_of_account_ids(pre_states: &[AccountWithMetadata]) -> boo
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validity_window_unbounded_accepts_any_value() {
let w: ValidityWindow<u64> = ValidityWindow::new_unbounded();
assert!(w.is_valid_for(0));
assert!(w.is_valid_for(u64::MAX));
}
#[test]
fn validity_window_bounded_range_includes_from_excludes_to() {
let w: ValidityWindow<u64> = (Some(5), Some(10)).try_into().unwrap();
assert!(!w.is_valid_for(4));
assert!(w.is_valid_for(5));
assert!(w.is_valid_for(9));
assert!(!w.is_valid_for(10));
}
#[test]
fn validity_window_only_from_bound() {
let w: ValidityWindow<u64> = (Some(5), None).try_into().unwrap();
assert!(!w.is_valid_for(4));
assert!(w.is_valid_for(5));
assert!(w.is_valid_for(u64::MAX));
}
#[test]
fn validity_window_only_to_bound() {
let w: ValidityWindow<u64> = (None, Some(5)).try_into().unwrap();
assert!(w.is_valid_for(0));
assert!(w.is_valid_for(4));
assert!(!w.is_valid_for(5));
}
#[test]
fn validity_window_adjacent_bounds_are_invalid() {
// [5, 5) is an empty range — from == to
assert!(ValidityWindow::<u64>::try_from((Some(5), Some(5))).is_err());
}
#[test]
fn validity_window_inverted_bounds_are_invalid() {
assert!(ValidityWindow::<u64>::try_from((Some(10), Some(5))).is_err());
}
#[test]
fn validity_window_getters_match_construction() {
let w: ValidityWindow<u64> = (Some(3), Some(7)).try_into().unwrap();
assert_eq!(w.start(), Some(3));
assert_eq!(w.end(), Some(7));
}
#[test]
fn validity_window_getters_for_unbounded() {
let w: ValidityWindow<u64> = ValidityWindow::new_unbounded();
assert_eq!(w.start(), None);
assert_eq!(w.end(), None);
}
#[test]
fn validity_window_from_range() {
let w: ValidityWindow<u64> = ValidityWindow::try_from(5_u64..10).unwrap();
assert_eq!(w.start(), Some(5));
assert_eq!(w.end(), Some(10));
}
#[test]
fn validity_window_from_range_empty_is_invalid() {
assert!(ValidityWindow::<u64>::try_from(5_u64..5).is_err());
}
#[test]
fn validity_window_from_range_inverted_is_invalid() {
let from = 10_u64;
let to = 5_u64;
assert!(ValidityWindow::<u64>::try_from(from..to).is_err());
}
#[test]
fn validity_window_from_range_from() {
let w: ValidityWindow<u64> = (5_u64..).into();
assert_eq!(w.start(), Some(5));
assert_eq!(w.end(), None);
}
#[test]
fn validity_window_from_range_to() {
let w: ValidityWindow<u64> = (..10_u64).into();
assert_eq!(w.start(), None);
assert_eq!(w.end(), Some(10));
}
#[test]
fn validity_window_from_range_full() {
let w: ValidityWindow<u64> = (..).into();
assert_eq!(w.start(), None);
assert_eq!(w.end(), None);
}
#[test]
fn program_output_try_with_block_validity_window_range() {
let output = ProgramOutput::new(DEFAULT_PROGRAM_ID, None, vec![], vec![], vec![])
.try_with_block_validity_window(10_u64..100)
.unwrap();
assert_eq!(output.block_validity_window.start(), Some(10));
assert_eq!(output.block_validity_window.end(), Some(100));
}
#[test]
fn program_output_with_block_validity_window_range_from() {
let output = ProgramOutput::new(DEFAULT_PROGRAM_ID, None, vec![], vec![], vec![])
.with_block_validity_window(10_u64..);
assert_eq!(output.block_validity_window.start(), Some(10));
assert_eq!(output.block_validity_window.end(), None);
}
#[test]
fn program_output_with_block_validity_window_range_to() {
let output = ProgramOutput::new(DEFAULT_PROGRAM_ID, None, vec![], vec![], vec![])
.with_block_validity_window(..100_u64);
assert_eq!(output.block_validity_window.start(), None);
assert_eq!(output.block_validity_window.end(), Some(100));
}
#[test]
fn program_output_try_with_block_validity_window_empty_range_fails() {
let result = ProgramOutput::new(DEFAULT_PROGRAM_ID, None, vec![], vec![], vec![])
.try_with_block_validity_window(5_u64..5);
assert!(result.is_err());
}
#[test]
fn post_state_new_with_claim_constructor() {
let account = Account {
program_owner: [1, 2, 3, 4, 5, 6, 7, 8],
balance: 1337,
data: vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(),
nonce: 10_u128.into(),
};
let account_post_state = AccountPostState::new_claimed(account.clone(), Claim::Authorized);
assert_eq!(account, account_post_state.account);
assert_eq!(account_post_state.required_claim(), Some(Claim::Authorized));
}
#[test]
fn post_state_new_without_claim_constructor() {
let account = Account {
program_owner: [1, 2, 3, 4, 5, 6, 7, 8],
balance: 1337,
data: vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(),
nonce: 10_u128.into(),
};
let account_post_state = AccountPostState::new(account.clone());
assert_eq!(account, account_post_state.account);
assert!(account_post_state.required_claim().is_none());
}
#[test]
fn post_state_account_getter() {
let mut account = Account {
program_owner: [1, 2, 3, 4, 5, 6, 7, 8],
balance: 1337,
data: vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(),
nonce: 10_u128.into(),
};
let mut account_post_state = AccountPostState::new(account.clone());
assert_eq!(account_post_state.account(), &account);
assert_eq!(account_post_state.account_mut(), &mut account);
}
// ---- AccountId::for_private_pda tests ----
/// Pins `AccountId::for_private_pda` against a hardcoded expected output for a specific
/// `(program_id, seed, npk, identifier)` tuple. Any change to `PRIVATE_PDA_PREFIX`, byte
/// ordering, or the underlying hash breaks this test.
#[test]
fn for_private_pda_matches_pinned_value() {
let program_id: ProgramId = [1; 8];
let seed = PdaSeed::new([2; 32]);
let npk = NullifierPublicKey([3; 32]);
let identifier: Identifier = u128::MAX;
let expected = AccountId::new([
59, 239, 182, 97, 14, 220, 96, 115, 238, 133, 143, 33, 234, 82, 237, 255, 148, 110, 54,
124, 98, 159, 245, 101, 146, 182, 150, 54, 37, 62, 25, 17,
]);
assert_eq!(
AccountId::for_private_pda(&program_id, &seed, &npk, identifier),
expected
);
}
/// Two groups with different viewing keys at the same (program, seed) get different addresses.
#[test]
fn for_private_pda_differs_for_different_npk() {
let program_id: ProgramId = [1; 8];
let seed = PdaSeed::new([2; 32]);
let npk_a = NullifierPublicKey([3; 32]);
let npk_b = NullifierPublicKey([4; 32]);
assert_ne!(
AccountId::for_private_pda(&program_id, &seed, &npk_a, u128::MAX),
AccountId::for_private_pda(&program_id, &seed, &npk_b, u128::MAX),
);
}
/// Different seeds produce different addresses, even with the same program and npk.
#[test]
fn for_private_pda_differs_for_different_seed() {
let program_id: ProgramId = [1; 8];
let seed_a = PdaSeed::new([2; 32]);
let seed_b = PdaSeed::new([5; 32]);
let npk = NullifierPublicKey([3; 32]);
assert_ne!(
AccountId::for_private_pda(&program_id, &seed_a, &npk, u128::MAX),
AccountId::for_private_pda(&program_id, &seed_b, &npk, u128::MAX),
);
}
/// Different programs produce different addresses, even with the same seed and npk.
#[test]
fn for_private_pda_differs_for_different_program_id() {
let program_id_a: ProgramId = [1; 8];
let program_id_b: ProgramId = [9; 8];
let seed = PdaSeed::new([2; 32]);
let npk = NullifierPublicKey([3; 32]);
assert_ne!(
AccountId::for_private_pda(&program_id_a, &seed, &npk, u128::MAX),
AccountId::for_private_pda(&program_id_b, &seed, &npk, u128::MAX),
);
}
/// Different identifiers produce different addresses for the same `(program_id, seed, npk)`,
/// confirming that each `(program_id, seed, npk)` tuple controls a family of 2^128 addresses.
#[test]
fn for_private_pda_differs_for_different_identifier() {
let program_id: ProgramId = [1; 8];
let seed = PdaSeed::new([2; 32]);
let npk = NullifierPublicKey([3; 32]);
assert_ne!(
AccountId::for_private_pda(&program_id, &seed, &npk, 0),
AccountId::for_private_pda(&program_id, &seed, &npk, 1),
);
assert_ne!(
AccountId::for_private_pda(&program_id, &seed, &npk, 0),
AccountId::for_private_pda(&program_id, &seed, &npk, u128::MAX),
);
}
/// A private PDA at the same (program, seed) has a different address than a public PDA,
/// because the private formula uses a different prefix and includes npk.
#[test]
fn for_private_pda_differs_from_public_pda() {
let program_id: ProgramId = [1; 8];
let seed = PdaSeed::new([2; 32]);
let npk = NullifierPublicKey([3; 32]);
let private_id = AccountId::for_private_pda(&program_id, &seed, &npk, u128::MAX);
let public_id = AccountId::for_public_pda(&program_id, &seed);
assert_ne!(private_id, public_id);
}
#[cfg(feature = "host")]
#[test]
fn private_account_kind_header_round_trips() {
let regular = PrivateAccountKind::Regular(42);
let pda = PrivateAccountKind::Pda {
program_id: [1_u32; 8],
seed: PdaSeed::new([2_u8; 32]),
identifier: u128::MAX,
};
assert_eq!(
PrivateAccountKind::from_header_bytes(&regular.to_header_bytes()),
Some(regular)
);
assert_eq!(
PrivateAccountKind::from_header_bytes(&pda.to_header_bytes()),
Some(pda)
);
}
#[cfg(feature = "host")]
#[test]
fn private_account_kind_unknown_discriminant_returns_none() {
let mut bytes = [0_u8; PrivateAccountKind::HEADER_LEN];
bytes[0] = 0xFF;
assert_eq!(PrivateAccountKind::from_header_bytes(&bytes), None);
}
#[test]
fn for_private_account_dispatches_correctly() {
let program_id: ProgramId = [1; 8];
let seed = PdaSeed::new([2; 32]);
let npk = NullifierPublicKey([3; 32]);
let identifier: Identifier = 77;
assert_eq!(
AccountId::for_private_account(&npk, &PrivateAccountKind::Regular(identifier)),
AccountId::for_regular_private_account(&npk, identifier),
);
assert_eq!(
AccountId::for_private_account(
&npk,
&PrivateAccountKind::Pda {
program_id,
seed,
identifier
}
),
AccountId::for_private_pda(&program_id, &seed, &npk, identifier),
);
}
#[test]
fn compute_public_authorized_pdas_with_seeds() {
let caller: ProgramId = [1; 8];
let seed = PdaSeed::new([2; 32]);
let result = compute_public_authorized_pdas(Some(caller), &[seed]);
let expected = AccountId::for_public_pda(&caller, &seed);
assert!(result.contains(&expected));
assert_eq!(result.len(), 1);
}
/// With no caller (top-level call), the result is always empty.
#[test]
fn compute_public_authorized_pdas_no_caller_returns_empty() {
let seed = PdaSeed::new([2; 32]);
let result = compute_public_authorized_pdas(None, &[seed]);
assert!(result.is_empty());
}
}
mod tests;

View File

@ -0,0 +1,341 @@
use super::*;
#[test]
fn validity_window_unbounded_accepts_any_value() {
let w: ValidityWindow<u64> = ValidityWindow::new_unbounded();
assert!(w.is_valid_for(0));
assert!(w.is_valid_for(u64::MAX));
}
#[test]
fn validity_window_bounded_range_includes_from_excludes_to() {
let w: ValidityWindow<u64> = (Some(5), Some(10)).try_into().unwrap();
assert!(!w.is_valid_for(4));
assert!(w.is_valid_for(5));
assert!(w.is_valid_for(9));
assert!(!w.is_valid_for(10));
}
#[test]
fn validity_window_only_from_bound() {
let w: ValidityWindow<u64> = (Some(5), None).try_into().unwrap();
assert!(!w.is_valid_for(4));
assert!(w.is_valid_for(5));
assert!(w.is_valid_for(u64::MAX));
}
#[test]
fn validity_window_only_to_bound() {
let w: ValidityWindow<u64> = (None, Some(5)).try_into().unwrap();
assert!(w.is_valid_for(0));
assert!(w.is_valid_for(4));
assert!(!w.is_valid_for(5));
}
#[test]
fn validity_window_adjacent_bounds_are_invalid() {
// [5, 5) is an empty range — from == to
assert!(ValidityWindow::<u64>::try_from((Some(5), Some(5))).is_err());
}
#[test]
fn validity_window_inverted_bounds_are_invalid() {
assert!(ValidityWindow::<u64>::try_from((Some(10), Some(5))).is_err());
}
#[test]
fn validity_window_getters_match_construction() {
let w: ValidityWindow<u64> = (Some(3), Some(7)).try_into().unwrap();
assert_eq!(w.start(), Some(3));
assert_eq!(w.end(), Some(7));
}
#[test]
fn validity_window_getters_for_unbounded() {
let w: ValidityWindow<u64> = ValidityWindow::new_unbounded();
assert_eq!(w.start(), None);
assert_eq!(w.end(), None);
}
#[test]
fn validity_window_from_range() {
let w: ValidityWindow<u64> = ValidityWindow::try_from(5_u64..10).unwrap();
assert_eq!(w.start(), Some(5));
assert_eq!(w.end(), Some(10));
}
#[test]
fn validity_window_from_range_empty_is_invalid() {
assert!(ValidityWindow::<u64>::try_from(5_u64..5).is_err());
}
#[test]
fn validity_window_from_range_inverted_is_invalid() {
let from = 10_u64;
let to = 5_u64;
assert!(ValidityWindow::<u64>::try_from(from..to).is_err());
}
#[test]
fn validity_window_from_range_from() {
let w: ValidityWindow<u64> = (5_u64..).into();
assert_eq!(w.start(), Some(5));
assert_eq!(w.end(), None);
}
#[test]
fn validity_window_from_range_to() {
let w: ValidityWindow<u64> = (..10_u64).into();
assert_eq!(w.start(), None);
assert_eq!(w.end(), Some(10));
}
#[test]
fn validity_window_from_range_full() {
let w: ValidityWindow<u64> = (..).into();
assert_eq!(w.start(), None);
assert_eq!(w.end(), None);
}
#[test]
fn program_output_try_with_block_validity_window_range() {
let output = ProgramOutput::new(DEFAULT_PROGRAM_ID, None, vec![], vec![], vec![])
.try_with_block_validity_window(10_u64..100)
.unwrap();
assert_eq!(output.block_validity_window.start(), Some(10));
assert_eq!(output.block_validity_window.end(), Some(100));
}
#[test]
fn program_output_with_block_validity_window_range_from() {
let output = ProgramOutput::new(DEFAULT_PROGRAM_ID, None, vec![], vec![], vec![])
.with_block_validity_window(10_u64..);
assert_eq!(output.block_validity_window.start(), Some(10));
assert_eq!(output.block_validity_window.end(), None);
}
#[test]
fn program_output_with_block_validity_window_range_to() {
let output = ProgramOutput::new(DEFAULT_PROGRAM_ID, None, vec![], vec![], vec![])
.with_block_validity_window(..100_u64);
assert_eq!(output.block_validity_window.start(), None);
assert_eq!(output.block_validity_window.end(), Some(100));
}
#[test]
fn program_output_try_with_block_validity_window_empty_range_fails() {
let result = ProgramOutput::new(DEFAULT_PROGRAM_ID, None, vec![], vec![], vec![])
.try_with_block_validity_window(5_u64..5);
assert!(result.is_err());
}
#[test]
fn post_state_new_with_claim_constructor() {
let account = Account {
program_owner: [1, 2, 3, 4, 5, 6, 7, 8],
balance: 1337,
data: vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(),
nonce: 10_u128.into(),
};
let account_post_state = AccountPostState::new_claimed(account.clone(), Claim::Authorized);
assert_eq!(account, account_post_state.account);
assert_eq!(account_post_state.required_claim(), Some(Claim::Authorized));
}
#[test]
fn post_state_new_without_claim_constructor() {
let account = Account {
program_owner: [1, 2, 3, 4, 5, 6, 7, 8],
balance: 1337,
data: vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(),
nonce: 10_u128.into(),
};
let account_post_state = AccountPostState::new(account.clone());
assert_eq!(account, account_post_state.account);
assert!(account_post_state.required_claim().is_none());
}
#[test]
fn post_state_account_getter() {
let mut account = Account {
program_owner: [1, 2, 3, 4, 5, 6, 7, 8],
balance: 1337,
data: vec![0xde, 0xad, 0xbe, 0xef].try_into().unwrap(),
nonce: 10_u128.into(),
};
let mut account_post_state = AccountPostState::new(account.clone());
assert_eq!(account_post_state.account(), &account);
assert_eq!(account_post_state.account_mut(), &mut account);
}
// ---- AccountId::for_private_pda tests ----
/// Pins `AccountId::for_private_pda` against a hardcoded expected output for a specific
/// `(program_id, seed, npk, identifier)` tuple. Any change to `PRIVATE_PDA_PREFIX`, byte
/// ordering, or the underlying hash breaks this test.
#[test]
fn for_private_pda_matches_pinned_value() {
let program_id: ProgramId = [1; 8];
let seed = PdaSeed::new([2; 32]);
let npk = NullifierPublicKey([3; 32]);
let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]);
let identifier: Identifier = u128::MAX;
let expected = AccountId::new([
5, 87, 128, 244, 206, 244, 65, 130, 178, 88, 225, 183, 0, 159, 201, 201, 212, 206, 6, 156,
13, 55, 32, 139, 91, 222, 209, 83, 172, 148, 123, 179,
]);
assert_eq!(
AccountId::for_private_pda(&program_id, &seed, &npk, &vpk, identifier),
expected
);
}
/// Two groups with different viewing keys at the same (program, seed) get different addresses.
#[test]
fn for_private_pda_differs_for_different_npk() {
let program_id: ProgramId = [1; 8];
let seed = PdaSeed::new([2; 32]);
let npk_a = NullifierPublicKey([3; 32]);
let npk_b = NullifierPublicKey([4; 32]);
let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]);
assert_ne!(
AccountId::for_private_pda(&program_id, &seed, &npk_a, &vpk, u128::MAX),
AccountId::for_private_pda(&program_id, &seed, &npk_b, &vpk, u128::MAX),
);
}
/// Different seeds produce different addresses, even with the same program and npk.
#[test]
fn for_private_pda_differs_for_different_seed() {
let program_id: ProgramId = [1; 8];
let seed_a = PdaSeed::new([2; 32]);
let seed_b = PdaSeed::new([5; 32]);
let npk = NullifierPublicKey([3; 32]);
let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]);
assert_ne!(
AccountId::for_private_pda(&program_id, &seed_a, &npk, &vpk, u128::MAX),
AccountId::for_private_pda(&program_id, &seed_b, &npk, &vpk, u128::MAX),
);
}
/// Different programs produce different addresses, even with the same seed and npk.
#[test]
fn for_private_pda_differs_for_different_program_id() {
let program_id_a: ProgramId = [1; 8];
let program_id_b: ProgramId = [9; 8];
let seed = PdaSeed::new([2; 32]);
let npk = NullifierPublicKey([3; 32]);
let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]);
assert_ne!(
AccountId::for_private_pda(&program_id_a, &seed, &npk, &vpk, u128::MAX),
AccountId::for_private_pda(&program_id_b, &seed, &npk, &vpk, u128::MAX),
);
}
/// Different identifiers produce different addresses for the same `(program_id, seed, npk)`,
/// confirming that each `(program_id, seed, npk)` tuple controls a family of 2^128 addresses.
#[test]
fn for_private_pda_differs_for_different_identifier() {
let program_id: ProgramId = [1; 8];
let seed = PdaSeed::new([2; 32]);
let npk = NullifierPublicKey([3; 32]);
let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]);
assert_ne!(
AccountId::for_private_pda(&program_id, &seed, &npk, &vpk, 0),
AccountId::for_private_pda(&program_id, &seed, &npk, &vpk, 1),
);
assert_ne!(
AccountId::for_private_pda(&program_id, &seed, &npk, &vpk, 0),
AccountId::for_private_pda(&program_id, &seed, &npk, &vpk, u128::MAX),
);
}
/// A private PDA at the same (program, seed) has a different address than a public PDA,
/// because the private formula uses a different prefix and includes npk.
#[test]
fn for_private_pda_differs_from_public_pda() {
let program_id: ProgramId = [1; 8];
let seed = PdaSeed::new([2; 32]);
let npk = NullifierPublicKey([3; 32]);
let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]);
let private_id = AccountId::for_private_pda(&program_id, &seed, &npk, &vpk, u128::MAX);
let public_id = AccountId::for_public_pda(&program_id, &seed);
assert_ne!(private_id, public_id);
}
#[cfg(feature = "host")]
#[test]
fn private_account_kind_header_round_trips() {
let regular = PrivateAccountKind::Regular(42);
let pda = PrivateAccountKind::Pda {
program_id: [1_u32; 8],
seed: PdaSeed::new([2_u8; 32]),
identifier: u128::MAX,
};
assert_eq!(
PrivateAccountKind::from_header_bytes(&regular.to_header_bytes()),
Some(regular)
);
assert_eq!(
PrivateAccountKind::from_header_bytes(&pda.to_header_bytes()),
Some(pda)
);
}
#[cfg(feature = "host")]
#[test]
fn private_account_kind_unknown_discriminant_returns_none() {
let mut bytes = [0_u8; PrivateAccountKind::HEADER_LEN];
bytes[0] = 0xFF;
assert_eq!(PrivateAccountKind::from_header_bytes(&bytes), None);
}
#[test]
fn for_private_account_dispatches_correctly() {
let program_id: ProgramId = [1; 8];
let seed = PdaSeed::new([2; 32]);
let npk = NullifierPublicKey([3; 32]);
let vpk = ViewingPublicKey::from_seed(&[1_u8; 32], &[2_u8; 32]);
let identifier: Identifier = 77;
assert_eq!(
AccountId::for_private_account(&npk, &vpk, &PrivateAccountKind::Regular(identifier)),
AccountId::for_regular_private_account(&npk, &vpk, identifier),
);
assert_eq!(
AccountId::for_private_account(
&npk,
&vpk,
&PrivateAccountKind::Pda {
program_id,
seed,
identifier
}
),
AccountId::for_private_pda(&program_id, &seed, &npk, &vpk, identifier),
);
}
#[test]
fn compute_public_authorized_pdas_with_seeds() {
let caller: ProgramId = [1; 8];
let seed = PdaSeed::new([2; 32]);
let result = compute_public_authorized_pdas(Some(caller), &[seed]);
let expected = AccountId::for_public_pda(&caller, &seed);
assert!(result.contains(&expected));
assert_eq!(result.len(), 1);
}
/// With no caller (top-level call), the result is always empty.
#[test]
fn compute_public_authorized_pdas_no_caller_returns_empty() {
let seed = PdaSeed::new([2; 32]);
let result = compute_public_authorized_pdas(None, &[seed]);
assert!(result.is_empty());
}

View File

@ -164,398 +164,4 @@ const fn prev_power_of_two(x: usize) -> usize {
}
#[cfg(test)]
mod tests {
use hex_literal::hex;
use super::*;
impl MerkleTree {
pub fn new(values: &[Value]) -> Self {
let mut this = Self::with_capacity(values.len());
for value in values.iter().copied() {
this.insert(value);
}
this
}
}
#[test]
fn empty_merkle_tree() {
let tree = MerkleTree::with_capacity(4);
let expected_root =
hex!("0000000000000000000000000000000000000000000000000000000000000000");
assert_eq!(tree.root(), expected_root);
assert_eq!(tree.capacity, 4);
assert_eq!(tree.length, 0);
}
#[test]
fn merkle_tree_0() {
let values = [[0; 32]];
let tree = MerkleTree::new(&values);
assert_eq!(tree.root(), hash_value(&[0; 32]));
assert_eq!(tree.capacity, 1);
assert_eq!(tree.length, 1);
}
#[test]
fn merkle_tree_1() {
let values = [[1; 32], [2; 32], [3; 32], [4; 32]];
let tree = MerkleTree::new(&values);
let expected_root =
hex!("48c73f7821a58a8d2a703e5b39c571c0aa20cf14abcd0af8f2b955bc202998de");
assert_eq!(tree.root(), expected_root);
assert_eq!(tree.capacity, 4);
assert_eq!(tree.length, 4);
}
#[test]
fn merkle_tree_2() {
let values = [[1; 32], [2; 32], [3; 32], [0; 32]];
let tree = MerkleTree::new(&values);
let expected_root =
hex!("c9bbb83096df85157a146e7d770455a98412dee0633187ee86fee6c8a45b831a");
assert_eq!(tree.root(), expected_root);
assert_eq!(tree.capacity, 4);
assert_eq!(tree.length, 4);
}
#[test]
fn merkle_tree_3() {
let values = [[1; 32], [2; 32], [3; 32]];
let tree = MerkleTree::new(&values);
let expected_root =
hex!("c8d3d8d2b13f27ceeccdc699119871f9f32ea7ed86ff45d0ad11f77b28cd7568");
assert_eq!(tree.root(), expected_root);
assert_eq!(tree.capacity, 4);
assert_eq!(tree.length, 3);
}
#[test]
fn merkle_tree_4() {
let values = [[11; 32], [12; 32], [13; 32], [14; 32], [15; 32]];
let tree = MerkleTree::new(&values);
let expected_root =
hex!("ef418aed5aa20702d4d94c92da79a4012f2e36f1008bfdb3cd1e38749dca2499");
assert_eq!(tree.root(), expected_root);
assert_eq!(tree.capacity, 8);
assert_eq!(tree.length, 5);
}
#[test]
fn merkle_tree_5() {
let values = [
[11; 32], [12; 32], [12; 32], [13; 32], [14; 32], [15; 32], [15; 32], [13; 32],
[13; 32], [15; 32], [11; 32],
];
let tree = MerkleTree::new(&values);
let expected_root =
hex!("3f72d2ff55921a86c48e5988ec3e19ee9d0d5aa3e23197842970a903508ed767");
assert_eq!(tree.root(), expected_root);
assert_eq!(tree.capacity, 16);
assert_eq!(tree.length, 11);
}
#[test]
fn merkle_tree_6() {
let values = [[1; 32], [2; 32], [3; 32], [4; 32], [5; 32]];
let tree = MerkleTree::new(&values);
let expected_root =
hex!("069cb8259a06fe6edb3fa7ff7933a6dd7dca6fca299314379794a688926c3792");
assert_eq!(tree.root(), expected_root);
}
#[test]
fn with_capacity_4() {
let tree = MerkleTree::with_capacity(4);
assert_eq!(tree.length, 0);
assert_eq!(tree.nodes.len(), 7);
for i in 3..7 {
assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[0], "{i}");
}
for i in 1..3 {
assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[1], "{i}");
}
assert_eq!(*tree.get_node(0), default_values::DEFAULT_VALUES[2]);
}
#[test]
fn with_capacity_5() {
let tree = MerkleTree::with_capacity(5);
assert_eq!(tree.length, 0);
assert_eq!(tree.nodes.len(), 15);
for i in 7..15 {
assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[0]);
}
for i in 3..7 {
assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[1]);
}
for i in 1..3 {
assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[2]);
}
assert_eq!(*tree.get_node(0), default_values::DEFAULT_VALUES[3]);
}
#[test]
fn with_capacity_6() {
let mut tree = MerkleTree::with_capacity(100);
let values = [[1; 32], [2; 32], [3; 32], [4; 32]];
let expected_root =
hex!("48c73f7821a58a8d2a703e5b39c571c0aa20cf14abcd0af8f2b955bc202998de");
assert_eq!(0, tree.insert(values[0]));
assert_eq!(1, tree.insert(values[1]));
assert_eq!(2, tree.insert(values[2]));
assert_eq!(3, tree.insert(values[3]));
assert_eq!(tree.root(), expected_root);
}
#[test]
fn with_capacity_7() {
let mut tree = MerkleTree::with_capacity(599);
let values = [[1; 32], [2; 32], [3; 32]];
let expected_root =
hex!("c8d3d8d2b13f27ceeccdc699119871f9f32ea7ed86ff45d0ad11f77b28cd7568");
assert_eq!(0, tree.insert(values[0]));
assert_eq!(1, tree.insert(values[1]));
assert_eq!(2, tree.insert(values[2]));
assert_eq!(tree.root(), expected_root);
}
#[test]
fn with_capacity_8() {
let mut tree = MerkleTree::with_capacity(1);
let values = [[1; 32], [2; 32], [3; 32]];
let expected_root =
hex!("c8d3d8d2b13f27ceeccdc699119871f9f32ea7ed86ff45d0ad11f77b28cd7568");
assert_eq!(0, tree.insert(values[0]));
assert_eq!(1, tree.insert(values[1]));
assert_eq!(2, tree.insert(values[2]));
assert_eq!(tree.root(), expected_root);
}
#[test]
fn insert_value_1() {
let mut tree = MerkleTree::with_capacity(1);
let values = [[1; 32], [2; 32], [3; 32]];
let expected_tree = MerkleTree::new(&values);
assert_eq!(0, tree.insert(values[0]));
assert_eq!(1, tree.insert(values[1]));
assert_eq!(2, tree.insert(values[2]));
assert_eq!(expected_tree, tree);
}
#[test]
fn insert_value_2() {
let mut tree = MerkleTree::with_capacity(1);
let values = [[1; 32], [2; 32], [3; 32], [4; 32]];
let expected_tree = MerkleTree::new(&values);
assert_eq!(0, tree.insert(values[0]));
assert_eq!(1, tree.insert(values[1]));
assert_eq!(2, tree.insert(values[2]));
assert_eq!(3, tree.insert(values[3]));
assert_eq!(expected_tree, tree);
}
#[test]
fn insert_value_3() {
let mut tree = MerkleTree::with_capacity(1);
let values = [[11; 32], [12; 32], [13; 32], [14; 32], [15; 32]];
let expected_tree = MerkleTree::new(&values);
tree.insert(values[0]);
tree.insert(values[1]);
tree.insert(values[2]);
tree.insert(values[3]);
tree.insert(values[4]);
assert_eq!(expected_tree, tree);
}
// Reference implementation
fn verify_authentication_path(value: &Value, index: usize, path: &[Node], root: &Node) -> bool {
let mut result = hash_value(value);
let mut level_index = index;
for node in path {
let is_left_child = level_index & 1 == 0;
if is_left_child {
result = hash_two(&result, node);
} else {
result = hash_two(node, &result);
}
level_index >>= 1;
}
&result == root
}
#[test]
fn authentication_path_1() {
let values = [[1; 32], [2; 32], [3; 32], [4; 32]];
let tree = MerkleTree::new(&values);
let expected_authentication_path = vec![
hex!("9f4fb68f3e1dac82202f9aa581ce0bbf1f765df0e9ac3c8c57e20f685abab8ed"),
hex!("50a27d4746f357cb700cbe9d4883b77fb64f0128828a3489dc6a6f21ddbf2414"),
];
let authentication_path = tree.get_authentication_path_for(2).unwrap();
assert_eq!(authentication_path, expected_authentication_path);
}
#[test]
fn authentication_path_2() {
let values = [[1; 32], [2; 32], [3; 32]];
let tree = MerkleTree::new(&values);
let expected_authentication_path = vec![
hex!("75877bb41d393b5fb8455ce60ecd8dda001d06316496b14dfa7f895656eeca4a"),
hex!("a41b855d2db4de9052cd7be5ec67d6586629cb9f6e3246a4afa5ba313f07a9c5"),
];
let authentication_path = tree.get_authentication_path_for(0).unwrap();
assert_eq!(authentication_path, expected_authentication_path);
}
#[test]
fn authentication_path_3() {
let values = [[1; 32], [2; 32], [3; 32], [4; 32], [5; 32]];
let tree = MerkleTree::new(&values);
let expected_authentication_path = vec![
hex!("0000000000000000000000000000000000000000000000000000000000000000"),
hex!("f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b"),
hex!("48c73f7821a58a8d2a703e5b39c571c0aa20cf14abcd0af8f2b955bc202998de"),
];
let authentication_path = tree.get_authentication_path_for(4).unwrap();
assert_eq!(authentication_path, expected_authentication_path);
}
#[test]
fn authentication_path_4() {
let values = [[1; 32], [2; 32], [3; 32], [4; 32], [5; 32]];
let tree = MerkleTree::new(&values);
assert!(tree.get_authentication_path_for(5).is_none());
}
#[test]
fn authentication_path_5() {
let values = [[1; 32], [2; 32], [3; 32], [4; 32], [5; 32]];
let tree = MerkleTree::new(&values);
let index = 4;
let value = values[index];
let path = tree.get_authentication_path_for(index).unwrap();
assert!(verify_authentication_path(
&value,
index,
&path,
&tree.root()
));
}
#[test]
fn tree_with_63_insertions() {
let values = [
hex!("cd00acab0f45736e6c6311f1953becc0b69a062e7c2a7310875d28bdf9ef9c5b"),
hex!("0df5a6afbcc7bf126caf7084acfc593593ab512e6ca433c61c1a922be40a04ea"),
hex!("23c1258620266c7bedb6d1ee32f6da9413e4010ace975239dccb34e727e07c40"),
hex!("f33ccc3a11476b0ef62326ca5ec292056759b05e6a28023d2d1ce66165611353"),
hex!("77f914ab016b8049f6bea7704000e413a393865918a3824f9285c3db0aacff23"),
hex!("910a1c23188e54d57fd167ddb0f8bf68c6b70ed9ec76ef56c4b7f2632f82ca7f"),
hex!("047ee85526197d1e7403a559cf6d2f22c1926c8ad59481a2e2f1b697af45e40b"),
hex!("9d355cf89fb382ae34bf80566b28489278d10f2cebb5b0ea42fab1bac5adae0c"),
hex!("604018b95232596b2685a9bc737b6cccb53b10e483d2d9a2f4a755410b02a188"),
hex!("a16708ef7b6bf1796063addaf57d6a566b6f87b0bbe42af43a4590d05f1684cb"),
hex!("820f2dfa271cd2fd41e1452406d5dad552c85c1223c45d45dbd7446759fdc6b8"),
hex!("680b6912d7e219f8805d4d28adb4428dd78fea0dc1b8cdb2412645c4b1962c88"),
hex!("14d5471ce6c45506753982b17cac5790ac7bc29e6f388f31052d7dfd62b294e5"),
hex!("8b364200172b777d4aa16d2098b5eb98ac3dd4a1b9597e5c2bf6f6930031f230"),
hex!("9bb45b910711874339dda8a21a9aad73822286f5e52d7d3de0ed78dfbba329a5"),
hex!("d6806d5df5cb25ce5d531042f09b3cb34fb9e47c61182b63cccd9d44392f6027"),
hex!("b8cfa90ebc8fd09c04682d93a08fddd3e8e57715174dcc92451edd191264a58b"),
hex!("3463c7f81d00f809b3dfa83195447c927fb4045b3913dac6f45bee6c4010d7ed"),
hex!("1d6ad7f7d677905feb506c58f4b404a79370ebc567296abea3a368b61d5a8239"),
hex!("a58085ecf00963cb22da23c901b9b3ddc56462bb96ff03c923d67708e10dd29c"),
hex!("c3319f4a65fb5bbb8447137b0972c03cbd84ebf7d9da194e0fcbd68c2d4d5bdb"),
hex!("4aa31e90e0090faf3648d05e5d5499df2c78ebed4d6e6c23d8147de5d67dae73"),
hex!("9f33b1d2c8bc7bd265336de1033ede6344bc41260313bdcb43f1108b83b9be92"),
hex!("6500d4ad93d41c16ec81eaa5e70f173194aabe5c1072ac263b5727296f5b7cac"),
hex!("3584f5d260003669fad98786e13171376b0f19410cb232ce65606cbff79e6768"),
hex!("c8410946ebf56f13141c894a34ced85a5230088af70dcea581e44f52847830ac"),
hex!("71dd90281cdebb70422f2d04ae446d5d2d5ea64b803c16128d37e3fcd5d1a4cc"),
hex!("c05acf8d77ab4d659a538bd35af590864a7ad9c055ff5d6cda9d5aecfccecba3"),
hex!("f1df98822ea084cce9021aa9cc81b1746cd1e84a75690da63e10fd877633ed77"),
hex!("2ca822bc8f67bceb0a71a0d06fea7349036ef3e5ec21795a851e4182bd35ce01"),
hex!("7fd2179abc3bcf89b4d8092988ba8c23952b3bbd3d7caea6b5ea0c13cf19f68b"),
hex!("91b6ad516e017f6aa5a2e95776538bd3a3e933c1b1d32bb5e0f00a9db63c9c24"),
hex!("cd31a8b5eef5ca0be5ef1cb261d0bf0a74d774a3152bb99739cfd296a1d0b85e"),
hex!("3fb16f48b2bf93f3815979e6638f975d7f935088ec37db0be0f07965fbc78339"),
hex!("c60c61b99bf486af5f4bf780a69860dafcd35c1474306a8575666fb5449bcec0"),
hex!("8048d0d7e14091251f3f6c6b10bf6b5880a014b513f9f8c2395501dbffa6192a"),
hex!("778b5af10b9dbe80b60a8e4f0bb91caf4476bcb812801099760754ae623fbd84"),
hex!("d3ac25467920a4e08998b7a3226b8b54bfe66ac58cfedc71f15b2402fee0054a"),
hex!("029aa94598fae2961a0d43937b8a9a3138bcfeae99a7cb15f77fac7c506f8432"),
hex!("2eee5ef52fe669cb6882a68c893abdc1262dcf4424e4ba7a479da7cf1c10171d"),
hex!("de3fb3d070e3a90f0eed8b5e65088a8dc0e4e3c342b9c0bf33bab714eae5dfec"),
hex!("14d40177e833ab45bbfdc5f2b11fba7efaebb3f69facc554f24b549a2efe8538"),
hex!("5734355069702448774fb2df95f1d562e1b9fe1514aeb6b922554ee9d2d01068"),
hex!("8a273d49ac110343cec2cf3359d16eb2906b446bd9ec9833e2a640cebc8d5155"),
hex!("e3fa984dd3cbeb9a7e827ed32d3d4e6a6ba643a55d82be97d9ddb06ee809fa3e"),
hex!("90b1d5a364e17c8b7965396b06ec6e13749b5fc16500731518ad8fc30ae33e77"),
hex!("7517376541b2e8ec83cbab04522b54a26610908a9872feb663451385aea58eb1"),
hex!("5cba2e4cf7448e526d161133c4b2ea7c919ac4813a7308612595f46f11dea6cd"),
hex!("c721911b300bec0691c8a2dfaabfef1d66b7b6258918914d3c3ad690729f05b7"),
hex!("d0d0a70d8ae0d27806fa0b711c507290c260a89cbca0436d339d1dccdd087d62"),
hex!("2a625c28ea763c5e82dd0a93ecfca7ec371ccbb363cd42be359c2c875f58009d"),
hex!("174ef0119932ed890397d9f3837dd85f9100558b6fc9085d4af947ae8cf74bbc"),
hex!("b497bc267151e8efa3c6daa461e6804b01a3f05f44f1f4d5b41d5f0d3f5219b1"),
hex!("e987e91f5734630ddd7e6b58733b4fcdbc316ee9e8cac0e94c36c91cf58e59cc"),
hex!("55019ad8bbe656c51eb042190c1c8da53f42baf43fd2350ebea38fc7cca2fae3"),
hex!("c45a638edd18a6d9f5ad20b870c81b8626459bcb22dae7d58add7a6b6c6a84a8"),
hex!("d42d3a5fb2ad50b2027fe5a36d59dd71e49a63e4b1b299073c96bbf7ba5d68a1"),
hex!("9599e561054bcd3f647eb018ab0b069d3176497d42be9c4466551cbb959be47c"),
hex!("42f33b23775327ff71aea6569548255f3cc9929da73373cc9bb1743d417f7cda"),
hex!("ab24294f44fc6fdbeb96e0f6e93c4f6d97d035b73b9a337c353e18c6d0603bdd"),
hex!("33954ec63520334f99b640a2982ac966b68c363fed383d621a1ab573934f1d33"),
hex!("5e2a1f7df963d1fd8f50a285387cfbb5df581426619b325563e20bf7886c62b7"),
hex!("13ffde471d4e27c473254e766fd1328ad80c42cab4d4955cffeae43d866f86e5"),
];
let expected_root =
hex!("1cf9b214217d7823f9de51b8f6cb34d0a99436a3a1bb762f90b815672a6afcc0");
let mut tree_less_capacity = MerkleTree::with_capacity(1);
let mut tree_exact_capacity = MerkleTree::with_capacity(64);
let mut tree_more_capacity = MerkleTree::with_capacity(128);
for value in &values {
tree_less_capacity.insert(*value);
tree_exact_capacity.insert(*value);
tree_more_capacity.insert(*value);
}
assert_eq!(tree_more_capacity.root(), expected_root);
assert_eq!(tree_less_capacity.root(), expected_root);
assert_eq!(tree_exact_capacity.root(), expected_root);
}
}
//
mod tests;

View File

@ -0,0 +1,380 @@
use hex_literal::hex;
use super::*;
impl MerkleTree {
pub fn new(values: &[Value]) -> Self {
let mut this = Self::with_capacity(values.len());
for value in values.iter().copied() {
this.insert(value);
}
this
}
}
#[test]
fn empty_merkle_tree() {
let tree = MerkleTree::with_capacity(4);
let expected_root = hex!("0000000000000000000000000000000000000000000000000000000000000000");
assert_eq!(tree.root(), expected_root);
assert_eq!(tree.capacity, 4);
assert_eq!(tree.length, 0);
}
#[test]
fn merkle_tree_0() {
let values = [[0; 32]];
let tree = MerkleTree::new(&values);
assert_eq!(tree.root(), hash_value(&[0; 32]));
assert_eq!(tree.capacity, 1);
assert_eq!(tree.length, 1);
}
#[test]
fn merkle_tree_1() {
let values = [[1; 32], [2; 32], [3; 32], [4; 32]];
let tree = MerkleTree::new(&values);
let expected_root = hex!("48c73f7821a58a8d2a703e5b39c571c0aa20cf14abcd0af8f2b955bc202998de");
assert_eq!(tree.root(), expected_root);
assert_eq!(tree.capacity, 4);
assert_eq!(tree.length, 4);
}
#[test]
fn merkle_tree_2() {
let values = [[1; 32], [2; 32], [3; 32], [0; 32]];
let tree = MerkleTree::new(&values);
let expected_root = hex!("c9bbb83096df85157a146e7d770455a98412dee0633187ee86fee6c8a45b831a");
assert_eq!(tree.root(), expected_root);
assert_eq!(tree.capacity, 4);
assert_eq!(tree.length, 4);
}
#[test]
fn merkle_tree_3() {
let values = [[1; 32], [2; 32], [3; 32]];
let tree = MerkleTree::new(&values);
let expected_root = hex!("c8d3d8d2b13f27ceeccdc699119871f9f32ea7ed86ff45d0ad11f77b28cd7568");
assert_eq!(tree.root(), expected_root);
assert_eq!(tree.capacity, 4);
assert_eq!(tree.length, 3);
}
#[test]
fn merkle_tree_4() {
let values = [[11; 32], [12; 32], [13; 32], [14; 32], [15; 32]];
let tree = MerkleTree::new(&values);
let expected_root = hex!("ef418aed5aa20702d4d94c92da79a4012f2e36f1008bfdb3cd1e38749dca2499");
assert_eq!(tree.root(), expected_root);
assert_eq!(tree.capacity, 8);
assert_eq!(tree.length, 5);
}
#[test]
fn merkle_tree_5() {
let values = [
[11; 32], [12; 32], [12; 32], [13; 32], [14; 32], [15; 32], [15; 32], [13; 32], [13; 32],
[15; 32], [11; 32],
];
let tree = MerkleTree::new(&values);
let expected_root = hex!("3f72d2ff55921a86c48e5988ec3e19ee9d0d5aa3e23197842970a903508ed767");
assert_eq!(tree.root(), expected_root);
assert_eq!(tree.capacity, 16);
assert_eq!(tree.length, 11);
}
#[test]
fn merkle_tree_6() {
let values = [[1; 32], [2; 32], [3; 32], [4; 32], [5; 32]];
let tree = MerkleTree::new(&values);
let expected_root = hex!("069cb8259a06fe6edb3fa7ff7933a6dd7dca6fca299314379794a688926c3792");
assert_eq!(tree.root(), expected_root);
}
#[test]
fn with_capacity_4() {
let tree = MerkleTree::with_capacity(4);
assert_eq!(tree.length, 0);
assert_eq!(tree.nodes.len(), 7);
for i in 3..7 {
assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[0], "{i}");
}
for i in 1..3 {
assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[1], "{i}");
}
assert_eq!(*tree.get_node(0), default_values::DEFAULT_VALUES[2]);
}
#[test]
fn with_capacity_5() {
let tree = MerkleTree::with_capacity(5);
assert_eq!(tree.length, 0);
assert_eq!(tree.nodes.len(), 15);
for i in 7..15 {
assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[0]);
}
for i in 3..7 {
assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[1]);
}
for i in 1..3 {
assert_eq!(*tree.get_node(i), default_values::DEFAULT_VALUES[2]);
}
assert_eq!(*tree.get_node(0), default_values::DEFAULT_VALUES[3]);
}
#[test]
fn with_capacity_6() {
let mut tree = MerkleTree::with_capacity(100);
let values = [[1; 32], [2; 32], [3; 32], [4; 32]];
let expected_root = hex!("48c73f7821a58a8d2a703e5b39c571c0aa20cf14abcd0af8f2b955bc202998de");
assert_eq!(0, tree.insert(values[0]));
assert_eq!(1, tree.insert(values[1]));
assert_eq!(2, tree.insert(values[2]));
assert_eq!(3, tree.insert(values[3]));
assert_eq!(tree.root(), expected_root);
}
#[test]
fn with_capacity_7() {
let mut tree = MerkleTree::with_capacity(599);
let values = [[1; 32], [2; 32], [3; 32]];
let expected_root = hex!("c8d3d8d2b13f27ceeccdc699119871f9f32ea7ed86ff45d0ad11f77b28cd7568");
assert_eq!(0, tree.insert(values[0]));
assert_eq!(1, tree.insert(values[1]));
assert_eq!(2, tree.insert(values[2]));
assert_eq!(tree.root(), expected_root);
}
#[test]
fn with_capacity_8() {
let mut tree = MerkleTree::with_capacity(1);
let values = [[1; 32], [2; 32], [3; 32]];
let expected_root = hex!("c8d3d8d2b13f27ceeccdc699119871f9f32ea7ed86ff45d0ad11f77b28cd7568");
assert_eq!(0, tree.insert(values[0]));
assert_eq!(1, tree.insert(values[1]));
assert_eq!(2, tree.insert(values[2]));
assert_eq!(tree.root(), expected_root);
}
#[test]
fn insert_value_1() {
let mut tree = MerkleTree::with_capacity(1);
let values = [[1; 32], [2; 32], [3; 32]];
let expected_tree = MerkleTree::new(&values);
assert_eq!(0, tree.insert(values[0]));
assert_eq!(1, tree.insert(values[1]));
assert_eq!(2, tree.insert(values[2]));
assert_eq!(expected_tree, tree);
}
#[test]
fn insert_value_2() {
let mut tree = MerkleTree::with_capacity(1);
let values = [[1; 32], [2; 32], [3; 32], [4; 32]];
let expected_tree = MerkleTree::new(&values);
assert_eq!(0, tree.insert(values[0]));
assert_eq!(1, tree.insert(values[1]));
assert_eq!(2, tree.insert(values[2]));
assert_eq!(3, tree.insert(values[3]));
assert_eq!(expected_tree, tree);
}
#[test]
fn insert_value_3() {
let mut tree = MerkleTree::with_capacity(1);
let values = [[11; 32], [12; 32], [13; 32], [14; 32], [15; 32]];
let expected_tree = MerkleTree::new(&values);
tree.insert(values[0]);
tree.insert(values[1]);
tree.insert(values[2]);
tree.insert(values[3]);
tree.insert(values[4]);
assert_eq!(expected_tree, tree);
}
// Reference implementation
fn verify_authentication_path(value: &Value, index: usize, path: &[Node], root: &Node) -> bool {
let mut result = hash_value(value);
let mut level_index = index;
for node in path {
let is_left_child = level_index & 1 == 0;
if is_left_child {
result = hash_two(&result, node);
} else {
result = hash_two(node, &result);
}
level_index >>= 1;
}
&result == root
}
#[test]
fn authentication_path_1() {
let values = [[1; 32], [2; 32], [3; 32], [4; 32]];
let tree = MerkleTree::new(&values);
let expected_authentication_path = vec![
hex!("9f4fb68f3e1dac82202f9aa581ce0bbf1f765df0e9ac3c8c57e20f685abab8ed"),
hex!("50a27d4746f357cb700cbe9d4883b77fb64f0128828a3489dc6a6f21ddbf2414"),
];
let authentication_path = tree.get_authentication_path_for(2).unwrap();
assert_eq!(authentication_path, expected_authentication_path);
}
#[test]
fn authentication_path_2() {
let values = [[1; 32], [2; 32], [3; 32]];
let tree = MerkleTree::new(&values);
let expected_authentication_path = vec![
hex!("75877bb41d393b5fb8455ce60ecd8dda001d06316496b14dfa7f895656eeca4a"),
hex!("a41b855d2db4de9052cd7be5ec67d6586629cb9f6e3246a4afa5ba313f07a9c5"),
];
let authentication_path = tree.get_authentication_path_for(0).unwrap();
assert_eq!(authentication_path, expected_authentication_path);
}
#[test]
fn authentication_path_3() {
let values = [[1; 32], [2; 32], [3; 32], [4; 32], [5; 32]];
let tree = MerkleTree::new(&values);
let expected_authentication_path = vec![
hex!("0000000000000000000000000000000000000000000000000000000000000000"),
hex!("f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b"),
hex!("48c73f7821a58a8d2a703e5b39c571c0aa20cf14abcd0af8f2b955bc202998de"),
];
let authentication_path = tree.get_authentication_path_for(4).unwrap();
assert_eq!(authentication_path, expected_authentication_path);
}
#[test]
fn authentication_path_4() {
let values = [[1; 32], [2; 32], [3; 32], [4; 32], [5; 32]];
let tree = MerkleTree::new(&values);
assert!(tree.get_authentication_path_for(5).is_none());
}
#[test]
fn authentication_path_5() {
let values = [[1; 32], [2; 32], [3; 32], [4; 32], [5; 32]];
let tree = MerkleTree::new(&values);
let index = 4;
let value = values[index];
let path = tree.get_authentication_path_for(index).unwrap();
assert!(verify_authentication_path(
&value,
index,
&path,
&tree.root()
));
}
#[test]
fn tree_with_63_insertions() {
let values = [
hex!("cd00acab0f45736e6c6311f1953becc0b69a062e7c2a7310875d28bdf9ef9c5b"),
hex!("0df5a6afbcc7bf126caf7084acfc593593ab512e6ca433c61c1a922be40a04ea"),
hex!("23c1258620266c7bedb6d1ee32f6da9413e4010ace975239dccb34e727e07c40"),
hex!("f33ccc3a11476b0ef62326ca5ec292056759b05e6a28023d2d1ce66165611353"),
hex!("77f914ab016b8049f6bea7704000e413a393865918a3824f9285c3db0aacff23"),
hex!("910a1c23188e54d57fd167ddb0f8bf68c6b70ed9ec76ef56c4b7f2632f82ca7f"),
hex!("047ee85526197d1e7403a559cf6d2f22c1926c8ad59481a2e2f1b697af45e40b"),
hex!("9d355cf89fb382ae34bf80566b28489278d10f2cebb5b0ea42fab1bac5adae0c"),
hex!("604018b95232596b2685a9bc737b6cccb53b10e483d2d9a2f4a755410b02a188"),
hex!("a16708ef7b6bf1796063addaf57d6a566b6f87b0bbe42af43a4590d05f1684cb"),
hex!("820f2dfa271cd2fd41e1452406d5dad552c85c1223c45d45dbd7446759fdc6b8"),
hex!("680b6912d7e219f8805d4d28adb4428dd78fea0dc1b8cdb2412645c4b1962c88"),
hex!("14d5471ce6c45506753982b17cac5790ac7bc29e6f388f31052d7dfd62b294e5"),
hex!("8b364200172b777d4aa16d2098b5eb98ac3dd4a1b9597e5c2bf6f6930031f230"),
hex!("9bb45b910711874339dda8a21a9aad73822286f5e52d7d3de0ed78dfbba329a5"),
hex!("d6806d5df5cb25ce5d531042f09b3cb34fb9e47c61182b63cccd9d44392f6027"),
hex!("b8cfa90ebc8fd09c04682d93a08fddd3e8e57715174dcc92451edd191264a58b"),
hex!("3463c7f81d00f809b3dfa83195447c927fb4045b3913dac6f45bee6c4010d7ed"),
hex!("1d6ad7f7d677905feb506c58f4b404a79370ebc567296abea3a368b61d5a8239"),
hex!("a58085ecf00963cb22da23c901b9b3ddc56462bb96ff03c923d67708e10dd29c"),
hex!("c3319f4a65fb5bbb8447137b0972c03cbd84ebf7d9da194e0fcbd68c2d4d5bdb"),
hex!("4aa31e90e0090faf3648d05e5d5499df2c78ebed4d6e6c23d8147de5d67dae73"),
hex!("9f33b1d2c8bc7bd265336de1033ede6344bc41260313bdcb43f1108b83b9be92"),
hex!("6500d4ad93d41c16ec81eaa5e70f173194aabe5c1072ac263b5727296f5b7cac"),
hex!("3584f5d260003669fad98786e13171376b0f19410cb232ce65606cbff79e6768"),
hex!("c8410946ebf56f13141c894a34ced85a5230088af70dcea581e44f52847830ac"),
hex!("71dd90281cdebb70422f2d04ae446d5d2d5ea64b803c16128d37e3fcd5d1a4cc"),
hex!("c05acf8d77ab4d659a538bd35af590864a7ad9c055ff5d6cda9d5aecfccecba3"),
hex!("f1df98822ea084cce9021aa9cc81b1746cd1e84a75690da63e10fd877633ed77"),
hex!("2ca822bc8f67bceb0a71a0d06fea7349036ef3e5ec21795a851e4182bd35ce01"),
hex!("7fd2179abc3bcf89b4d8092988ba8c23952b3bbd3d7caea6b5ea0c13cf19f68b"),
hex!("91b6ad516e017f6aa5a2e95776538bd3a3e933c1b1d32bb5e0f00a9db63c9c24"),
hex!("cd31a8b5eef5ca0be5ef1cb261d0bf0a74d774a3152bb99739cfd296a1d0b85e"),
hex!("3fb16f48b2bf93f3815979e6638f975d7f935088ec37db0be0f07965fbc78339"),
hex!("c60c61b99bf486af5f4bf780a69860dafcd35c1474306a8575666fb5449bcec0"),
hex!("8048d0d7e14091251f3f6c6b10bf6b5880a014b513f9f8c2395501dbffa6192a"),
hex!("778b5af10b9dbe80b60a8e4f0bb91caf4476bcb812801099760754ae623fbd84"),
hex!("d3ac25467920a4e08998b7a3226b8b54bfe66ac58cfedc71f15b2402fee0054a"),
hex!("029aa94598fae2961a0d43937b8a9a3138bcfeae99a7cb15f77fac7c506f8432"),
hex!("2eee5ef52fe669cb6882a68c893abdc1262dcf4424e4ba7a479da7cf1c10171d"),
hex!("de3fb3d070e3a90f0eed8b5e65088a8dc0e4e3c342b9c0bf33bab714eae5dfec"),
hex!("14d40177e833ab45bbfdc5f2b11fba7efaebb3f69facc554f24b549a2efe8538"),
hex!("5734355069702448774fb2df95f1d562e1b9fe1514aeb6b922554ee9d2d01068"),
hex!("8a273d49ac110343cec2cf3359d16eb2906b446bd9ec9833e2a640cebc8d5155"),
hex!("e3fa984dd3cbeb9a7e827ed32d3d4e6a6ba643a55d82be97d9ddb06ee809fa3e"),
hex!("90b1d5a364e17c8b7965396b06ec6e13749b5fc16500731518ad8fc30ae33e77"),
hex!("7517376541b2e8ec83cbab04522b54a26610908a9872feb663451385aea58eb1"),
hex!("5cba2e4cf7448e526d161133c4b2ea7c919ac4813a7308612595f46f11dea6cd"),
hex!("c721911b300bec0691c8a2dfaabfef1d66b7b6258918914d3c3ad690729f05b7"),
hex!("d0d0a70d8ae0d27806fa0b711c507290c260a89cbca0436d339d1dccdd087d62"),
hex!("2a625c28ea763c5e82dd0a93ecfca7ec371ccbb363cd42be359c2c875f58009d"),
hex!("174ef0119932ed890397d9f3837dd85f9100558b6fc9085d4af947ae8cf74bbc"),
hex!("b497bc267151e8efa3c6daa461e6804b01a3f05f44f1f4d5b41d5f0d3f5219b1"),
hex!("e987e91f5734630ddd7e6b58733b4fcdbc316ee9e8cac0e94c36c91cf58e59cc"),
hex!("55019ad8bbe656c51eb042190c1c8da53f42baf43fd2350ebea38fc7cca2fae3"),
hex!("c45a638edd18a6d9f5ad20b870c81b8626459bcb22dae7d58add7a6b6c6a84a8"),
hex!("d42d3a5fb2ad50b2027fe5a36d59dd71e49a63e4b1b299073c96bbf7ba5d68a1"),
hex!("9599e561054bcd3f647eb018ab0b069d3176497d42be9c4466551cbb959be47c"),
hex!("42f33b23775327ff71aea6569548255f3cc9929da73373cc9bb1743d417f7cda"),
hex!("ab24294f44fc6fdbeb96e0f6e93c4f6d97d035b73b9a337c353e18c6d0603bdd"),
hex!("33954ec63520334f99b640a2982ac966b68c363fed383d621a1ab573934f1d33"),
hex!("5e2a1f7df963d1fd8f50a285387cfbb5df581426619b325563e20bf7886c62b7"),
hex!("13ffde471d4e27c473254e766fd1328ad80c42cab4d4955cffeae43d866f86e5"),
];
let expected_root = hex!("1cf9b214217d7823f9de51b8f6cb34d0a99436a3a1bb762f90b815672a6afcc0");
let mut tree_less_capacity = MerkleTree::with_capacity(1);
let mut tree_exact_capacity = MerkleTree::with_capacity(64);
let mut tree_more_capacity = MerkleTree::with_capacity(128);
for value in &values {
tree_less_capacity.insert(*value);
tree_exact_capacity.insert(*value);
tree_more_capacity.insert(*value);
}
assert_eq!(tree_more_capacity.root(), expected_root);
assert_eq!(tree_less_capacity.root(), expected_root);
assert_eq!(tree_exact_capacity.root(), expected_root);
}

View File

@ -1,906 +0,0 @@
use std::collections::{HashMap, VecDeque};
use borsh::{BorshDeserialize, BorshSerialize};
use lee_core::{
InputAccountIdentity, PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput,
account::AccountWithMetadata,
program::{ChainedCall, InstructionData, ProgramId, ProgramOutput},
};
use risc0_zkvm::{ExecutorEnv, InnerReceipt, ProverOpts, Receipt, default_prover};
use crate::{
PRIVACY_PRESERVING_CIRCUIT_ELF, PRIVACY_PRESERVING_CIRCUIT_ID,
error::{InvalidProgramBehaviorError, LeeError},
program::Program,
state::MAX_NUMBER_CHAINED_CALLS,
};
/// Proof of the privacy preserving execution circuit.
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct Proof(pub(crate) Vec<u8>);
impl Proof {
#[must_use]
pub fn into_inner(self) -> Vec<u8> {
self.0
}
#[must_use]
pub const fn from_inner(inner: Vec<u8>) -> Self {
Self(inner)
}
pub(crate) fn is_valid_for(&self, circuit_output: &PrivacyPreservingCircuitOutput) -> bool {
let Ok(inner) = borsh::from_slice::<InnerReceipt>(&self.0) else {
return false;
};
let receipt = Receipt::new(inner, circuit_output.to_bytes());
receipt.verify(PRIVACY_PRESERVING_CIRCUIT_ID).is_ok()
}
}
#[derive(Clone)]
pub struct ProgramWithDependencies {
pub program: Program,
// TODO: avoid having a copy of the bytecode of each dependency.
pub dependencies: HashMap<ProgramId, Program>,
}
impl ProgramWithDependencies {
#[must_use]
pub const fn new(program: Program, dependencies: HashMap<ProgramId, Program>) -> Self {
Self {
program,
dependencies,
}
}
}
impl From<Program> for ProgramWithDependencies {
fn from(program: Program) -> Self {
Self::new(program, HashMap::new())
}
}
/// Generates a proof of the execution of a LEE program inside the privacy preserving execution
/// circuit.
pub fn execute_and_prove(
pre_states: Vec<AccountWithMetadata>,
instruction_data: InstructionData,
account_identities: Vec<InputAccountIdentity>,
program_with_dependencies: &ProgramWithDependencies,
) -> Result<(PrivacyPreservingCircuitOutput, Proof), LeeError> {
let ProgramWithDependencies {
program: initial_program,
dependencies,
} = program_with_dependencies;
let mut env_builder = ExecutorEnv::builder();
let mut program_outputs = Vec::new();
let initial_call = ChainedCall {
program_id: initial_program.id(),
instruction_data,
pre_states,
pda_seeds: vec![],
};
let mut chained_calls = VecDeque::from_iter([(initial_call, initial_program, None)]);
let mut chain_calls_counter = 0;
while let Some((chained_call, program, caller_program_id)) = chained_calls.pop_front() {
if chain_calls_counter >= MAX_NUMBER_CHAINED_CALLS {
return Err(LeeError::MaxChainedCallsDepthExceeded);
}
let inner_receipt = execute_and_prove_program(
program,
caller_program_id,
&chained_call.pre_states,
&chained_call.instruction_data,
)?;
let program_output: ProgramOutput = inner_receipt
.journal
.decode()
.map_err(|e| LeeError::ProgramOutputDeserializationError(e.to_string()))?;
// TODO: remove clone
program_outputs.push(program_output.clone());
// Prove circuit.
env_builder.add_assumption(inner_receipt);
for new_call in program_output.chained_calls.into_iter().rev() {
let next_program = dependencies.get(&new_call.program_id).ok_or(
InvalidProgramBehaviorError::UndeclaredProgramDependency {
program_id: new_call.program_id,
},
)?;
chained_calls.push_front((new_call, next_program, Some(chained_call.program_id)));
}
chain_calls_counter = chain_calls_counter
.checked_add(1)
.expect("we check the max depth at the beginning of the loop");
}
let circuit_input = PrivacyPreservingCircuitInput {
program_outputs,
account_identities,
program_id: program_with_dependencies.program.id(),
};
env_builder.write(&circuit_input).unwrap();
let env = env_builder.build().unwrap();
let prover = default_prover();
let opts = ProverOpts::succinct();
let prove_info = prover
.prove_with_opts(env, PRIVACY_PRESERVING_CIRCUIT_ELF, &opts)
.map_err(|e| LeeError::CircuitProvingError(e.to_string()))?;
let proof = Proof(borsh::to_vec(&prove_info.receipt.inner)?);
let circuit_output: PrivacyPreservingCircuitOutput = prove_info
.receipt
.journal
.decode()
.map_err(|e| LeeError::CircuitOutputDeserializationError(e.to_string()))?;
Ok((circuit_output, proof))
}
fn execute_and_prove_program(
program: &Program,
caller_program_id: Option<ProgramId>,
pre_states: &[AccountWithMetadata],
instruction_data: &InstructionData,
) -> Result<Receipt, LeeError> {
// Write inputs to the program
let mut env_builder = ExecutorEnv::builder();
Program::write_inputs(
program.id(),
caller_program_id,
pre_states,
instruction_data,
&mut env_builder,
)?;
let env = env_builder.build().unwrap();
// Prove the program
let prover = default_prover();
Ok(prover
.prove(env, program.elf())
.map_err(|e| LeeError::ProgramProveFailed(e.to_string()))?
.receipt)
}
#[cfg(test)]
mod tests {
#![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")]
use lee_core::{
Commitment, DUMMY_COMMITMENT_HASH, EncryptedAccountData, EncryptionScheme,
EphemeralPublicKey, Nullifier, PrivacyPreservingCircuitOutput, SharedSecretKey,
account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data},
program::{PdaSeed, PrivateAccountKind},
};
use super::*;
use crate::{
error::LeeError,
privacy_preserving_transaction::circuit::execute_and_prove,
program::Program,
state::{
CommitmentSet,
tests::{test_private_account_keys_1, test_private_account_keys_2},
},
};
fn decrypt_kind(
output: &PrivacyPreservingCircuitOutput,
ssk: &SharedSecretKey,
idx: usize,
) -> PrivateAccountKind {
let (kind, _) = EncryptionScheme::decrypt(
&output.encrypted_private_post_states[idx].ciphertext,
ssk,
&output.new_commitments[idx],
u32::try_from(idx).expect("idx fits in u32"),
)
.unwrap();
kind
}
#[test]
fn proof_inner_roundtrip() {
// `Proof::from_inner(b).into_inner()` must return exactly `b`. Catches
// mutations of `into_inner` returning `vec![]`, `vec![0]`, or `vec![1]`,
// and of `from_inner` discarding its argument.
let bytes = vec![0xDE_u8, 0xAD, 0xBE, 0xEF];
assert_eq!(Proof::from_inner(bytes.clone()).into_inner(), bytes);
assert!(Proof::from_inner(vec![]).into_inner().is_empty());
assert_eq!(Proof::from_inner(vec![0xFF]).into_inner(), vec![0xFF_u8]);
}
#[test]
fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts() {
let recipient_keys = test_private_account_keys_1();
let program = crate::test_methods::simple_balance_transfer();
let sender = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 100,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let recipient_account_id = AccountId::for_regular_private_account(&recipient_keys.npk(), 0);
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id);
let balance_to_move: u128 = 37;
let expected_sender_post = Account {
program_owner: program.id(),
balance: 100 - balance_to_move,
nonce: Nonce::default(),
data: Data::default(),
};
let expected_recipient_post = Account {
program_owner: program.id(),
balance: balance_to_move,
nonce: Nonce::private_account_nonce_init(&recipient_account_id),
data: Data::default(),
};
let expected_sender_pre = sender.clone();
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &[0_u8; 32], 0).0;
let (output, proof) = execute_and_prove(
vec![sender, recipient],
Program::serialize_instruction(balance_to_move).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(
&recipient_keys.npk(),
&recipient_keys.vpk(),
),
npk: recipient_keys.npk(),
ssk: shared_secret,
identifier: 0,
},
],
&crate::test_methods::simple_balance_transfer().into(),
)
.unwrap();
assert!(proof.is_valid_for(&output));
let [sender_pre] = output.public_pre_states.try_into().unwrap();
let [sender_post] = output.public_post_states.try_into().unwrap();
assert_eq!(sender_pre, expected_sender_pre);
assert_eq!(sender_post, expected_sender_post);
assert_eq!(output.new_commitments.len(), 1);
assert_eq!(output.new_nullifiers.len(), 1);
assert_eq!(output.encrypted_private_post_states.len(), 1);
let (_identifier, recipient_post) = EncryptionScheme::decrypt(
&output.encrypted_private_post_states[0].ciphertext,
&shared_secret,
&output.new_commitments[0],
0,
)
.unwrap();
assert_eq!(recipient_post, expected_recipient_post);
}
#[test]
fn prove_privacy_preserving_execution_circuit_fully_private() {
let program = crate::test_methods::simple_balance_transfer();
let sender_keys = test_private_account_keys_1();
let recipient_keys = test_private_account_keys_2();
let sender_nonce = Nonce(0xdead_beef);
let sender_pre = AccountWithMetadata::new(
Account {
balance: 100,
nonce: sender_nonce,
program_owner: program.id(),
data: Data::default(),
},
true,
AccountId::for_regular_private_account(&sender_keys.npk(), 0),
);
let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0);
let commitment_sender = Commitment::new(&sender_account_id, &sender_pre.account);
let recipient_account_id = AccountId::for_regular_private_account(&recipient_keys.npk(), 0);
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id);
let balance_to_move: u128 = 37;
let mut commitment_set = CommitmentSet::with_capacity(2);
commitment_set.extend(std::slice::from_ref(&commitment_sender));
let expected_new_nullifiers = vec![
(
Nullifier::for_account_update(&commitment_sender, &sender_keys.nsk),
commitment_set.digest(),
),
(
Nullifier::for_account_initialization(&recipient_account_id),
DUMMY_COMMITMENT_HASH,
),
];
let program = crate::test_methods::simple_balance_transfer();
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),
..Default::default()
};
let expected_private_account_2 = Account {
program_owner: program.id(),
balance: balance_to_move,
nonce: Nonce::private_account_nonce_init(&recipient_account_id),
..Default::default()
};
let expected_new_commitments = vec![
Commitment::new(&sender_account_id, &expected_private_account_1),
Commitment::new(&recipient_account_id, &expected_private_account_2),
];
let shared_secret_1 =
SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &[0_u8; 32], 0).0;
let shared_secret_2 =
SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &[0_u8; 32], 1).0;
let (output, proof) = execute_and_prove(
vec![sender_pre, recipient],
Program::serialize_instruction(balance_to_move).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(
&sender_keys.npk(),
&sender_keys.vpk(),
),
ssk: shared_secret_1,
nsk: sender_keys.nsk,
membership_proof: commitment_set
.get_proof_for(&commitment_sender)
.expect("sender's commitment must be in the set"),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(
&recipient_keys.npk(),
&recipient_keys.vpk(),
),
npk: recipient_keys.npk(),
ssk: shared_secret_2,
identifier: 0,
},
],
&program.into(),
)
.unwrap();
assert!(proof.is_valid_for(&output));
assert!(output.public_pre_states.is_empty());
assert!(output.public_post_states.is_empty());
assert_eq!(output.new_commitments, expected_new_commitments);
assert_eq!(output.new_nullifiers, expected_new_nullifiers);
assert_eq!(output.encrypted_private_post_states.len(), 2);
let (_identifier, sender_post) = EncryptionScheme::decrypt(
&output.encrypted_private_post_states[0].ciphertext,
&shared_secret_1,
&expected_new_commitments[0],
0,
)
.unwrap();
assert_eq!(sender_post, expected_private_account_1);
let (_identifier, recipient_post) = EncryptionScheme::decrypt(
&output.encrypted_private_post_states[1].ciphertext,
&shared_secret_2,
&expected_new_commitments[1],
1,
)
.unwrap();
assert_eq!(recipient_post, expected_private_account_2);
}
#[test]
fn circuit_fails_when_chained_validity_windows_have_empty_intersection() {
let account_keys = test_private_account_keys_1();
let pre = AccountWithMetadata::new(
Account::default(),
false,
AccountId::for_regular_private_account(&account_keys.npk(), 0),
);
let validity_window_chain_caller = crate::test_methods::validity_window_chain_caller();
let validity_window = crate::test_methods::validity_window();
let instruction = Program::serialize_instruction((
Some(1_u64),
Some(4_u64),
validity_window.id(),
Some(4_u64),
Some(7_u64),
))
.unwrap();
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&account_keys.vpk(), &[0_u8; 32], 0).0;
let program_with_deps = ProgramWithDependencies::new(
validity_window_chain_caller,
[(validity_window.id(), validity_window)].into(),
);
let result = execute_and_prove(
vec![pre],
instruction,
vec![InputAccountIdentity::PrivateUnauthorized {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(
&account_keys.npk(),
&account_keys.vpk(),
),
npk: account_keys.npk(),
ssk: shared_secret,
identifier: 0,
}],
&program_with_deps,
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
/// A private PDA claimed with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Pda` carrying the correct `(program_id, seed, identifier)`.
#[test]
fn private_pda_claim_with_custom_identifier_encrypts_correct_kind() {
let program = crate::test_methods::pda_claimer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let identifier: u128 = 99;
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, identifier);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let (output, _proof) = execute_and_prove(
vec![pre_state],
Program::serialize_instruction(seed).unwrap(),
vec![InputAccountIdentity::PrivatePdaInit {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&npk, &keys.vpk()),
npk,
ssk: shared_secret,
identifier,
seed: None,
}],
&program.clone().into(),
)
.unwrap();
assert_eq!(
decrypt_kind(&output, &shared_secret, 0),
PrivateAccountKind::Pda {
program_id: program.id(),
seed,
identifier
},
);
}
/// PDA init: initializes a new PDA under `simple_balance_transfer`'s ownership.
/// The `simple_transfer_proxy` program chains to `simple_balance_transfer` with `pda_seeds`
/// to establish authorization and the private PDA binding.
#[test]
fn private_pda_init() {
let program = crate::test_methods::simple_transfer_proxy();
let simple_transfer = crate::test_methods::simple_balance_transfer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret_pda =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
// PDA (new, private PDA)
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 0);
let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id);
let auth_id = simple_transfer.id();
let program_with_deps =
ProgramWithDependencies::new(program, [(auth_id, simple_transfer)].into());
// is_withdraw=false triggers init path (1 pre-state)
let instruction = Program::serialize_instruction((seed, auth_id, 0_u128, false)).unwrap();
let result = execute_and_prove(
vec![pda_pre],
instruction,
vec![InputAccountIdentity::PrivatePdaInit {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&npk, &keys.vpk()),
npk,
ssk: shared_secret_pda,
identifier: 0,
seed: None,
}],
&program_with_deps,
);
let (output, _proof) = result.expect("PDA init should succeed");
assert_eq!(output.new_commitments.len(), 1);
}
/// PDA withdraw: chains to `simple_balance_transfer` to move balance from PDA to recipient.
/// Uses a default PDA (amount=0) because testing with a pre-funded PDA requires a
/// two-tx sequence with membership proofs.
#[test]
fn private_pda_withdraw() {
let program = crate::test_methods::simple_transfer_proxy();
let simple_transfer = crate::test_methods::simple_balance_transfer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret_pda =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
// PDA (new, private PDA)
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 0);
let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id);
// Recipient (public)
let recipient_id = AccountId::new([88; 32]);
let recipient_pre = AccountWithMetadata::new(
Account {
program_owner: simple_transfer.id(),
balance: 10000,
..Account::default()
},
true,
recipient_id,
);
let auth_id = simple_transfer.id();
let program_with_deps =
ProgramWithDependencies::new(program, [(auth_id, simple_transfer)].into());
// is_withdraw=true, amount=0 (PDA has no balance yet)
let instruction = Program::serialize_instruction((seed, auth_id, 0_u128, true)).unwrap();
let result = execute_and_prove(
vec![pda_pre, recipient_pre],
instruction,
vec![
InputAccountIdentity::PrivatePdaInit {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&npk, &keys.vpk()),
npk,
ssk: shared_secret_pda,
identifier: 0,
seed: None,
},
InputAccountIdentity::Public,
],
&program_with_deps,
);
let (output, _proof) = result.expect("PDA withdraw should succeed");
assert_eq!(output.new_commitments.len(), 1);
}
/// Shared regular private account: receives funds via `authenticated_transfer` directly,
/// no custom program needed. This demonstrates the non-PDA shared account flow where
/// keys are derived from GMS via `derive_keys_for_shared_account`. The shared account
/// uses the standard unauthorized private account path and works with auth-transfer's
/// transfer path like any other private account.
#[test]
fn shared_account_receives_via_simple_transfer() {
let program = crate::test_methods::simple_balance_transfer();
let shared_keys = test_private_account_keys_1();
let shared_npk = shared_keys.npk();
let shared_identifier: u128 = 42;
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&shared_keys.vpk(), &[0_u8; 32], 0).0;
// Sender: public account with balance, owned by auth-transfer
let sender_id = AccountId::new([99; 32]);
let sender = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 1000,
..Account::default()
},
true,
sender_id,
);
// Recipient: shared private account (new, unauthorized)
let shared_account_id = AccountId::from((&shared_npk, shared_identifier));
let recipient = AccountWithMetadata::new(Account::default(), false, shared_account_id);
let balance_to_move: u128 = 100;
let instruction = Program::serialize_instruction(balance_to_move).unwrap();
let result = execute_and_prove(
vec![sender, recipient],
instruction,
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(
&shared_npk,
&shared_keys.vpk(),
),
npk: shared_npk,
ssk: shared_secret,
identifier: shared_identifier,
},
],
&program.into(),
);
let (output, _proof) = result.expect("shared account receive should succeed");
// Sender is public (no commitment), recipient is private (1 commitment)
assert_eq!(output.new_commitments.len(), 1);
}
/// `PrivateAuthorizedInit` with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Regular` carrying the correct identifier.
#[test]
fn private_authorized_init_encrypts_regular_kind_with_identifier() {
let program = crate::test_methods::claimer();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_regular_private_account(&keys.npk(), identifier);
let pre = AccountWithMetadata::new(Account::default(), true, account_id);
let (output, _) = execute_and_prove(
vec![pre],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedInit {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()),
ssk,
nsk: keys.nsk,
identifier,
}],
&program.into(),
)
.unwrap();
assert_eq!(
decrypt_kind(&output, &ssk, 0),
PrivateAccountKind::Regular(identifier)
);
}
/// `PrivateUnauthorized` with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Regular` carrying the correct identifier.
#[test]
fn private_unauthorized_init_encrypts_regular_kind_with_identifier() {
let program = crate::test_methods::claimer();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let recipient_id = AccountId::for_regular_private_account(&keys.npk(), identifier);
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_id);
let (output, _) = execute_and_prove(
vec![recipient],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::PrivateUnauthorized {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()),
npk: keys.npk(),
ssk,
identifier,
}],
&program.into(),
)
.unwrap();
assert_eq!(
decrypt_kind(&output, &ssk, 0),
PrivateAccountKind::Regular(identifier)
);
}
/// `PrivateAuthorizedUpdate` with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Regular` carrying the correct identifier.
#[test]
fn private_authorized_update_encrypts_regular_kind_with_identifier() {
let program = crate::test_methods::noop();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_regular_private_account(&keys.npk(), 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 sender = AccountWithMetadata::new(account, true, account_id);
let (output, _) = execute_and_prove(
vec![sender],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&keys.npk(), &keys.vpk()),
ssk,
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&commitment).unwrap(),
identifier,
}],
&program.into(),
)
.unwrap();
assert_eq!(
decrypt_kind(&output, &ssk, 0),
PrivateAccountKind::Regular(identifier)
);
}
/// `PrivatePdaUpdate` with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Pda` carrying the correct `(program_id, seed, identifier)`.
#[test]
fn private_pda_update_encrypts_pda_kind_with_identifier() {
let program = crate::test_methods::pda_spend_proxy();
let simple_transfer = crate::test_methods::simple_balance_transfer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let identifier: u128 = 99;
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let simple_transfer_id = simple_transfer.id();
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, identifier);
let pda_account = Account {
program_owner: simple_transfer_id,
balance: 1,
..Account::default()
};
let pda_commitment = Commitment::new(&pda_id, &pda_account);
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&pda_commitment));
let pda_pre = AccountWithMetadata::new(pda_account, true, pda_id);
let recipient_pre =
AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32]));
let program_with_deps = ProgramWithDependencies::new(
program.clone(),
[(simple_transfer_id, simple_transfer)].into(),
);
let (output, _) = execute_and_prove(
vec![pda_pre, recipient_pre],
Program::serialize_instruction((seed, 1_u128, simple_transfer_id, false)).unwrap(),
vec![
InputAccountIdentity::PrivatePdaUpdate {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&npk, &keys.vpk()),
ssk,
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
identifier,
seed: None,
},
InputAccountIdentity::Public,
],
&program_with_deps,
)
.unwrap();
assert_eq!(
decrypt_kind(&output, &ssk, 0),
PrivateAccountKind::Pda {
program_id: program.id(),
seed,
identifier
},
);
}
#[test]
fn private_pda_init_identifier_mismatch_fails() {
let program = crate::test_methods::pda_claimer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 5);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let result = execute_and_prove(
vec![pre_state],
Program::serialize_instruction(seed).unwrap(),
vec![InputAccountIdentity::PrivatePdaInit {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&npk, &keys.vpk()),
npk,
ssk: shared_secret,
identifier: 99,
seed: None,
}],
&program.into(),
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn private_pda_update_identifier_mismatch_fails() {
let program = crate::test_methods::pda_spend_proxy();
let simple_transfer = crate::test_methods::simple_balance_transfer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &[0_u8; 32], 0).0;
let simple_transfer_id = simple_transfer.id();
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 5);
let pda_account = Account {
program_owner: simple_transfer_id,
balance: 1,
..Account::default()
};
let pda_commitment = Commitment::new(&pda_id, &pda_account);
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&pda_commitment));
let pda_pre = AccountWithMetadata::new(pda_account, true, pda_id);
let recipient_pre =
AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32]));
let program_with_deps =
ProgramWithDependencies::new(program, [(simple_transfer_id, simple_transfer)].into());
let result = execute_and_prove(
vec![pda_pre, recipient_pre],
Program::serialize_instruction((seed, 1_u128, simple_transfer_id, false)).unwrap(),
vec![
InputAccountIdentity::PrivatePdaUpdate {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&npk, &keys.vpk()),
ssk,
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
identifier: 99,
seed: None,
},
InputAccountIdentity::Public,
],
&program_with_deps,
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
}

View File

@ -0,0 +1,177 @@
use std::collections::{HashMap, VecDeque};
use borsh::{BorshDeserialize, BorshSerialize};
use lee_core::{
InputAccountIdentity, PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput,
account::AccountWithMetadata,
program::{ChainedCall, InstructionData, ProgramId, ProgramOutput},
};
use risc0_zkvm::{ExecutorEnv, InnerReceipt, ProverOpts, Receipt, default_prover};
use crate::{
PRIVACY_PRESERVING_CIRCUIT_ELF, PRIVACY_PRESERVING_CIRCUIT_ID,
error::{InvalidProgramBehaviorError, LeeError},
program::Program,
state::MAX_NUMBER_CHAINED_CALLS,
};
/// Proof of the privacy preserving execution circuit.
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct Proof(pub(crate) Vec<u8>);
impl Proof {
#[must_use]
pub fn into_inner(self) -> Vec<u8> {
self.0
}
#[must_use]
pub const fn from_inner(inner: Vec<u8>) -> Self {
Self(inner)
}
pub(crate) fn is_valid_for(&self, circuit_output: &PrivacyPreservingCircuitOutput) -> bool {
let Ok(inner) = borsh::from_slice::<InnerReceipt>(&self.0) else {
return false;
};
let receipt = Receipt::new(inner, circuit_output.to_bytes());
receipt.verify(PRIVACY_PRESERVING_CIRCUIT_ID).is_ok()
}
}
#[derive(Clone)]
pub struct ProgramWithDependencies {
pub program: Program,
// TODO: avoid having a copy of the bytecode of each dependency.
pub dependencies: HashMap<ProgramId, Program>,
}
impl ProgramWithDependencies {
#[must_use]
pub const fn new(program: Program, dependencies: HashMap<ProgramId, Program>) -> Self {
Self {
program,
dependencies,
}
}
}
impl From<Program> for ProgramWithDependencies {
fn from(program: Program) -> Self {
Self::new(program, HashMap::new())
}
}
/// Generates a proof of the execution of a LEE program inside the privacy preserving execution
/// circuit.
pub fn execute_and_prove(
pre_states: Vec<AccountWithMetadata>,
instruction_data: InstructionData,
account_identities: Vec<InputAccountIdentity>,
program_with_dependencies: &ProgramWithDependencies,
) -> Result<(PrivacyPreservingCircuitOutput, Proof), LeeError> {
let ProgramWithDependencies {
program: initial_program,
dependencies,
} = program_with_dependencies;
let mut env_builder = ExecutorEnv::builder();
let mut program_outputs = Vec::new();
let initial_call = ChainedCall {
program_id: initial_program.id(),
instruction_data,
pre_states,
pda_seeds: vec![],
};
let mut chained_calls = VecDeque::from_iter([(initial_call, initial_program, None)]);
let mut chain_calls_counter = 0;
while let Some((chained_call, program, caller_program_id)) = chained_calls.pop_front() {
if chain_calls_counter >= MAX_NUMBER_CHAINED_CALLS {
return Err(LeeError::MaxChainedCallsDepthExceeded);
}
let inner_receipt = execute_and_prove_program(
program,
caller_program_id,
&chained_call.pre_states,
&chained_call.instruction_data,
)?;
let program_output: ProgramOutput = inner_receipt
.journal
.decode()
.map_err(|e| LeeError::ProgramOutputDeserializationError(e.to_string()))?;
// TODO: remove clone
program_outputs.push(program_output.clone());
// Prove circuit.
env_builder.add_assumption(inner_receipt);
for new_call in program_output.chained_calls.into_iter().rev() {
let next_program = dependencies.get(&new_call.program_id).ok_or(
InvalidProgramBehaviorError::UndeclaredProgramDependency {
program_id: new_call.program_id,
},
)?;
chained_calls.push_front((new_call, next_program, Some(chained_call.program_id)));
}
chain_calls_counter = chain_calls_counter
.checked_add(1)
.expect("we check the max depth at the beginning of the loop");
}
let circuit_input = PrivacyPreservingCircuitInput {
program_outputs,
account_identities,
program_id: program_with_dependencies.program.id(),
};
env_builder.write(&circuit_input).unwrap();
let env = env_builder.build().unwrap();
let prover = default_prover();
let opts = ProverOpts::succinct();
let prove_info = prover
.prove_with_opts(env, PRIVACY_PRESERVING_CIRCUIT_ELF, &opts)
.map_err(|e| LeeError::CircuitProvingError(e.to_string()))?;
let proof = Proof(borsh::to_vec(&prove_info.receipt.inner)?);
let circuit_output: PrivacyPreservingCircuitOutput = prove_info
.receipt
.journal
.decode()
.map_err(|e| LeeError::CircuitOutputDeserializationError(e.to_string()))?;
Ok((circuit_output, proof))
}
fn execute_and_prove_program(
program: &Program,
caller_program_id: Option<ProgramId>,
pre_states: &[AccountWithMetadata],
instruction_data: &InstructionData,
) -> Result<Receipt, LeeError> {
// Write inputs to the program
let mut env_builder = ExecutorEnv::builder();
Program::write_inputs(
program.id(),
caller_program_id,
pre_states,
instruction_data,
&mut env_builder,
)?;
let env = env_builder.build().unwrap();
// Prove the program
let prover = default_prover();
Ok(prover
.prove(env, program.elf())
.map_err(|e| LeeError::ProgramProveFailed(e.to_string()))?
.receipt)
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,722 @@
#![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")]
use lee_core::{
Commitment, DUMMY_COMMITMENT_HASH, EncryptionScheme, EphemeralSecretKey, Nullifier,
PrivacyPreservingCircuitOutput, SharedSecretKey,
account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data},
program::{PdaSeed, PrivateAccountKind},
};
use super::*;
use crate::{
error::LeeError,
privacy_preserving_transaction::circuit::execute_and_prove,
program::Program,
state::{
CommitmentSet,
tests::{test_private_account_keys_1, test_private_account_keys_2},
},
};
fn decrypt_kind(
output: &PrivacyPreservingCircuitOutput,
ssk: &SharedSecretKey,
idx: usize,
) -> PrivateAccountKind {
let (kind, _) = EncryptionScheme::decrypt(
&output.encrypted_private_post_states[idx].ciphertext,
ssk,
&output.new_commitments[idx],
u32::try_from(idx).expect("idx fits in u32"),
)
.unwrap();
kind
}
#[test]
fn proof_inner_roundtrip() {
// `Proof::from_inner(b).into_inner()` must return exactly `b`. Catches
// mutations of `into_inner` returning `vec![]`, `vec![0]`, or `vec![1]`,
// and of `from_inner` discarding its argument.
let bytes = vec![0xDE_u8, 0xAD, 0xBE, 0xEF];
assert_eq!(Proof::from_inner(bytes.clone()).into_inner(), bytes);
assert!(Proof::from_inner(vec![]).into_inner().is_empty());
assert_eq!(Proof::from_inner(vec![0xFF]).into_inner(), vec![0xFF_u8]);
}
#[test]
fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts() {
let recipient_keys = test_private_account_keys_1();
let program = crate::test_methods::simple_balance_transfer();
let sender = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 100,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let recipient_account_id =
AccountId::for_regular_private_account(&recipient_keys.npk(), &recipient_keys.vpk(), 0);
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id);
let balance_to_move: u128 = 37;
let expected_sender_post = Account {
program_owner: program.id(),
balance: 100 - balance_to_move,
nonce: Nonce::default(),
data: Data::default(),
};
let expected_recipient_post = Account {
program_owner: program.id(),
balance: balance_to_move,
nonce: Nonce::private_account_nonce_init(&recipient_account_id),
data: Data::default(),
};
let expected_sender_pre = sender.clone();
let init_nonce = Nonce::private_account_nonce_init(&recipient_account_id);
let esk = EphemeralSecretKey::new(&recipient_account_id, &[0; 32], &init_nonce);
let shared_secret = SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &esk).0;
let (output, proof) = execute_and_prove(
vec![sender, recipient],
Program::serialize_instruction(balance_to_move).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
npk: recipient_keys.npk(),
identifier: 0,
commitment_root: DUMMY_COMMITMENT_HASH,
},
],
&crate::test_methods::simple_balance_transfer().into(),
)
.unwrap();
assert!(proof.is_valid_for(&output));
let [sender_pre] = output.public_pre_states.try_into().unwrap();
let [sender_post] = output.public_post_states.try_into().unwrap();
assert_eq!(sender_pre, expected_sender_pre);
assert_eq!(sender_post, expected_sender_post);
assert_eq!(output.new_commitments.len(), 1);
assert_eq!(output.new_nullifiers.len(), 1);
assert_eq!(output.encrypted_private_post_states.len(), 1);
let (_identifier, recipient_post) = EncryptionScheme::decrypt(
&output.encrypted_private_post_states[0].ciphertext,
&shared_secret,
&output.new_commitments[0],
0,
)
.unwrap();
assert_eq!(recipient_post, expected_recipient_post);
}
#[test]
fn prove_privacy_preserving_execution_circuit_fully_private() {
let program = crate::test_methods::simple_balance_transfer();
let sender_keys = test_private_account_keys_1();
let recipient_keys = test_private_account_keys_2();
let sender_nonce = Nonce(0xdead_beef);
let sender_pre = AccountWithMetadata::new(
Account {
balance: 100,
nonce: sender_nonce,
program_owner: program.id(),
data: Data::default(),
},
true,
AccountId::for_regular_private_account(&sender_keys.npk(), &sender_keys.vpk(), 0),
);
let sender_account_id =
AccountId::for_regular_private_account(&sender_keys.npk(), &sender_keys.vpk(), 0);
let commitment_sender = Commitment::new(&sender_account_id, &sender_pre.account);
let recipient_account_id =
AccountId::for_regular_private_account(&recipient_keys.npk(), &recipient_keys.vpk(), 0);
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id);
let balance_to_move: u128 = 37;
let mut commitment_set = CommitmentSet::with_capacity(2);
commitment_set.extend(std::slice::from_ref(&commitment_sender));
let expected_new_nullifiers = vec![
(
Nullifier::for_account_update(&commitment_sender, &sender_keys.nsk),
commitment_set.digest(),
),
(
Nullifier::for_account_initialization(&recipient_account_id),
DUMMY_COMMITMENT_HASH,
),
];
let program = crate::test_methods::simple_balance_transfer();
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),
..Default::default()
};
let expected_private_account_2 = Account {
program_owner: program.id(),
balance: balance_to_move,
nonce: Nonce::private_account_nonce_init(&recipient_account_id),
..Default::default()
};
let expected_new_commitments = vec![
Commitment::new(&sender_account_id, &expected_private_account_1),
Commitment::new(&recipient_account_id, &expected_private_account_2),
];
let esk_1 = EphemeralSecretKey::new(
&sender_account_id,
&[0; 32],
&sender_nonce.private_account_nonce_increment(&sender_keys.nsk),
);
let shared_secret_1 = SharedSecretKey::encapsulate_deterministic(&sender_keys.vpk(), &esk_1).0;
let init_nonce_2 = Nonce::private_account_nonce_init(&recipient_account_id);
let esk_2 = EphemeralSecretKey::new(&recipient_account_id, &[0; 32], &init_nonce_2);
let shared_secret_2 =
SharedSecretKey::encapsulate_deterministic(&recipient_keys.vpk(), &esk_2).0;
let (output, proof) = execute_and_prove(
vec![sender_pre, recipient],
Program::serialize_instruction(balance_to_move).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
nsk: sender_keys.nsk,
membership_proof: commitment_set
.get_proof_for(&commitment_sender)
.expect("sender's commitment must be in the set"),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
npk: recipient_keys.npk(),
identifier: 0,
commitment_root: DUMMY_COMMITMENT_HASH,
},
],
&program.into(),
)
.unwrap();
assert!(proof.is_valid_for(&output));
assert!(output.public_pre_states.is_empty());
assert!(output.public_post_states.is_empty());
assert_eq!(output.new_commitments, expected_new_commitments);
assert_eq!(output.new_nullifiers, expected_new_nullifiers);
assert_eq!(output.encrypted_private_post_states.len(), 2);
let (_identifier, sender_post) = EncryptionScheme::decrypt(
&output.encrypted_private_post_states[0].ciphertext,
&shared_secret_1,
&expected_new_commitments[0],
0,
)
.unwrap();
assert_eq!(sender_post, expected_private_account_1);
let (_identifier, recipient_post) = EncryptionScheme::decrypt(
&output.encrypted_private_post_states[1].ciphertext,
&shared_secret_2,
&expected_new_commitments[1],
1,
)
.unwrap();
assert_eq!(recipient_post, expected_private_account_2);
}
#[test]
fn circuit_fails_when_chained_validity_windows_have_empty_intersection() {
let account_keys = test_private_account_keys_1();
let pre = AccountWithMetadata::new(
Account::default(),
false,
AccountId::for_regular_private_account(&account_keys.npk(), &account_keys.vpk(), 0),
);
let validity_window_chain_caller = crate::test_methods::validity_window_chain_caller();
let validity_window = crate::test_methods::validity_window();
let instruction = Program::serialize_instruction((
Some(1_u64),
Some(4_u64),
validity_window.id(),
Some(4_u64),
Some(7_u64),
))
.unwrap();
let program_with_deps = ProgramWithDependencies::new(
validity_window_chain_caller,
[(validity_window.id(), validity_window)].into(),
);
let result = execute_and_prove(
vec![pre],
instruction,
vec![InputAccountIdentity::PrivateUnauthorized {
vpk: account_keys.vpk(),
random_seed: [0; 32],
npk: account_keys.npk(),
identifier: 0,
commitment_root: DUMMY_COMMITMENT_HASH,
}],
&program_with_deps,
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
/// A private PDA claimed with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Pda` carrying the correct `(program_id, seed, identifier)`.
#[test]
fn private_pda_claim_with_custom_identifier_encrypts_correct_kind() {
let program = crate::test_methods::pda_claimer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let identifier: u128 = 99;
let account_id =
AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), identifier);
let init_nonce = Nonce::private_account_nonce_init(&account_id);
let esk = EphemeralSecretKey::new(&account_id, &[0; 32], &init_nonce);
let shared_secret = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0;
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let (output, _proof) = execute_and_prove(
vec![pre_state],
Program::serialize_instruction(seed).unwrap(),
vec![InputAccountIdentity::PrivatePdaInit {
vpk: keys.vpk(),
random_seed: [0; 32],
npk,
identifier,
commitment_root: DUMMY_COMMITMENT_HASH,
seed: None,
}],
&program.clone().into(),
)
.unwrap();
assert_eq!(
decrypt_kind(&output, &shared_secret, 0),
PrivateAccountKind::Pda {
program_id: program.id(),
seed,
identifier
},
);
}
/// PDA init: initializes a new PDA under `simple_balance_transfer`'s ownership.
/// The `simple_transfer_proxy` program chains to `simple_balance_transfer` with `pda_seeds`
/// to establish authorization and the private PDA binding.
#[test]
fn private_pda_init() {
let program = crate::test_methods::simple_transfer_proxy();
let simple_transfer = crate::test_methods::simple_balance_transfer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
// PDA (new, private PDA)
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), 0);
let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id);
let auth_id = simple_transfer.id();
let program_with_deps =
ProgramWithDependencies::new(program, [(auth_id, simple_transfer)].into());
// is_withdraw=false triggers init path (1 pre-state)
let instruction = Program::serialize_instruction((seed, auth_id, 0_u128, false)).unwrap();
let result = execute_and_prove(
vec![pda_pre],
instruction,
vec![InputAccountIdentity::PrivatePdaInit {
vpk: keys.vpk(),
random_seed: [0; 32],
npk,
identifier: 0,
commitment_root: DUMMY_COMMITMENT_HASH,
seed: None,
}],
&program_with_deps,
);
let (output, _proof) = result.expect("PDA init should succeed");
assert_eq!(output.new_commitments.len(), 1);
}
/// PDA withdraw: chains to `simple_balance_transfer` to move balance from PDA to recipient.
/// Uses a default PDA (amount=0) because testing with a pre-funded PDA requires a
/// two-tx sequence with membership proofs.
#[test]
fn private_pda_withdraw() {
let program = crate::test_methods::simple_transfer_proxy();
let simple_transfer = crate::test_methods::simple_balance_transfer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
// PDA (new, private PDA)
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), 0);
let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id);
// Recipient (public)
let recipient_id = AccountId::new([88; 32]);
let recipient_pre = AccountWithMetadata::new(
Account {
program_owner: simple_transfer.id(),
balance: 10000,
..Account::default()
},
true,
recipient_id,
);
let auth_id = simple_transfer.id();
let program_with_deps =
ProgramWithDependencies::new(program, [(auth_id, simple_transfer)].into());
// is_withdraw=true, amount=0 (PDA has no balance yet)
let instruction = Program::serialize_instruction((seed, auth_id, 0_u128, true)).unwrap();
let result = execute_and_prove(
vec![pda_pre, recipient_pre],
instruction,
vec![
InputAccountIdentity::PrivatePdaInit {
vpk: keys.vpk(),
random_seed: [0; 32],
npk,
identifier: 0,
commitment_root: DUMMY_COMMITMENT_HASH,
seed: None,
},
InputAccountIdentity::Public,
],
&program_with_deps,
);
let (output, _proof) = result.expect("PDA withdraw should succeed");
assert_eq!(output.new_commitments.len(), 1);
}
/// Shared regular private account: receives funds via `authenticated_transfer` directly,
/// no custom program needed. This demonstrates the non-PDA shared account flow where
/// keys are derived from GMS via `derive_keys_for_shared_account`. The shared account
/// uses the standard unauthorized private account path and works with auth-transfer's
/// transfer path like any other private account.
#[test]
fn shared_account_receives_via_simple_transfer() {
let program = crate::test_methods::simple_balance_transfer();
let shared_keys = test_private_account_keys_1();
let shared_npk = shared_keys.npk();
let shared_identifier: u128 = 42;
// Sender: public account with balance, owned by auth-transfer
let sender_id = AccountId::new([99; 32]);
let sender = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 1000,
..Account::default()
},
true,
sender_id,
);
// Recipient: shared private account (new, unauthorized)
let shared_account_id = AccountId::from((&shared_npk, &shared_keys.vpk(), shared_identifier));
let recipient = AccountWithMetadata::new(Account::default(), false, shared_account_id);
let balance_to_move: u128 = 100;
let instruction = Program::serialize_instruction(balance_to_move).unwrap();
let result = execute_and_prove(
vec![sender, recipient],
instruction,
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
vpk: shared_keys.vpk(),
random_seed: [0; 32],
npk: shared_npk,
identifier: shared_identifier,
commitment_root: DUMMY_COMMITMENT_HASH,
},
],
&program.into(),
);
let (output, _proof) = result.expect("shared account receive should succeed");
// Sender is public (no commitment), recipient is private (1 commitment)
assert_eq!(output.new_commitments.len(), 1);
}
/// `PrivateAuthorizedInit` with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Regular` carrying the correct identifier.
#[test]
fn private_authorized_init_encrypts_regular_kind_with_identifier() {
let program = crate::test_methods::claimer();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier);
let esk = EphemeralSecretKey::new(
&account_id,
&[0; 32],
&Nonce::private_account_nonce_init(&account_id),
);
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0;
let pre = AccountWithMetadata::new(Account::default(), true, account_id);
let (output, _) = execute_and_prove(
vec![pre],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedInit {
vpk: keys.vpk(),
random_seed: [0; 32],
nsk: keys.nsk,
identifier,
commitment_root: DUMMY_COMMITMENT_HASH,
}],
&program.into(),
)
.unwrap();
assert_eq!(
decrypt_kind(&output, &ssk, 0),
PrivateAccountKind::Regular(identifier)
);
}
/// `PrivateUnauthorized` with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Regular` carrying the correct identifier.
#[test]
fn private_unauthorized_init_encrypts_regular_kind_with_identifier() {
let program = crate::test_methods::claimer();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let recipient_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier);
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 recipient = AccountWithMetadata::new(Account::default(), false, recipient_id);
let (output, _) = execute_and_prove(
vec![recipient],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::PrivateUnauthorized {
vpk: keys.vpk(),
random_seed: [0; 32],
npk: keys.npk(),
identifier,
commitment_root: DUMMY_COMMITMENT_HASH,
}],
&program.into(),
)
.unwrap();
assert_eq!(
decrypt_kind(&output, &ssk, 0),
PrivateAccountKind::Regular(identifier)
);
}
/// `PrivateAuthorizedUpdate` with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Regular` carrying the correct identifier.
#[test]
fn private_authorized_update_encrypts_regular_kind_with_identifier() {
let program = crate::test_methods::noop();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), identifier);
let esk = EphemeralSecretKey::new(
&account_id,
&[0; 32],
&Nonce::default().private_account_nonce_increment(&keys.nsk),
);
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0;
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 sender = AccountWithMetadata::new(account, true, account_id);
let (output, _) = execute_and_prove(
vec![sender],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: keys.vpk(),
random_seed: [0; 32],
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&commitment).unwrap(),
identifier,
}],
&program.into(),
)
.unwrap();
assert_eq!(
decrypt_kind(&output, &ssk, 0),
PrivateAccountKind::Regular(identifier)
);
}
/// `PrivatePdaUpdate` with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Pda` carrying the correct `(program_id, seed, identifier)`.
#[test]
fn private_pda_update_encrypts_pda_kind_with_identifier() {
let program = crate::test_methods::pda_spend_proxy();
let simple_transfer = crate::test_methods::simple_balance_transfer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let identifier: u128 = 99;
let simple_transfer_id = simple_transfer.id();
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), identifier);
let esk = EphemeralSecretKey::new(
&pda_id,
&[0; 32],
&Nonce::default().private_account_nonce_increment(&keys.nsk),
);
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0;
let pda_account = Account {
program_owner: simple_transfer_id,
balance: 1,
..Account::default()
};
let pda_commitment = Commitment::new(&pda_id, &pda_account);
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&pda_commitment));
let pda_pre = AccountWithMetadata::new(pda_account, true, pda_id);
let recipient_pre = AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32]));
let program_with_deps = ProgramWithDependencies::new(
program.clone(),
[(simple_transfer_id, simple_transfer)].into(),
);
let (output, _) = execute_and_prove(
vec![pda_pre, recipient_pre],
Program::serialize_instruction((seed, 1_u128, simple_transfer_id, false)).unwrap(),
vec![
InputAccountIdentity::PrivatePdaUpdate {
vpk: keys.vpk(),
random_seed: [0; 32],
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
identifier,
seed: None,
},
InputAccountIdentity::Public,
],
&program_with_deps,
)
.unwrap();
assert_eq!(
decrypt_kind(&output, &ssk, 0),
PrivateAccountKind::Pda {
program_id: program.id(),
seed,
identifier
},
);
}
#[test]
fn private_pda_init_identifier_mismatch_fails() {
let program = crate::test_methods::pda_claimer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), 5);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let result = execute_and_prove(
vec![pre_state],
Program::serialize_instruction(seed).unwrap(),
vec![InputAccountIdentity::PrivatePdaInit {
vpk: keys.vpk(),
random_seed: [0; 32],
npk,
identifier: 99,
commitment_root: DUMMY_COMMITMENT_HASH,
seed: None,
}],
&program.into(),
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn private_pda_update_identifier_mismatch_fails() {
let program = crate::test_methods::pda_spend_proxy();
let simple_transfer = crate::test_methods::simple_balance_transfer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let simple_transfer_id = simple_transfer.id();
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), 5);
let pda_account = Account {
program_owner: simple_transfer_id,
balance: 1,
..Account::default()
};
let pda_commitment = Commitment::new(&pda_id, &pda_account);
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&pda_commitment));
let pda_pre = AccountWithMetadata::new(pda_account, true, pda_id);
let recipient_pre = AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32]));
let program_with_deps =
ProgramWithDependencies::new(program, [(simple_transfer_id, simple_transfer)].into());
let result = execute_and_prove(
vec![pda_pre, recipient_pre],
Program::serialize_instruction((seed, 1_u128, simple_transfer_id, false)).unwrap(),
vec![
InputAccountIdentity::PrivatePdaUpdate {
vpk: keys.vpk(),
random_seed: [0; 32],
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
identifier: 99,
seed: None,
},
InputAccountIdentity::Public,
],
&program_with_deps,
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}

View File

@ -11,7 +11,7 @@ use crate::{AccountId, error::LeeError};
const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Privacy/\x00\x00\x00\x00\x00\x00";
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
#[derive(Clone, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct Message {
pub public_account_ids: Vec<AccountId>,
pub nonces: Vec<Nonce>,
@ -84,13 +84,27 @@ impl Message {
Sha256::digest(bytes).into()
}
/// Ensure that the commitments, nullifiers, and ciphertexts agree.
pub fn validate_note_lengths(&self) -> Result<usize, LeeError> {
let count = self.new_nullifiers.len();
if self.new_commitments.len() != count || self.encrypted_private_post_states.len() != count
{
return Err(LeeError::InvalidInput(format!(
"Note vectors disagree in length with {count} nullifiers, {} commitments, and {} ciphertexts",
self.new_commitments.len(),
self.encrypted_private_post_states.len(),
)));
}
Ok(count)
}
}
#[cfg(test)]
pub mod tests {
use lee_core::{
Commitment, EncryptionScheme, Nullifier, NullifierPublicKey, PrivateAccountKind,
SharedSecretKey,
Commitment, EncryptionScheme, EphemeralSecretKey, Nullifier, NullifierPublicKey,
PrivateAccountKind, SharedSecretKey,
account::{Account, AccountId, Nonce},
encryption::ViewingPublicKey,
program::{BlockValidityWindow, TimestampValidityWindow},
@ -109,6 +123,7 @@ pub mod tests {
let npk1 = NullifierPublicKey::from(&nsk1);
let npk2 = NullifierPublicKey::from(&nsk2);
let vpk = ViewingPublicKey::from_seed(&[7; 32], &[8; 32]);
let public_account_ids = vec![AccountId::new([1; 32])];
@ -118,10 +133,10 @@ pub mod tests {
let encrypted_private_post_states = Vec::new();
let account_id2 = lee_core::account::AccountId::for_regular_private_account(&npk2, 0);
let account_id2 = lee_core::account::AccountId::for_regular_private_account(&npk2, &vpk, 0);
let new_commitments = vec![Commitment::new(&account_id2, &account2)];
let account_id1 = lee_core::account::AccountId::for_regular_private_account(&npk1, 0);
let account_id1 = lee_core::account::AccountId::for_regular_private_account(&npk1, &vpk, 0);
let old_commitment = Commitment::new(&account_id1, &account1);
let new_nullifiers = vec![(
Nullifier::for_account_update(&old_commitment, &nsk1),
@ -140,6 +155,20 @@ pub mod tests {
}
}
#[test]
fn validate_note_lengths_accepts_matching_and_rejects_mismatched() {
assert_eq!(Message::default().validate_note_lengths().unwrap(), 0);
let mismatched = Message {
new_commitments: vec![Commitment::new(
&AccountId::new([0; 32]),
&Account::default(),
)],
..Default::default()
};
assert!(mismatched.validate_note_lengths().is_err());
}
#[test]
fn hash_privacy_pinned() {
let msg = Message {
@ -197,9 +226,10 @@ pub mod tests {
let npk = NullifierPublicKey::from(&[1; 32]);
let vpk = ViewingPublicKey::from_seed(&[2_u8; 32], &[3_u8; 32]);
let account = Account::default();
let account_id = lee_core::account::AccountId::for_regular_private_account(&npk, 0);
let account_id = lee_core::account::AccountId::for_regular_private_account(&npk, &vpk, 0);
let commitment = Commitment::new(&account_id, &account);
let (shared_secret, epk) = SharedSecretKey::encapsulate_deterministic(&vpk, &[0_u8; 32], 0);
let (shared_secret, epk) =
SharedSecretKey::encapsulate_deterministic(&vpk, &EphemeralSecretKey([0_u8; 32]));
let ciphertext = EncryptionScheme::encrypt(
&account,
&PrivateAccountKind::Regular(0),

View File

@ -111,42 +111,4 @@ impl Program {
}
#[cfg(test)]
mod tests {
use lee_core::account::{Account, AccountId, AccountWithMetadata};
use crate::program::Program;
#[test]
fn program_execution() {
let program = crate::test_methods::simple_balance_transfer();
let balance_to_move: u128 = 11_223_344_556_677;
let instruction_data = Program::serialize_instruction(balance_to_move).unwrap();
let sender = AccountWithMetadata::new(
Account {
balance: 77_665_544_332_211,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let recipient =
AccountWithMetadata::new(Account::default(), false, AccountId::new([1; 32]));
let expected_sender_post = Account {
balance: 77_665_544_332_211 - balance_to_move,
..Account::default()
};
let expected_recipient_post = Account {
balance: balance_to_move,
..Account::default()
};
let program_output = program
.execute(None, &[sender, recipient], &instruction_data)
.unwrap();
let [sender_post, recipient_post] = program_output.post_states.try_into().unwrap();
assert_eq!(sender_post.account(), &expected_sender_post);
assert_eq!(recipient_post.account(), &expected_recipient_post);
}
}
mod tests;

View File

@ -0,0 +1,36 @@
use lee_core::account::{Account, AccountId, AccountWithMetadata};
use crate::program::Program;
#[test]
fn program_execution() {
let program = crate::test_methods::simple_balance_transfer();
let balance_to_move: u128 = 11_223_344_556_677;
let instruction_data = Program::serialize_instruction(balance_to_move).unwrap();
let sender = AccountWithMetadata::new(
Account {
balance: 77_665_544_332_211,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let recipient = AccountWithMetadata::new(Account::default(), false, AccountId::new([1; 32]));
let expected_sender_post = Account {
balance: 77_665_544_332_211 - balance_to_move,
..Account::default()
};
let expected_recipient_post = Account {
balance: balance_to_move,
..Account::default()
};
let program_output = program
.execute(None, &[sender, recipient], &instruction_data)
.unwrap();
let [sender_post, recipient_post] = program_output.post_states.try_into().unwrap();
assert_eq!(sender_post.account(), &expected_sender_post);
assert_eq!(recipient_post.account(), &expected_recipient_post);
}

View File

@ -1,10 +1,18 @@
use borsh::{BorshDeserialize, BorshSerialize};
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
#[derive(Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
pub struct Message {
pub(crate) bytecode: Vec<u8>,
}
impl std::fmt::Debug for Message {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Message")
.field("bytecode", &format_args!("<{} bytes>", self.bytecode.len()))
.finish()
}
}
impl Message {
#[must_use]
pub const fn new(bytecode: Vec<u8>) -> Self {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,333 @@
use std::collections::{BTreeSet, HashMap, HashSet};
use borsh::{BorshDeserialize, BorshSerialize};
use lee_core::{
BlockId, Commitment, CommitmentSetDigest, DUMMY_COMMITMENT, MembershipProof, Nullifier,
Timestamp,
account::{Account, AccountId},
program::ProgramId,
};
use crate::{
error::LeeError,
merkle_tree::MerkleTree,
privacy_preserving_transaction::PrivacyPreservingTransaction,
program::Program,
program_deployment_transaction::ProgramDeploymentTransaction,
public_transaction::PublicTransaction,
validated_state_diff::{StateDiff, ValidatedStateDiff},
};
pub const MAX_NUMBER_CHAINED_CALLS: usize = 10;
#[derive(Clone, BorshSerialize, BorshDeserialize)]
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
pub struct CommitmentSet {
merkle_tree: MerkleTree,
commitments: HashMap<Commitment, usize>,
root_history: HashSet<CommitmentSetDigest>,
}
impl CommitmentSet {
pub(crate) fn digest(&self) -> CommitmentSetDigest {
self.merkle_tree.root()
}
/// Queries the `CommitmentSet` for a membership proof of commitment.
pub fn get_proof_for(&self, commitment: &Commitment) -> Option<MembershipProof> {
let index = *self.commitments.get(commitment)?;
self.merkle_tree
.get_authentication_path_for(index)
.map(|path| (index, path))
}
/// Inserts a list of commitments to the `CommitmentSet`.
pub(crate) fn extend(&mut self, commitments: &[Commitment]) {
for commitment in commitments.iter().cloned() {
let index = self.merkle_tree.insert(commitment.to_byte_array());
self.commitments.insert(commitment, index);
}
self.root_history.insert(self.digest());
}
fn contains(&self, commitment: &Commitment) -> bool {
self.commitments.contains_key(commitment)
}
/// Initializes an empty `CommitmentSet` with a given capacity.
/// If the capacity is not a `power_of_two`, then capacity is taken
/// to be the next `power_of_two`.
pub(crate) fn with_capacity(capacity: usize) -> Self {
Self {
merkle_tree: MerkleTree::with_capacity(capacity),
commitments: HashMap::new(),
root_history: HashSet::new(),
}
}
}
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
#[derive(Clone)]
struct NullifierSet(BTreeSet<Nullifier>);
impl NullifierSet {
const fn new() -> Self {
Self(BTreeSet::new())
}
fn extend(&mut self, new_nullifiers: &[Nullifier]) {
self.0.extend(new_nullifiers);
}
fn contains(&self, nullifier: &Nullifier) -> bool {
self.0.contains(nullifier)
}
}
impl BorshSerialize for NullifierSet {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
self.0.iter().collect::<Vec<_>>().serialize(writer)
}
}
impl BorshDeserialize for NullifierSet {
fn deserialize_reader<R: std::io::Read>(reader: &mut R) -> std::io::Result<Self> {
let vec = Vec::<Nullifier>::deserialize_reader(reader)?;
let mut set = BTreeSet::new();
for n in vec {
if !set.insert(n) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"duplicate nullifier in NullifierSet",
));
}
}
Ok(Self(set))
}
}
#[derive(Clone, BorshSerialize, BorshDeserialize)]
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
pub struct V03State {
public_state: HashMap<AccountId, Account>,
private_state: (CommitmentSet, NullifierSet),
programs: HashMap<ProgramId, Program>,
}
impl Default for V03State {
fn default() -> Self {
let mut commitment_set = CommitmentSet::with_capacity(32);
commitment_set.extend(&[DUMMY_COMMITMENT]);
let nullifier_set = NullifierSet::new();
let private_state = (commitment_set, nullifier_set);
Self {
public_state: HashMap::default(),
private_state,
programs: HashMap::default(),
}
}
}
impl V03State {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn commitment_root(&self) -> CommitmentSetDigest {
self.private_state.0.digest()
}
/// Initializes state with given public account balances leaving other account fields at their
/// default values.
#[must_use]
pub fn with_public_account_balances(
mut self,
balances: impl IntoIterator<Item = (AccountId, u128)>,
) -> Self {
let public_accounts = balances.into_iter().map(|(account_id, balance)| {
(
account_id,
Account {
balance,
..Account::default()
},
)
});
self.public_state.extend(public_accounts);
self
}
/// Initializes state with given public accounts.
#[must_use]
pub fn with_public_accounts(
mut self,
public_accounts: impl IntoIterator<Item = (AccountId, Account)>,
) -> Self {
self.public_state.extend(public_accounts);
self
}
/// Initializes state with given private accounts.
#[must_use]
pub fn with_private_accounts(
mut self,
private_accounts: impl IntoIterator<Item = (Commitment, Nullifier)>,
) -> Self {
let (commitments, nullifiers): (Vec<Commitment>, Vec<Nullifier>) =
private_accounts.into_iter().unzip();
self.private_state.0.extend(&commitments);
self.private_state.1.extend(&nullifiers);
self
}
/// Initializes state with given builtin programs.
#[must_use]
pub fn with_programs(mut self, programs: impl IntoIterator<Item = Program>) -> Self {
for program in programs {
self.insert_program(program);
}
self
}
pub(crate) fn insert_program(&mut self, program: Program) {
self.programs.insert(program.id(), program);
}
/// Seeds a single genesis account that is not produced by any transaction
/// (e.g. the cross-zone inbox config or a bridge-lock holding). Lets the
/// sequencer and indexer seed identical zone-specific state after building
/// the shared initial state.
pub fn insert_genesis_account(&mut self, account_id: AccountId, account: Account) {
self.public_state.insert(account_id, account);
}
pub fn apply_state_diff(&mut self, diff: ValidatedStateDiff) {
let StateDiff {
signer_account_ids,
public_diff,
new_commitments,
new_nullifiers,
program,
} = diff.into_state_diff();
#[expect(
clippy::iter_over_hash_type,
reason = "Iteration order doesn't matter here"
)]
for (account_id, account) in public_diff {
*self.get_account_by_id_mut(account_id) = account;
}
for account_id in signer_account_ids {
self.get_account_by_id_mut(account_id)
.nonce
.public_account_nonce_increment();
}
self.private_state.0.extend(&new_commitments);
self.private_state.1.extend(&new_nullifiers);
if let Some(program) = program {
self.insert_program(program);
}
}
pub fn transition_from_public_transaction(
&mut self,
tx: &PublicTransaction,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<(), LeeError> {
let diff = ValidatedStateDiff::from_public_transaction(tx, self, block_id, timestamp)?;
self.apply_state_diff(diff);
Ok(())
}
pub fn transition_from_privacy_preserving_transaction(
&mut self,
tx: &PrivacyPreservingTransaction,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<(), LeeError> {
let diff =
ValidatedStateDiff::from_privacy_preserving_transaction(tx, self, block_id, timestamp)?;
self.apply_state_diff(diff);
Ok(())
}
pub fn transition_from_program_deployment_transaction(
&mut self,
tx: &ProgramDeploymentTransaction,
) -> Result<(), LeeError> {
let diff = ValidatedStateDiff::from_program_deployment_transaction(tx, self)?;
self.apply_state_diff(diff);
Ok(())
}
fn get_account_by_id_mut(&mut self, account_id: AccountId) -> &mut Account {
self.public_state.entry(account_id).or_default()
}
#[must_use]
pub fn get_account_by_id(&self, account_id: AccountId) -> Account {
self.public_state
.get(&account_id)
.cloned()
.unwrap_or_else(Account::default)
}
#[must_use]
pub fn get_proof_for_commitment(&self, commitment: &Commitment) -> Option<MembershipProof> {
self.private_state.0.get_proof_for(commitment)
}
pub(crate) const fn programs(&self) -> &HashMap<ProgramId, Program> {
&self.programs
}
#[must_use]
pub fn commitment_set_digest(&self) -> CommitmentSetDigest {
self.private_state.0.digest()
}
pub(crate) fn check_commitments_are_new(
&self,
new_commitments: &[Commitment],
) -> Result<(), LeeError> {
for commitment in new_commitments {
if self.private_state.0.contains(commitment) {
return Err(LeeError::InvalidInput("Commitment already seen".to_owned()));
}
}
Ok(())
}
pub(crate) fn check_nullifiers_are_valid(
&self,
new_nullifiers: &[(Nullifier, CommitmentSetDigest)],
) -> Result<(), LeeError> {
for (nullifier, digest) in new_nullifiers {
if self.private_state.1.contains(nullifier) {
return Err(LeeError::InvalidInput("Nullifier already seen".to_owned()));
}
if !self.private_state.0.root_history.contains(digest) {
return Err(LeeError::InvalidInput(
"Unrecognized commitment set digest".to_owned(),
));
}
}
Ok(())
}
}
#[cfg(any(test, feature = "test-utils"))]
impl V03State {
pub fn force_insert_account(&mut self, account_id: AccountId, account: Account) {
self.public_state.insert(account_id, account);
}
}
#[cfg(test)]
pub mod tests;

View File

@ -0,0 +1,149 @@
use super::*;
#[test]
fn transition_from_authenticated_transfer_program_invocation_default_account_destination() {
let key = PrivateKey::try_new([1; 32]).unwrap();
let account_id = AccountId::from(&PublicKey::new_from_private_key(&key));
let initial_data = [(
account_id,
Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
balance: 100,
..Account::default()
},
)];
let mut state = V03State::new()
.with_public_accounts(initial_data)
.with_test_programs();
let from = account_id;
let to_key = PrivateKey::try_new([2; 32]).unwrap();
let to = AccountId::from(&PublicKey::new_from_private_key(&to_key));
assert_eq!(state.get_account_by_id(to), Account::default());
let balance_to_move = 5;
let tx = transfer_transaction(from, &key, 0, to, &to_key, 0, balance_to_move);
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
assert_eq!(state.get_account_by_id(from).balance, 95);
assert_eq!(state.get_account_by_id(to).balance, 5);
assert_eq!(state.get_account_by_id(from).nonce, Nonce(1));
assert_eq!(state.get_account_by_id(to).nonce, Nonce(1));
}
#[test]
fn transition_from_authenticated_transfer_program_invocation_insuficient_balance() {
let key = PrivateKey::try_new([1; 32]).unwrap();
let account_id = AccountId::from(&PublicKey::new_from_private_key(&key));
let mut state = V03State::new()
.with_public_account_balances([(account_id, 100)])
.with_test_programs();
let from = account_id;
let from_key = key;
let to_key = PrivateKey::try_new([2; 32]).unwrap();
let to = AccountId::from(&PublicKey::new_from_private_key(&to_key));
let balance_to_move = 101;
assert!(state.get_account_by_id(from).balance < balance_to_move);
let tx = transfer_transaction(from, &from_key, 0, to, &to_key, 0, balance_to_move);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(result, Err(LeeError::ProgramExecutionFailed(_))));
assert_eq!(state.get_account_by_id(from).balance, 100);
assert_eq!(state.get_account_by_id(to).balance, 0);
assert_eq!(state.get_account_by_id(from).nonce, Nonce(0));
assert_eq!(state.get_account_by_id(to).nonce, Nonce(0));
}
#[test]
fn transition_from_authenticated_transfer_program_invocation_non_default_account_destination() {
let key1 = PrivateKey::try_new([1; 32]).unwrap();
let key2 = PrivateKey::try_new([2; 32]).unwrap();
let account_id1 = AccountId::from(&PublicKey::new_from_private_key(&key1));
let account_id2 = AccountId::from(&PublicKey::new_from_private_key(&key2));
let initial_data = [
(
account_id1,
Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
balance: 100,
..Account::default()
},
),
(
account_id2,
Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
balance: 200,
..Account::default()
},
),
];
let mut state = V03State::new()
.with_public_accounts(initial_data)
.with_test_programs();
let from = account_id2;
let from_key = key2;
let to = account_id1;
let to_key = key1;
assert_ne!(state.get_account_by_id(to), Account::default());
let balance_to_move = 8;
let tx = transfer_transaction(from, &from_key, 0, to, &to_key, 0, balance_to_move);
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
assert_eq!(state.get_account_by_id(from).balance, 192);
assert_eq!(state.get_account_by_id(to).balance, 108);
assert_eq!(state.get_account_by_id(from).nonce, Nonce(1));
assert_eq!(state.get_account_by_id(to).nonce, Nonce(1));
}
#[test]
fn transition_from_sequence_of_authenticated_transfer_program_invocations() {
let key1 = PrivateKey::try_new([8; 32]).unwrap();
let account_id1 = AccountId::from(&PublicKey::new_from_private_key(&key1));
let key2 = PrivateKey::try_new([2; 32]).unwrap();
let account_id2 = AccountId::from(&PublicKey::new_from_private_key(&key2));
let initial_data = [(
account_id1,
Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
balance: 100,
..Account::default()
},
)];
let mut state = V03State::new()
.with_public_accounts(initial_data)
.with_test_programs();
let key3 = PrivateKey::try_new([3; 32]).unwrap();
let account_id3 = AccountId::from(&PublicKey::new_from_private_key(&key3));
let balance_to_move = 5;
let tx = transfer_transaction(
account_id1,
&key1,
0,
account_id2,
&key2,
0,
balance_to_move,
);
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
let balance_to_move = 3;
let tx = transfer_transaction(
account_id2,
&key2,
1,
account_id3,
&key3,
0,
balance_to_move,
);
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
assert_eq!(state.get_account_by_id(account_id1).balance, 95);
assert_eq!(state.get_account_by_id(account_id2).balance, 2);
assert_eq!(state.get_account_by_id(account_id3).balance, 3);
assert_eq!(state.get_account_by_id(account_id1).nonce, Nonce(1));
assert_eq!(state.get_account_by_id(account_id2).nonce, Nonce(2));
assert_eq!(state.get_account_by_id(account_id3).nonce, Nonce(1));
}

View File

@ -0,0 +1,116 @@
use super::*;
#[test]
fn public_changer_claimer_no_data_change_no_claim_succeeds() {
let initial_data = [];
let mut state = V03State::new()
.with_public_accounts(public_state_from_balances(&initial_data))
.with_test_programs();
let account_id = AccountId::new([1; 32]);
let program_id = crate::test_methods::changer_claimer().id();
// Don't change data (None) and don't claim (false)
let instruction: (Option<Vec<u8>>, bool) = (None, false);
let message =
public_transaction::Message::try_new(program_id, vec![account_id], vec![], instruction)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
// Should succeed - no changes made, no claim needed
assert!(result.is_ok());
// Account should remain default/unclaimed
assert_eq!(state.get_account_by_id(account_id), Account::default());
}
#[test]
fn public_changer_claimer_data_change_no_claim_fails() {
let initial_data = [];
let mut state = V03State::new()
.with_public_accounts(public_state_from_balances(&initial_data))
.with_test_programs();
let account_id = AccountId::new([1; 32]);
let program_id = crate::test_methods::changer_claimer().id();
// Change data but don't claim (false) - should fail
let new_data = vec![1, 2, 3, 4, 5];
let instruction: (Option<Vec<u8>>, bool) = (Some(new_data), false);
let message =
public_transaction::Message::try_new(program_id, vec![account_id], vec![], instruction)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
// Should fail - cannot modify data without claiming the account
assert!(matches!(
result,
Err(LeeError::InvalidProgramBehavior(
InvalidProgramBehaviorError::DefaultAccountModifiedWithoutClaim {
account_id: err_account_id
}
)) if err_account_id == account_id
));
}
#[test]
fn private_changer_claimer_no_data_change_no_claim_succeeds() {
let program = crate::test_methods::changer_claimer();
let sender_keys = test_private_account_keys_1();
let private_account = AccountWithMetadata::new(
Account::default(),
true,
(&sender_keys.npk(), &sender_keys.vpk(), 0),
);
// Don't change data (None) and don't claim (false)
let instruction: (Option<Vec<u8>>, bool) = (None, false);
let result = execute_and_prove(
vec![private_account],
Program::serialize_instruction(instruction).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
}],
&program.into(),
);
// Should succeed - no changes made, no claim needed
assert!(result.is_ok());
}
#[test]
fn private_changer_claimer_data_change_no_claim_fails() {
let program = crate::test_methods::changer_claimer();
let sender_keys = test_private_account_keys_1();
let private_account = AccountWithMetadata::new(
Account::default(),
true,
(&sender_keys.npk(), &sender_keys.vpk(), 0),
);
// Change data but don't claim (false) - should fail
let new_data = vec![1, 2, 3, 4, 5];
let instruction: (Option<Vec<u8>>, bool) = (Some(new_data), false);
let result = execute_and_prove(
vec![private_account],
Program::serialize_instruction(instruction).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
}],
&program.into(),
);
// Should fail - cannot modify data without claiming the account
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,607 @@
use super::*;
#[test]
fn claiming_mechanism() {
let program = crate::test_methods::simple_balance_transfer();
let from_key = PrivateKey::try_new([1; 32]).unwrap();
let from = AccountId::from(&PublicKey::new_from_private_key(&from_key));
let initial_balance = 100;
let initial_data = [(from, initial_balance)];
let mut state = V03State::new()
.with_public_accounts(public_state_from_balances(&initial_data))
.with_test_programs();
let to_key = PrivateKey::try_new([2; 32]).unwrap();
let to = AccountId::from(&PublicKey::new_from_private_key(&to_key));
let amount: u128 = 37;
// Check the recipient is an uninitialized account
assert_eq!(state.get_account_by_id(to), Account::default());
let expected_recipient_post = Account {
program_owner: program.id(),
balance: amount,
nonce: Nonce(1),
..Account::default()
};
let message = public_transaction::Message::try_new(
program.id(),
vec![from, to],
vec![Nonce(0), Nonce(0)],
amount,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&from_key, &to_key]);
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
let recipient_post = state.get_account_by_id(to);
assert_eq!(recipient_post, expected_recipient_post);
}
#[test]
fn unauthorized_public_account_claiming_fails() {
let program = crate::test_methods::simple_balance_transfer();
let account_key = PrivateKey::try_new([9; 32]).unwrap();
let account_id = AccountId::from(&PublicKey::new_from_private_key(&account_key));
let mut state = V03State::new().with_test_programs();
assert_eq!(state.get_account_by_id(account_id), Account::default());
let message =
public_transaction::Message::try_new(program.id(), vec![account_id], vec![], 0_u128)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 2, 0);
assert!(matches!(result, Err(LeeError::InvalidProgramBehavior(_))));
assert_eq!(state.get_account_by_id(account_id), Account::default());
}
#[test]
fn authorized_public_account_claiming_succeeds() {
let program = crate::test_methods::simple_balance_transfer();
let account_key = PrivateKey::try_new([10; 32]).unwrap();
let account_id = AccountId::from(&PublicKey::new_from_private_key(&account_key));
let mut state = V03State::new().with_test_programs();
assert_eq!(state.get_account_by_id(account_id), Account::default());
let message = public_transaction::Message::try_new(
program.id(),
vec![account_id],
vec![Nonce(0)],
0_u128,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&account_key]);
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
assert_eq!(
state.get_account_by_id(account_id),
Account {
program_owner: program.id(),
nonce: Nonce(1),
..Account::default()
}
);
}
#[test]
fn public_chained_call() {
let program = crate::test_methods::chain_caller();
let key = PrivateKey::try_new([1; 32]).unwrap();
let from = AccountId::from(&PublicKey::new_from_private_key(&key));
let to = AccountId::new([2; 32]);
let initial_balance = 1000;
let initial_data = [(from, initial_balance), (to, 0)];
let mut state = V03State::new()
.with_public_accounts(public_state_from_balances(&initial_data))
.with_test_programs();
let from_key = key;
let amount: u128 = 37;
let instruction: (u128, ProgramId, u32, Option<PdaSeed>) = (
amount,
crate::test_methods::simple_balance_transfer().id(),
2,
None,
);
let expected_to_post = Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
balance: amount * 2, // The `chain_caller` chains the program twice
..Account::default()
};
let message = public_transaction::Message::try_new(
program.id(),
vec![to, from], // The chain_caller program permutes the account order in the chain
// call
vec![Nonce(0)],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&from_key]);
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
let from_post = state.get_account_by_id(from);
let to_post = state.get_account_by_id(to);
// The `chain_caller` program calls the program twice
assert_eq!(from_post.balance, initial_balance - 2 * amount);
assert_eq!(to_post, expected_to_post);
}
#[test]
fn execution_fails_if_chained_calls_exceeds_depth() {
let program = crate::test_methods::chain_caller();
let key = PrivateKey::try_new([1; 32]).unwrap();
let from = AccountId::from(&PublicKey::new_from_private_key(&key));
let to = AccountId::new([2; 32]);
let initial_balance = 100;
let initial_data = [(from, initial_balance), (to, 0)];
let mut state = V03State::new()
.with_public_accounts(public_state_from_balances(&initial_data))
.with_test_programs();
let from_key = key;
let amount: u128 = 0;
let instruction: (u128, ProgramId, u32, Option<PdaSeed>) = (
amount,
crate::test_methods::simple_balance_transfer().id(),
u32::try_from(MAX_NUMBER_CHAINED_CALLS).expect("MAX_NUMBER_CHAINED_CALLS fits in u32") + 1,
None,
);
let message = public_transaction::Message::try_new(
program.id(),
vec![to, from], // The chain_caller program permutes the account order in the chain
// call
vec![Nonce(0)],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&from_key]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(
result,
Err(LeeError::MaxChainedCallsDepthExceeded)
));
}
#[test]
fn execution_that_requires_authentication_of_a_program_derived_account_id_succeeds() {
let chain_caller = crate::test_methods::chain_caller();
let pda_seed = PdaSeed::new([37; 32]);
let from = AccountId::for_public_pda(&chain_caller.id(), &pda_seed);
let to = AccountId::new([2; 32]);
let initial_balance = 1000;
let initial_data = [(from, initial_balance), (to, 0)];
let mut state = V03State::new()
.with_public_accounts(public_state_from_balances(&initial_data))
.with_test_programs();
let amount: u128 = 58;
let instruction: (u128, ProgramId, u32, Option<PdaSeed>) = (
amount,
crate::test_methods::simple_balance_transfer().id(),
1,
Some(pda_seed),
);
let expected_to_post = Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
balance: amount, // The `chain_caller` chains the program twice
..Account::default()
};
let message = public_transaction::Message::try_new(
chain_caller.id(),
vec![to, from], // The chain_caller program permutes the account order in the chain
// call
vec![],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
let from_post = state.get_account_by_id(from);
let to_post = state.get_account_by_id(to);
assert_eq!(from_post.balance, initial_balance - amount);
assert_eq!(to_post, expected_to_post);
}
#[test]
fn claiming_mechanism_within_chain_call() {
// This test calls the authenticated transfer program through the chain_caller program.
// The transfer is made from an initialized sender to an uninitialized recipient. And
// it is expected that the recipient account is claimed by the authenticated transfer
// program and not the chained_caller program.
let chain_caller = crate::test_methods::chain_caller();
let simple_transfer = crate::test_methods::simple_balance_transfer();
let from_key = PrivateKey::try_new([1; 32]).unwrap();
let from = AccountId::from(&PublicKey::new_from_private_key(&from_key));
let initial_balance = 100;
let initial_data = [(from, initial_balance)];
let mut state = V03State::new()
.with_public_accounts(public_state_from_balances(&initial_data))
.with_test_programs();
let to_key = PrivateKey::try_new([2; 32]).unwrap();
let to = AccountId::from(&PublicKey::new_from_private_key(&to_key));
let amount: u128 = 37;
// Check the recipient is an uninitialized account
assert_eq!(state.get_account_by_id(to), Account::default());
let expected_to_post = Account {
// The expected program owner is the authenticated transfer program
program_owner: simple_transfer.id(),
balance: amount,
nonce: Nonce(1),
..Account::default()
};
// The transaction executes the chain_caller program, which internally calls the
// authenticated_transfer program
let instruction: (u128, ProgramId, u32, Option<PdaSeed>) = (
amount,
crate::test_methods::simple_balance_transfer().id(),
1,
None,
);
let message = public_transaction::Message::try_new(
chain_caller.id(),
vec![to, from], // The chain_caller program permutes the account order in the chain
// call
vec![Nonce(0), Nonce(0)],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&from_key, &to_key]);
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
let from_post = state.get_account_by_id(from);
let to_post = state.get_account_by_id(to);
assert_eq!(from_post.balance, initial_balance - amount);
assert_eq!(to_post, expected_to_post);
}
#[test]
fn unauthorized_public_account_claiming_fails_when_executed_privately() {
let program = crate::test_methods::simple_balance_transfer();
let account_id = AccountId::new([11; 32]);
let public_account = AccountWithMetadata::new(Account::default(), false, account_id);
let result = execute_and_prove(
vec![public_account],
Program::serialize_instruction(0_u128).unwrap(),
vec![InputAccountIdentity::Public],
&program.into(),
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn authorized_public_account_claiming_succeeds_when_executed_privately() {
let program = crate::test_methods::simple_balance_transfer();
let program_id = program.id();
let sender_keys = test_private_account_keys_1();
let sender_private_account = Account {
program_owner: program_id,
balance: 100,
..Account::default()
};
let sender_account_id =
AccountId::for_regular_private_account(&sender_keys.npk(), &sender_keys.vpk(), 0);
let sender_commitment = Commitment::new(&sender_account_id, &sender_private_account);
let sender_init_nullifier = Nullifier::for_account_initialization(&sender_account_id);
let mut state =
V03State::new().with_private_accounts([(sender_commitment.clone(), sender_init_nullifier)]);
let sender_pre = AccountWithMetadata::new(
sender_private_account,
true,
(&sender_keys.npk(), &sender_keys.vpk(), 0),
);
let recipient_private_key = PrivateKey::try_new([2; 32]).unwrap();
let recipient_account_id =
AccountId::from(&PublicKey::new_from_private_key(&recipient_private_key));
let recipient_pre = AccountWithMetadata::new(Account::default(), true, recipient_account_id);
let balance = 37;
let (output, proof) = execute_and_prove(
vec![sender_pre, recipient_pre],
Program::serialize_instruction(balance).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
nsk: sender_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&sender_commitment)
.expect("sender's commitment must be in state"),
identifier: 0,
},
InputAccountIdentity::Public,
],
&program.into(),
)
.unwrap();
let message =
Message::try_from_circuit_output(vec![recipient_account_id], vec![Nonce(0)], output)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[&recipient_private_key]);
let tx = PrivacyPreservingTransaction::new(message, witness_set);
state
.transition_from_privacy_preserving_transaction(&tx, 1, 0)
.unwrap();
let nullifier = Nullifier::for_account_update(&sender_commitment, &sender_keys.nsk);
assert!(state.private_state.1.contains(&nullifier));
assert_eq!(
state.get_account_by_id(recipient_account_id),
Account {
program_owner: program_id,
balance,
nonce: Nonce(1),
..Account::default()
}
);
}
#[test_case::test_case(1; "single call")]
#[test_case::test_case(2; "two calls")]
fn private_chained_call(number_of_calls: u32) {
// Arrange
let chain_caller = crate::test_methods::chain_caller();
let simple_transfers = crate::test_methods::simple_balance_transfer();
let from_keys = test_private_account_keys_1();
let to_keys = test_private_account_keys_2();
let initial_balance = 100;
let from_account = AccountWithMetadata::new(
Account {
program_owner: simple_transfers.id(),
balance: initial_balance,
..Account::default()
},
true,
(&from_keys.npk(), &from_keys.vpk(), 0),
);
let to_account = AccountWithMetadata::new(
Account {
program_owner: simple_transfers.id(),
..Account::default()
},
true,
(&to_keys.npk(), &to_keys.vpk(), 0),
);
let from_account_id =
AccountId::for_regular_private_account(&from_keys.npk(), &from_keys.vpk(), 0);
let to_account_id = AccountId::for_regular_private_account(&to_keys.npk(), &to_keys.vpk(), 0);
let from_commitment = Commitment::new(&from_account_id, &from_account.account);
let to_commitment = Commitment::new(&to_account_id, &to_account.account);
let from_init_nullifier = Nullifier::for_account_initialization(&from_account_id);
let to_init_nullifier = Nullifier::for_account_initialization(&to_account_id);
let mut state = V03State::new()
.with_private_accounts([
(from_commitment.clone(), from_init_nullifier),
(to_commitment.clone(), to_init_nullifier),
])
.with_test_programs();
let amount: u128 = 37;
let instruction: (u128, ProgramId, u32, Option<PdaSeed>) = (
amount,
crate::test_methods::simple_balance_transfer().id(),
number_of_calls,
None,
);
let mut dependencies = HashMap::new();
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_expected_post = Account {
balance: initial_balance - u128::from(number_of_calls) * amount,
nonce: from_new_nonce,
..from_account.account.clone()
};
let from_expected_commitment = Commitment::new(&from_account_id, &from_expected_post);
let to_expected_post = Account {
balance: u128::from(number_of_calls) * amount,
nonce: to_new_nonce,
..to_account.account.clone()
};
let to_expected_commitment = Commitment::new(&to_account_id, &to_expected_post);
// Act
let (output, proof) = execute_and_prove(
vec![to_account, from_account],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: from_keys.vpk(),
random_seed: [0; 32],
nsk: from_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&from_commitment)
.expect("from's commitment must be in state"),
identifier: 0,
},
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: to_keys.vpk(),
random_seed: [0; 32],
nsk: to_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&to_commitment)
.expect("to's commitment must be in state"),
identifier: 0,
},
],
&program_with_deps,
)
.unwrap();
let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
let transaction = PrivacyPreservingTransaction::new(message, witness_set);
state
.transition_from_privacy_preserving_transaction(&transaction, 1, 0)
.unwrap();
// Assert
assert!(
state
.get_proof_for_commitment(&from_expected_commitment)
.is_some()
);
assert!(
state
.get_proof_for_commitment(&to_expected_commitment)
.is_some()
);
}
#[test]
fn claiming_mechanism_cannot_claim_initialied_accounts() {
let claimer = crate::test_methods::claimer();
let mut state = V03State::new().with_test_programs();
let account_id = AccountId::new([2; 32]);
// Insert an account with non-default program owner
state.force_insert_account(
account_id,
Account {
program_owner: [1, 2, 3, 4, 5, 6, 7, 8],
..Account::default()
},
);
let message =
public_transaction::Message::try_new(claimer.id(), vec![account_id], vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(
result,
Err(LeeError::InvalidProgramBehavior(
InvalidProgramBehaviorError::ClaimedNonDefaultAccount { account_id: err_account_id }
)) if err_account_id == account_id
));
}
/// This test ensures that even if a malicious program tries to perform overflow of balances
/// it will not be able to break the balance validation.
#[test]
fn malicious_program_cannot_break_balance_validation_if_not_in_genesis() {
let sender_key = PrivateKey::try_new([37; 32]).unwrap();
let sender_id = AccountId::from(&PublicKey::new_from_private_key(&sender_key));
let sender_init_balance: u128 = 10;
let recipient_key = PrivateKey::try_new([42; 32]).unwrap();
let recipient_id = AccountId::from(&PublicKey::new_from_private_key(&recipient_key));
let recipient_init_balance: u128 = 10;
let modified_transfer_id = crate::test_methods::modified_transfer_program().id();
let mut state = V03State::new()
.with_public_accounts([
(
sender_id,
Account {
program_owner: modified_transfer_id,
balance: sender_init_balance,
..Account::default()
},
),
(
recipient_id,
Account {
program_owner: modified_transfer_id,
balance: recipient_init_balance,
..Account::default()
},
),
])
.with_test_programs();
let balance_to_move: u128 = 4;
let sender = AccountWithMetadata::new(state.get_account_by_id(sender_id), true, sender_id);
let sender_nonce = sender.account.nonce;
let _recipient =
AccountWithMetadata::new(state.get_account_by_id(recipient_id), false, sender_id);
let message = public_transaction::Message::try_new(
modified_transfer_id,
vec![sender_id, recipient_id],
vec![sender_nonce],
balance_to_move,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&sender_key]);
let tx = PublicTransaction::new(message, witness_set);
let res = state.transition_from_public_transaction(&tx, 2, 0);
let expected_total_balance_pre_states =
WrappedBalanceSum::from_balances([sender_init_balance, recipient_init_balance].into_iter())
.unwrap();
let expected_total_balance_post_states = WrappedBalanceSum::from_balances(
[sender_init_balance, recipient_init_balance, u128::MAX, 1].into_iter(),
)
.unwrap();
assert!(matches!(
res,
Err(LeeError::InvalidProgramBehavior(
InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states }
)
)) if total_balance_pre_states == expected_total_balance_pre_states && total_balance_post_states == expected_total_balance_post_states
));
let sender_post = state.get_account_by_id(sender_id);
let recipient_post = state.get_account_by_id(recipient_id);
let expected_sender_post = {
let mut this = state.get_account_by_id(sender_id);
this.balance = sender_init_balance;
this.nonce = Nonce(0);
this
};
let expected_recipient_post = {
let mut this = state.get_account_by_id(sender_id);
this.balance = recipient_init_balance;
this.nonce = Nonce(0);
this
};
assert_eq!(expected_sender_post, sender_post);
assert_eq!(expected_recipient_post, recipient_post);
}

View File

@ -0,0 +1,235 @@
use super::*;
#[test]
fn flash_swap_successful() {
let initiator = crate::test_methods::flash_swap_initiator();
let callback = crate::test_methods::flash_swap_callback();
let token = crate::test_methods::simple_balance_transfer();
let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32]));
let receiver_id = AccountId::for_public_pda(&callback.id(), &PdaSeed::new([1_u8; 32]));
let initial_balance: u128 = 1000;
let amount_out: u128 = 100;
let vault_account = Account {
program_owner: token.id(),
balance: initial_balance,
..Account::default()
};
let receiver_account = Account {
program_owner: token.id(),
balance: 0,
..Account::default()
};
let mut state = V03State::new().with_test_programs();
state.force_insert_account(vault_id, vault_account);
state.force_insert_account(receiver_id, receiver_account);
// Callback instruction: return funds
let cb_instruction = CallbackInstruction {
return_funds: true,
token_program_id: token.id(),
amount: amount_out,
};
let cb_data = Program::serialize_instruction(cb_instruction).unwrap();
let instruction = FlashSwapInstruction::Initiate {
token_program_id: token.id(),
callback_program_id: callback.id(),
amount_out,
callback_instruction_data: cb_data,
};
let tx = build_flash_swap_tx(&initiator, vault_id, receiver_id, instruction);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(result.is_ok(), "flash swap should succeed: {result:?}");
// Vault balance restored, receiver back to 0
assert_eq!(state.get_account_by_id(vault_id).balance, initial_balance);
assert_eq!(state.get_account_by_id(receiver_id).balance, 0);
}
#[test]
fn flash_swap_callback_keeps_funds_rollback() {
let initiator = crate::test_methods::flash_swap_initiator();
let callback = crate::test_methods::flash_swap_callback();
let token = crate::test_methods::simple_balance_transfer();
let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32]));
let receiver_id = AccountId::for_public_pda(&callback.id(), &PdaSeed::new([1_u8; 32]));
let initial_balance: u128 = 1000;
let amount_out: u128 = 100;
let vault_account = Account {
program_owner: token.id(),
balance: initial_balance,
..Account::default()
};
let receiver_account = Account {
program_owner: token.id(),
balance: 0,
..Account::default()
};
let mut state = V03State::new().with_test_programs();
state.force_insert_account(vault_id, vault_account);
state.force_insert_account(receiver_id, receiver_account);
// Callback instruction: do NOT return funds
let cb_instruction = CallbackInstruction {
return_funds: false,
token_program_id: token.id(),
amount: amount_out,
};
let cb_data = Program::serialize_instruction(cb_instruction).unwrap();
let instruction = FlashSwapInstruction::Initiate {
token_program_id: token.id(),
callback_program_id: callback.id(),
amount_out,
callback_instruction_data: cb_data,
};
let tx = build_flash_swap_tx(&initiator, vault_id, receiver_id, instruction);
let result = state.transition_from_public_transaction(&tx, 1, 0);
// Invariant check fails → entire tx rolls back
assert!(
result.is_err(),
"flash swap should fail when callback keeps funds"
);
// State unchanged (rollback)
assert_eq!(state.get_account_by_id(vault_id).balance, initial_balance);
assert_eq!(state.get_account_by_id(receiver_id).balance, 0);
}
#[test]
fn flash_swap_self_call_targets_correct_program() {
// Zero-amount flash swap: the invariant self-call still runs and succeeds
// because vault balance doesn't decrease.
let initiator = crate::test_methods::flash_swap_initiator();
let callback = crate::test_methods::flash_swap_callback();
let token = crate::test_methods::simple_balance_transfer();
let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32]));
let receiver_id = AccountId::for_public_pda(&callback.id(), &PdaSeed::new([1_u8; 32]));
let initial_balance: u128 = 1000;
let vault_account = Account {
program_owner: token.id(),
balance: initial_balance,
..Account::default()
};
let receiver_account = Account {
program_owner: token.id(),
balance: 0,
..Account::default()
};
let mut state = V03State::new().with_test_programs();
state.force_insert_account(vault_id, vault_account);
state.force_insert_account(receiver_id, receiver_account);
let cb_instruction = CallbackInstruction {
return_funds: true,
token_program_id: token.id(),
amount: 0,
};
let cb_data = Program::serialize_instruction(cb_instruction).unwrap();
let instruction = FlashSwapInstruction::Initiate {
token_program_id: token.id(),
callback_program_id: callback.id(),
amount_out: 0,
callback_instruction_data: cb_data,
};
let tx = build_flash_swap_tx(&initiator, vault_id, receiver_id, instruction);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(
result.is_ok(),
"zero-amount flash swap should succeed: {result:?}"
);
}
#[test]
fn flash_swap_standalone_invariant_check_rejected() {
// Calling InvariantCheck directly (not as a chained self-call) should fail
// because caller_program_id will be None.
let initiator = crate::test_methods::flash_swap_initiator();
let token = crate::test_methods::simple_balance_transfer();
let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32]));
let vault_account = Account {
program_owner: token.id(),
balance: 1000,
..Account::default()
};
let mut state = V03State::new().with_test_programs();
state.force_insert_account(vault_id, vault_account);
let instruction = FlashSwapInstruction::InvariantCheck {
min_vault_balance: 1000,
};
let message =
public_transaction::Message::try_new(initiator.id(), vec![vault_id], vec![], instruction)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(
result.is_err(),
"standalone InvariantCheck should be rejected (caller_program_id is None)"
);
}
#[test]
fn malicious_self_program_id_rejected_in_public_execution() {
let program = crate::test_methods::malicious_self_program_id();
let acc_id = AccountId::new([99; 32]);
let account = Account::default();
let mut state = V03State::new().with_test_programs();
state.force_insert_account(acc_id, account);
let message =
public_transaction::Message::try_new(program.id(), vec![acc_id], vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(
result.is_err(),
"program with wrong self_program_id in output should be rejected"
);
}
#[test]
fn malicious_caller_program_id_rejected_in_public_execution() {
let program = crate::test_methods::malicious_caller_program_id();
let acc_id = AccountId::new([99; 32]);
let account = Account::default();
let mut state = V03State::new().with_test_programs();
state.force_insert_account(acc_id, account);
let message =
public_transaction::Message::try_new(program.id(), vec![acc_id], vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(
result.is_err(),
"program with spoofed caller_program_id in output should be rejected"
);
}

View File

@ -0,0 +1,128 @@
use super::*;
#[test]
fn new_works() {
let key1 = PrivateKey::try_new([1; 32]).unwrap();
let key2 = PrivateKey::try_new([2; 32]).unwrap();
let addr1 = AccountId::from(&PublicKey::new_from_private_key(&key1));
let addr2 = AccountId::from(&PublicKey::new_from_private_key(&key2));
let expected_public_state = {
let mut this = HashMap::new();
this.insert(
addr1,
Account {
balance: 100,
..Account::default()
},
);
this.insert(
addr2,
Account {
balance: 151,
..Account::default()
},
);
this
};
let expected_builtin_programs = HashMap::new();
let state =
V03State::new().with_public_account_balances([(addr1, 100_u128), (addr2, 151_u128)]);
assert_eq!(state.public_state, expected_public_state);
assert_eq!(state.programs, expected_builtin_programs);
}
#[test]
fn new_includes_nullifiers_for_private_accounts() {
let keys1 = test_private_account_keys_1();
let keys2 = test_private_account_keys_2();
let account = Account {
balance: 100,
..Account::default()
};
let account_id1 = AccountId::for_regular_private_account(&keys1.npk(), &keys1.vpk(), 0);
let account_id2 = AccountId::for_regular_private_account(&keys2.npk(), &keys2.vpk(), 0);
let init_commitment1 = Commitment::new(&account_id1, &account);
let init_commitment2 = Commitment::new(&account_id2, &account);
let init_nullifier1 = Nullifier::for_account_initialization(&account_id1);
let init_nullifier2 = Nullifier::for_account_initialization(&account_id2);
let initial_private_accounts = vec![
(init_commitment1, init_nullifier1),
(init_commitment2, init_nullifier2),
];
let state = V03State::new().with_private_accounts(initial_private_accounts);
assert!(state.private_state.1.contains(&init_nullifier1));
assert!(state.private_state.1.contains(&init_nullifier2));
}
#[test]
fn insert_program() {
let mut state = V03State::new();
let program_to_insert = crate::test_methods::simple_balance_transfer();
let program_id = program_to_insert.id();
assert!(!state.programs.contains_key(&program_id));
state.insert_program(program_to_insert);
assert!(state.programs.contains_key(&program_id));
}
#[test]
fn get_account_by_account_id_non_default_account() {
let key = PrivateKey::try_new([1; 32]).unwrap();
let account_id = AccountId::from(&PublicKey::new_from_private_key(&key));
let initial_data = [(
account_id,
Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
balance: 100,
..Account::default()
},
)];
let state = V03State::new().with_public_accounts(initial_data);
let expected_account = &state.public_state[&account_id];
let account = state.get_account_by_id(account_id);
assert_eq!(&account, expected_account);
}
#[test]
fn get_account_by_account_id_default_account() {
let addr2 = AccountId::new([0; 32]);
let state = V03State::new();
let expected_account = Account::default();
let account = state.get_account_by_id(addr2);
assert_eq!(account, expected_account);
}
#[test]
fn builtin_programs_getter() {
let state = V03State::new();
let builtin_programs = state.programs();
assert_eq!(builtin_programs, &state.programs);
}
#[test]
fn state_serialization_roundtrip() {
let account_id_1 = AccountId::new([1; 32]);
let account_id_2 = AccountId::new([2; 32]);
let initial_data = [(account_id_1, 100_u128), (account_id_2, 151_u128)];
let state = V03State::new()
.with_public_accounts(public_state_from_balances(&initial_data))
.with_test_programs();
let bytes = borsh::to_vec(&state).unwrap();
let state_from_bytes: V03State = borsh::from_slice(&bytes).unwrap();
assert_eq!(state, state_from_bytes);
}

View File

@ -0,0 +1,425 @@
#![expect(
clippy::arithmetic_side_effects,
clippy::shadow_unrelated,
reason = "We don't care about it in tests"
)]
use std::collections::HashMap;
use lee_core::{
BlockId, Commitment, DUMMY_COMMITMENT_HASH, InputAccountIdentity, Nullifier,
NullifierPublicKey, NullifierSecretKey, Timestamp,
account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data},
encryption::ViewingPublicKey,
program::{
BlockValidityWindow, ExecutionValidationError, MAX_NUMBER_CHAINED_CALLS, PdaSeed,
ProgramId, TimestampValidityWindow, WrappedBalanceSum,
},
};
use crate::{
PublicKey, PublicTransaction, V03State,
error::{InvalidProgramBehaviorError, LeeError},
execute_and_prove,
privacy_preserving_transaction::{
PrivacyPreservingTransaction, circuit::ProgramWithDependencies, message::Message,
witness_set::WitnessSet,
},
program::Program,
public_transaction,
signature::PrivateKey,
};
mod authenticated_transfer;
mod changer_claimer;
mod circuit;
mod claiming;
mod flash_swap;
mod genesis;
mod privacy_preserving;
mod public_program_rules;
mod validity_window;
impl V03State {
/// Include test programs in the builtin programs map.
#[must_use]
pub fn with_test_programs(mut self) -> Self {
self.insert_program(crate::test_methods::simple_balance_transfer());
self.insert_program(crate::test_methods::nonce_changer());
self.insert_program(crate::test_methods::extra_output());
self.insert_program(crate::test_methods::missing_output());
self.insert_program(crate::test_methods::program_owner_changer());
self.insert_program(crate::test_methods::data_changer());
self.insert_program(crate::test_methods::minter());
self.insert_program(crate::test_methods::burner());
self.insert_program(crate::test_methods::auth_asserting_noop());
self.insert_program(crate::test_methods::private_pda_delegator());
self.insert_program(crate::test_methods::pda_claimer());
self.insert_program(crate::test_methods::two_pda_claimer());
self.insert_program(crate::test_methods::noop());
self.insert_program(crate::test_methods::chain_caller());
self.insert_program(crate::test_methods::modified_transfer_program());
self.insert_program(crate::test_methods::malicious_authorization_changer());
self.insert_program(crate::test_methods::validity_window());
self.insert_program(crate::test_methods::flash_swap_initiator());
self.insert_program(crate::test_methods::flash_swap_callback());
self.insert_program(crate::test_methods::malicious_self_program_id());
self.insert_program(crate::test_methods::malicious_caller_program_id());
self.insert_program(crate::test_methods::pda_spend_proxy());
self.insert_program(crate::test_methods::claimer());
self.insert_program(crate::test_methods::changer_claimer());
self.insert_program(crate::test_methods::validity_window_chain_caller());
self.insert_program(crate::test_methods::simple_transfer_proxy());
self.insert_program(crate::test_methods::malicious_injector());
self.insert_program(crate::test_methods::malicious_launderer());
self.insert_program(crate::test_methods::modified_transfer_program());
self
}
#[must_use]
pub fn with_non_default_accounts_but_default_program_owners(mut self) -> Self {
let account_with_default_values_except_balance = Account {
balance: 100,
..Account::default()
};
let account_with_default_values_except_nonce = Account {
nonce: Nonce(37),
..Account::default()
};
let account_with_default_values_except_data = Account {
data: vec![0xca, 0xfe].try_into().unwrap(),
..Account::default()
};
self.force_insert_account(
AccountId::new([255; 32]),
account_with_default_values_except_balance,
);
self.force_insert_account(
AccountId::new([254; 32]),
account_with_default_values_except_nonce,
);
self.force_insert_account(
AccountId::new([253; 32]),
account_with_default_values_except_data,
);
self
}
#[must_use]
pub fn with_account_owned_by_burner_program(mut self) -> Self {
let account = Account {
program_owner: crate::test_methods::burner().id(),
balance: 100,
..Default::default()
};
self.force_insert_account(AccountId::new([252; 32]), account);
self
}
#[must_use]
pub fn with_private_account(mut self, keys: &TestPrivateKeys, account: &Account) -> Self {
let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), 0);
let commitment = Commitment::new(&account_id, account);
self.private_state.0.extend(&[commitment]);
self
}
}
pub struct TestPublicKeys {
pub signing_key: PrivateKey,
}
impl TestPublicKeys {
pub fn account_id(&self) -> AccountId {
AccountId::from(&PublicKey::new_from_private_key(&self.signing_key))
}
}
pub struct TestPrivateKeys {
pub nsk: NullifierSecretKey,
pub d: [u8; 32],
pub z: [u8; 32],
}
impl TestPrivateKeys {
pub fn npk(&self) -> NullifierPublicKey {
NullifierPublicKey::from(&self.nsk)
}
pub fn vpk(&self) -> ViewingPublicKey {
ViewingPublicKey::from_seed(&self.d, &self.z)
}
}
// ── Flash Swap types (mirrors of guest types for host-side serialisation) ──
#[derive(serde::Serialize, serde::Deserialize)]
struct CallbackInstruction {
return_funds: bool,
token_program_id: ProgramId,
amount: u128,
}
#[derive(serde::Serialize, serde::Deserialize)]
enum FlashSwapInstruction {
Initiate {
token_program_id: ProgramId,
callback_program_id: ProgramId,
amount_out: u128,
callback_instruction_data: Vec<u32>,
},
InvariantCheck {
min_vault_balance: u128,
},
}
fn public_state_from_balances(initial_data: &[(AccountId, u128)]) -> HashMap<AccountId, Account> {
initial_data
.iter()
.copied()
.map(|(account_id, balance)| {
(
account_id,
Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
balance,
..Account::default()
},
)
})
.collect()
}
fn transfer_transaction(
from: AccountId,
from_key: &PrivateKey,
from_nonce: u128,
to: AccountId,
to_key: &PrivateKey,
to_nonce: u128,
balance: u128,
) -> PublicTransaction {
let account_ids = vec![from, to];
let nonces = vec![Nonce(from_nonce), Nonce(to_nonce)];
let program_id = crate::test_methods::simple_balance_transfer().id();
let message =
public_transaction::Message::try_new(program_id, account_ids, nonces, balance).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[from_key, to_key]);
PublicTransaction::new(message, witness_set)
}
fn build_flash_swap_tx(
initiator: &Program,
vault_id: AccountId,
receiver_id: AccountId,
instruction: FlashSwapInstruction,
) -> PublicTransaction {
let message = public_transaction::Message::try_new(
initiator.id(),
vec![vault_id, receiver_id],
vec![], // no signers — vault is PDA-authorised
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
PublicTransaction::new(message, witness_set)
}
fn test_public_account_keys_1() -> TestPublicKeys {
TestPublicKeys {
signing_key: PrivateKey::try_new([37; 32]).unwrap(),
}
}
fn test_public_account_keys_2() -> TestPublicKeys {
TestPublicKeys {
signing_key: PrivateKey::try_new([38; 32]).unwrap(),
}
}
pub fn test_private_account_keys_1() -> TestPrivateKeys {
TestPrivateKeys {
nsk: [13; 32],
d: [31; 32],
z: [32; 32],
}
}
pub fn test_private_account_keys_2() -> TestPrivateKeys {
TestPrivateKeys {
nsk: [38; 32],
d: [83; 32],
z: [84; 32],
}
}
fn shielded_balance_transfer_for_tests(
sender_keys: &TestPublicKeys,
recipient_keys: &TestPrivateKeys,
balance_to_move: u128,
state: &V03State,
) -> PrivacyPreservingTransaction {
let sender = AccountWithMetadata::new(
state.get_account_by_id(sender_keys.account_id()),
true,
sender_keys.account_id(),
);
let sender_nonce = sender.account.nonce;
let recipient = AccountWithMetadata::new(
Account::default(),
false,
(&recipient_keys.npk(), &recipient_keys.vpk(), 0),
);
let (output, proof) = crate::privacy_preserving_transaction::circuit::execute_and_prove(
vec![sender, recipient],
Program::serialize_instruction(balance_to_move).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
npk: recipient_keys.npk(),
identifier: 0,
commitment_root: DUMMY_COMMITMENT_HASH,
},
],
&crate::test_methods::simple_balance_transfer().into(),
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![sender_keys.account_id()],
vec![sender_nonce],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[&sender_keys.signing_key]);
PrivacyPreservingTransaction::new(message, witness_set)
}
fn private_balance_transfer_for_tests(
sender_keys: &TestPrivateKeys,
sender_private_account: &Account,
recipient_keys: &TestPrivateKeys,
balance_to_move: u128,
state: &V03State,
) -> PrivacyPreservingTransaction {
let program = crate::test_methods::simple_balance_transfer();
let sender_account_id =
AccountId::for_regular_private_account(&sender_keys.npk(), &sender_keys.vpk(), 0);
let sender_commitment = Commitment::new(&sender_account_id, sender_private_account);
let sender_pre = AccountWithMetadata::new(
sender_private_account.clone(),
true,
(&sender_keys.npk(), &sender_keys.vpk(), 0),
);
let recipient_pre = AccountWithMetadata::new(
Account::default(),
false,
(&recipient_keys.npk(), &recipient_keys.vpk(), 0),
);
let (output, proof) = crate::privacy_preserving_transaction::circuit::execute_and_prove(
vec![sender_pre, recipient_pre],
Program::serialize_instruction(balance_to_move).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
nsk: sender_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&sender_commitment)
.expect("sender's commitment must be in state"),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
npk: recipient_keys.npk(),
identifier: 0,
commitment_root: DUMMY_COMMITMENT_HASH,
},
],
&program.into(),
)
.unwrap();
let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
PrivacyPreservingTransaction::new(message, witness_set)
}
fn deshielded_balance_transfer_for_tests(
sender_keys: &TestPrivateKeys,
sender_private_account: &Account,
recipient_account_id: &AccountId,
balance_to_move: u128,
state: &V03State,
) -> PrivacyPreservingTransaction {
let program = crate::test_methods::simple_balance_transfer();
let sender_account_id =
AccountId::for_regular_private_account(&sender_keys.npk(), &sender_keys.vpk(), 0);
let sender_commitment = Commitment::new(&sender_account_id, sender_private_account);
let sender_pre = AccountWithMetadata::new(
sender_private_account.clone(),
true,
(&sender_keys.npk(), &sender_keys.vpk(), 0),
);
let recipient_pre = AccountWithMetadata::new(
state.get_account_by_id(*recipient_account_id),
false,
*recipient_account_id,
);
let (output, proof) = crate::privacy_preserving_transaction::circuit::execute_and_prove(
vec![sender_pre, recipient_pre],
Program::serialize_instruction(balance_to_move).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: sender_keys.vpk(),
random_seed: [0; 32],
nsk: sender_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&sender_commitment)
.expect("sender's commitment must be in state"),
identifier: 0,
},
InputAccountIdentity::Public,
],
&program.into(),
)
.unwrap();
let message =
Message::try_from_circuit_output(vec![*recipient_account_id], vec![], output).unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
PrivacyPreservingTransaction::new(message, witness_set)
}
fn valid_private_transfer_tx_and_state() -> (V03State, PrivacyPreservingTransaction) {
let sender_keys = test_private_account_keys_1();
let sender_private_account = Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
balance: 100,
nonce: Nonce(0xdead_beef),
..Account::default()
};
let recipient_keys = test_private_account_keys_2();
let state = V03State::new().with_private_account(&sender_keys, &sender_private_account);
let tx = private_balance_transfer_for_tests(
&sender_keys,
&sender_private_account,
&recipient_keys,
37,
&state,
);
(state, tx)
}

View File

@ -0,0 +1,539 @@
use super::*;
#[test]
fn transition_from_privacy_preserving_transaction_shielded() {
let sender_keys = test_public_account_keys_1();
let recipient_keys = test_private_account_keys_1();
let mut state = V03State::new().with_public_accounts([(
sender_keys.account_id(),
Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
balance: 200,
..Account::default()
},
)]);
let balance_to_move = 37;
let tx =
shielded_balance_transfer_for_tests(&sender_keys, &recipient_keys, balance_to_move, &state);
let expected_sender_post = {
let mut this = state.get_account_by_id(sender_keys.account_id());
this.balance -= balance_to_move;
this.nonce.public_account_nonce_increment();
this
};
let [expected_new_commitment] = tx.message().new_commitments.clone().try_into().unwrap();
assert!(!state.private_state.0.contains(&expected_new_commitment));
state
.transition_from_privacy_preserving_transaction(&tx, 1, 0)
.unwrap();
let sender_post = state.get_account_by_id(sender_keys.account_id());
assert_eq!(sender_post, expected_sender_post);
assert!(state.private_state.0.contains(&expected_new_commitment));
assert_eq!(
state.get_account_by_id(sender_keys.account_id()).balance,
200 - balance_to_move
);
}
#[test]
fn transition_from_privacy_preserving_transaction_private() {
let sender_keys = test_private_account_keys_1();
let sender_nonce = Nonce(0xdead_beef);
let sender_private_account = Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
balance: 100,
nonce: sender_nonce,
data: Data::default(),
};
let recipient_keys = test_private_account_keys_2();
let mut state = V03State::new().with_private_account(&sender_keys, &sender_private_account);
let balance_to_move = 37;
let tx = private_balance_transfer_for_tests(
&sender_keys,
&sender_private_account,
&recipient_keys,
balance_to_move,
&state,
);
let sender_account_id =
AccountId::for_regular_private_account(&sender_keys.npk(), &sender_keys.vpk(), 0);
let recipient_account_id =
AccountId::for_regular_private_account(&recipient_keys.npk(), &recipient_keys.vpk(), 0);
let expected_new_commitment_1 = Commitment::new(
&sender_account_id,
&Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk),
balance: sender_private_account.balance - balance_to_move,
data: Data::default(),
},
);
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);
let expected_new_commitment_2 = Commitment::new(
&recipient_account_id,
&Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
nonce: Nonce::private_account_nonce_init(&recipient_account_id),
balance: balance_to_move,
..Account::default()
},
);
let previous_public_state = state.public_state.clone();
assert!(state.private_state.0.contains(&sender_pre_commitment));
assert!(!state.private_state.0.contains(&expected_new_commitment_1));
assert!(!state.private_state.0.contains(&expected_new_commitment_2));
assert!(!state.private_state.1.contains(&expected_new_nullifier));
state
.transition_from_privacy_preserving_transaction(&tx, 1, 0)
.unwrap();
assert_eq!(state.public_state, previous_public_state);
assert!(state.private_state.0.contains(&sender_pre_commitment));
assert!(state.private_state.0.contains(&expected_new_commitment_1));
assert!(state.private_state.0.contains(&expected_new_commitment_2));
assert!(state.private_state.1.contains(&expected_new_nullifier));
}
/// After a valid fully-private tx is proven, tampering with a note's epk should
/// make the shielding proof invalid.
#[test]
fn privacy_tampered_epk_is_rejected() {
use crate::validated_state_diff::ValidatedStateDiff;
let (state, mut tx) = valid_private_transfer_tx_and_state();
// Baseline: the untampered tx verifies
assert!(
ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0).is_ok(),
"the unmodified private transfer must verify"
);
// Flip a byte of the first note's epk
tx.message.encrypted_private_post_states[0].epk.0[0] ^= 0xFF;
assert!(
matches!(
ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0),
Err(LeeError::InvalidPrivacyPreservingProof)
),
"a tampered epk must be rejected by proof verification"
);
}
/// After a valid fully-private tx is proven, tampering with a note's view tag should
/// make the shielding proof invalid.
#[test]
fn privacy_tampered_view_tag_is_rejected() {
use crate::validated_state_diff::ValidatedStateDiff;
let (state, mut tx) = valid_private_transfer_tx_and_state();
// Baseline: the untampered tx verifies.
assert!(
ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0).is_ok(),
"the unmodified private transfer must verify"
);
// Flip the first note's view_tag
tx.message.encrypted_private_post_states[0].view_tag ^= 0xFF;
assert!(
matches!(
ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0),
Err(LeeError::InvalidPrivacyPreservingProof)
),
"a tampered view_tag must be rejected by proof verification"
);
}
#[test]
fn transition_from_privacy_preserving_transaction_deshielded() {
let sender_keys = test_private_account_keys_1();
let sender_nonce = Nonce(0xdead_beef);
let sender_private_account = Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
balance: 100,
nonce: sender_nonce,
data: Data::default(),
};
let recipient_keys = test_public_account_keys_1();
let recipient_initial_balance = 400;
let mut state = V03State::new()
.with_public_accounts([(
recipient_keys.account_id(),
Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
balance: recipient_initial_balance,
..Account::default()
},
)])
.with_private_account(&sender_keys, &sender_private_account);
let balance_to_move = 37;
let expected_recipient_post = {
let mut this = state.get_account_by_id(recipient_keys.account_id());
this.balance += balance_to_move;
this
};
let tx = deshielded_balance_transfer_for_tests(
&sender_keys,
&sender_private_account,
&recipient_keys.account_id(),
balance_to_move,
&state,
);
let sender_account_id =
AccountId::for_regular_private_account(&sender_keys.npk(), &sender_keys.vpk(), 0);
let expected_new_commitment = Commitment::new(
&sender_account_id,
&Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk),
balance: sender_private_account.balance - balance_to_move,
data: Data::default(),
},
);
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);
assert!(state.private_state.0.contains(&sender_pre_commitment));
assert!(!state.private_state.0.contains(&expected_new_commitment));
assert!(!state.private_state.1.contains(&expected_new_nullifier));
state
.transition_from_privacy_preserving_transaction(&tx, 1, 0)
.unwrap();
let recipient_post = state.get_account_by_id(recipient_keys.account_id());
assert_eq!(recipient_post, expected_recipient_post);
assert!(state.private_state.0.contains(&sender_pre_commitment));
assert!(state.private_state.0.contains(&expected_new_commitment));
assert!(state.private_state.1.contains(&expected_new_nullifier));
assert_eq!(
state.get_account_by_id(recipient_keys.account_id()).balance,
recipient_initial_balance + balance_to_move
);
}
#[test]
fn burner_program_should_fail_in_privacy_preserving_circuit() {
let program = crate::test_methods::burner();
let public_account = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 100,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let result = execute_and_prove(
vec![public_account],
Program::serialize_instruction(10_u128).unwrap(),
vec![InputAccountIdentity::Public],
&program.into(),
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn minter_program_should_fail_in_privacy_preserving_circuit() {
let program = crate::test_methods::minter();
let public_account = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 0,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let result = execute_and_prove(
vec![public_account],
Program::serialize_instruction(10_u128).unwrap(),
vec![InputAccountIdentity::Public],
&program.into(),
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn nonce_changer_program_should_fail_in_privacy_preserving_circuit() {
let program = crate::test_methods::nonce_changer();
let public_account = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 0,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let result = execute_and_prove(
vec![public_account],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::Public],
&program.into(),
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn data_changer_program_should_fail_for_non_owned_account_in_privacy_preserving_circuit() {
let program = crate::test_methods::data_changer();
let public_account = AccountWithMetadata::new(
Account {
program_owner: [0, 1, 2, 3, 4, 5, 6, 7],
balance: 0,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let result = execute_and_prove(
vec![public_account],
Program::serialize_instruction(vec![0]).unwrap(),
vec![InputAccountIdentity::Public],
&program.into(),
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn data_changer_program_should_fail_for_too_large_data_in_privacy_preserving_circuit() {
let program = crate::test_methods::data_changer();
let public_account = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 0,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let large_data: Vec<u8> =
vec![
0;
usize::try_from(lee_core::account::data::DATA_MAX_LENGTH.as_u64())
.expect("DATA_MAX_LENGTH fits in usize")
+ 1
];
let result = execute_and_prove(
vec![public_account],
Program::serialize_instruction(large_data).unwrap(),
vec![InputAccountIdentity::Public],
&program.into(),
);
assert!(matches!(result, Err(LeeError::ProgramProveFailed(_))));
}
#[test]
fn extra_output_program_should_fail_in_privacy_preserving_circuit() {
let program = crate::test_methods::extra_output();
let public_account = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 0,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let result = execute_and_prove(
vec![public_account],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::Public],
&program.into(),
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn missing_output_program_should_fail_in_privacy_preserving_circuit() {
let program = crate::test_methods::missing_output();
let public_account_1 = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 0,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let public_account_2 = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 0,
..Account::default()
},
true,
AccountId::new([1; 32]),
);
let result = execute_and_prove(
vec![public_account_1, public_account_2],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::Public, InputAccountIdentity::Public],
&program.into(),
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn program_owner_changer_should_fail_in_privacy_preserving_circuit() {
let program = crate::test_methods::program_owner_changer();
let public_account = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 0,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let result = execute_and_prove(
vec![public_account],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::Public],
&program.into(),
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn transfer_from_non_owned_account_should_fail_in_privacy_preserving_circuit() {
let program = crate::test_methods::simple_balance_transfer();
let public_account_1 = AccountWithMetadata::new(
Account {
program_owner: [0, 1, 2, 3, 4, 5, 6, 7],
balance: 100,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let public_account_2 = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 0,
..Account::default()
},
true,
AccountId::new([1; 32]),
);
let result = execute_and_prove(
vec![public_account_1, public_account_2],
Program::serialize_instruction(10_u128).unwrap(),
vec![InputAccountIdentity::Public, InputAccountIdentity::Public],
&program.into(),
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn malicious_authorization_changer_should_fail_in_privacy_preserving_circuit() {
// Arrange
let malicious_program = crate::test_methods::malicious_authorization_changer();
let simple_transfers = crate::test_methods::simple_balance_transfer();
let sender_keys = test_public_account_keys_1();
let recipient_keys = test_private_account_keys_1();
let sender_account = AccountWithMetadata::new(
Account {
program_owner: simple_transfers.id(),
balance: 100,
..Default::default()
},
false,
sender_keys.account_id(),
);
let recipient_account = AccountWithMetadata::new(
Account::default(),
true,
(&recipient_keys.npk(), &recipient_keys.vpk(), 0),
);
let recipient_account_id =
AccountId::for_regular_private_account(&recipient_keys.npk(), &recipient_keys.vpk(), 0);
let recipient_commitment = Commitment::new(&recipient_account_id, &recipient_account.account);
let recipient_init_nullifier = Nullifier::for_account_initialization(&recipient_account_id);
let state = V03State::new()
.with_public_accounts(public_state_from_balances(&[(
sender_account.account_id,
sender_account.account.balance,
)]))
.with_private_accounts([(recipient_commitment.clone(), recipient_init_nullifier)])
.with_test_programs();
let balance_to_transfer = 10_u128;
let instruction = (balance_to_transfer, simple_transfers.id());
let mut dependencies = HashMap::new();
dependencies.insert(simple_transfers.id(), simple_transfers);
let program_with_deps = ProgramWithDependencies::new(malicious_program, dependencies);
// Act - execute the malicious program - this should fail during proving
let result = execute_and_prove(
vec![sender_account, recipient_account],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: recipient_keys.vpk(),
random_seed: [0; 32],
nsk: recipient_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&recipient_commitment)
.expect("recipient's commitment must be in state"),
identifier: 0,
},
],
&program_with_deps,
);
// Assert - should fail because the malicious program tries to manipulate is_authorized
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}

View File

@ -0,0 +1,325 @@
use super::*;
#[test]
fn program_should_fail_if_modifies_nonces() {
let account_id = AccountId::new([1; 32]);
let mut state = V03State::new()
.with_public_account_balances([(account_id, 100)])
.with_test_programs();
let account_ids = vec![account_id];
let program_id = crate::test_methods::nonce_changer().id();
let message =
public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(
result,
Err(LeeError::InvalidProgramBehavior(
InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::ModifiedNonce { account_id: err_account_id }
)
)) if err_account_id == account_id
));
}
#[test]
fn program_should_fail_if_output_accounts_exceed_inputs() {
let mut state = V03State::new()
.with_public_account_balances([(AccountId::new([1; 32]), 0)])
.with_test_programs();
let account_ids = vec![AccountId::new([1; 32])];
let program_id = crate::test_methods::extra_output().id();
let message =
public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(
result,
Err(LeeError::InvalidProgramBehavior(
InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::MismatchedPreStatePostStateLength {
pre_state_length,
post_state_length
}
)
)) if pre_state_length == 1 && post_state_length == 2
));
}
#[test]
fn program_should_fail_with_missing_output_accounts() {
let mut state = V03State::new()
.with_public_account_balances([(AccountId::new([1; 32]), 100)])
.with_test_programs();
let account_ids = vec![AccountId::new([1; 32]), AccountId::new([2; 32])];
let program_id = crate::test_methods::missing_output().id();
let message =
public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(
result,
Err(LeeError::InvalidProgramBehavior(
InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::MismatchedPreStatePostStateLength {
pre_state_length,
post_state_length
}
)
)) if pre_state_length == 2 && post_state_length == 1
));
}
#[test]
fn program_should_fail_if_modifies_program_owner_with_only_non_default_program_owner() {
let initial_data = [(
AccountId::new([1; 32]),
Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
..Account::default()
},
)];
let mut state = V03State::new()
.with_public_accounts(initial_data)
.with_test_programs();
let account_id = AccountId::new([1; 32]);
let account = state.get_account_by_id(account_id);
// Assert the target account only differs from the default account in the program owner
// field
assert_ne!(account.program_owner, Account::default().program_owner);
assert_eq!(account.balance, Account::default().balance);
assert_eq!(account.nonce, Account::default().nonce);
assert_eq!(account.data, Account::default().data);
let program_id = crate::test_methods::program_owner_changer().id();
let message =
public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(
result,
Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id }
))) if err_account_id == account_id
));
}
#[test]
fn program_should_fail_if_modifies_program_owner_with_only_non_default_balance() {
let initial_data = HashMap::new();
let mut state = V03State::new()
.with_public_accounts(initial_data)
.with_test_programs()
.with_non_default_accounts_but_default_program_owners();
let account_id = AccountId::new([255; 32]);
let account = state.get_account_by_id(account_id);
// Assert the target account only differs from the default account in balance field
assert_eq!(account.program_owner, Account::default().program_owner);
assert_ne!(account.balance, Account::default().balance);
assert_eq!(account.nonce, Account::default().nonce);
assert_eq!(account.data, Account::default().data);
let program_id = crate::test_methods::program_owner_changer().id();
let message =
public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(
result,
Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id }
))) if err_account_id == account_id
));
}
#[test]
fn program_should_fail_if_modifies_program_owner_with_only_non_default_nonce() {
let initial_data = HashMap::new();
let mut state = V03State::new()
.with_public_accounts(initial_data)
.with_test_programs()
.with_non_default_accounts_but_default_program_owners();
let account_id = AccountId::new([254; 32]);
let account = state.get_account_by_id(account_id);
// Assert the target account only differs from the default account in nonce field
assert_eq!(account.program_owner, Account::default().program_owner);
assert_eq!(account.balance, Account::default().balance);
assert_ne!(account.nonce, Account::default().nonce);
assert_eq!(account.data, Account::default().data);
let program_id = crate::test_methods::program_owner_changer().id();
let message =
public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(
result,
Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id }
))) if err_account_id == account_id
));
}
#[test]
fn program_should_fail_if_modifies_program_owner_with_only_non_default_data() {
let initial_data = HashMap::new();
let mut state = V03State::new()
.with_public_accounts(initial_data)
.with_test_programs()
.with_non_default_accounts_but_default_program_owners();
let account_id = AccountId::new([253; 32]);
let account = state.get_account_by_id(account_id);
// Assert the target account only differs from the default account in data field
assert_eq!(account.program_owner, Account::default().program_owner);
assert_eq!(account.balance, Account::default().balance);
assert_eq!(account.nonce, Account::default().nonce);
assert_ne!(account.data, Account::default().data);
let program_id = crate::test_methods::program_owner_changer().id();
let message =
public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(
result,
Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id }
))) if err_account_id == account_id
));
}
#[test]
fn program_should_fail_if_transfers_balance_from_non_owned_account() {
let sender_account_id = AccountId::new([1; 32]);
let receiver_account_id = AccountId::new([2; 32]);
let mut state = V03State::new()
.with_public_account_balances([(sender_account_id, 100)])
.with_test_programs();
let balance_to_move: u128 = 1;
let program_id = crate::test_methods::simple_balance_transfer().id();
assert_ne!(
state.get_account_by_id(sender_account_id).program_owner,
program_id
);
let message = public_transaction::Message::try_new(
program_id,
vec![sender_account_id, receiver_account_id],
vec![],
balance_to_move,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(
result,
Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::UnauthorizedBalanceDecrease { account_id: err_account_id, owner_program_id, executing_program_id }
))) if err_account_id == sender_account_id && owner_program_id != program_id && executing_program_id == program_id
));
}
#[test]
fn program_should_fail_if_modifies_data_of_non_owned_account() {
let initial_data = HashMap::new();
let mut state = V03State::new()
.with_public_accounts(initial_data)
.with_test_programs()
.with_non_default_accounts_but_default_program_owners();
let account_id = AccountId::new([255; 32]);
let program_id = crate::test_methods::data_changer().id();
assert_ne!(state.get_account_by_id(account_id), Account::default());
assert_ne!(
state.get_account_by_id(account_id).program_owner,
program_id
);
let message =
public_transaction::Message::try_new(program_id, vec![account_id], vec![], vec![0])
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(
result,
Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::UnauthorizedDataModification { account_id: err_account_id, executing_program_id }
))) if err_account_id == account_id && executing_program_id == program_id
));
}
#[test]
fn program_should_fail_if_does_not_preserve_total_balance_by_minting() {
let initial_data = HashMap::new();
let mut state = V03State::new()
.with_public_accounts(initial_data)
.with_test_programs();
let account_id = AccountId::new([1; 32]);
let program_id = crate::test_methods::minter().id();
let message =
public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 2, 0);
assert!(matches!(
result,
Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states }
))) if total_balance_pre_states == 0.into() && total_balance_post_states == 1.into()
));
}
#[test]
fn program_should_fail_if_does_not_preserve_total_balance_by_burning() {
let initial_data = HashMap::new();
let mut state = V03State::new()
.with_public_accounts(initial_data)
.with_test_programs()
.with_account_owned_by_burner_program();
let program_id = crate::test_methods::burner().id();
let account_id = AccountId::new([252; 32]);
assert_eq!(
state.get_account_by_id(account_id).program_owner,
program_id
);
let balance_to_burn: u128 = 1;
assert!(state.get_account_by_id(account_id).balance > balance_to_burn);
let message =
public_transaction::Message::try_new(program_id, vec![account_id], vec![], balance_to_burn)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 2, 0);
assert!(matches!(
result,
Err(LeeError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states }
))) if total_balance_pre_states == 100.into() && total_balance_post_states == 99.into()
));
}

View File

@ -0,0 +1,237 @@
use super::*;
#[test_case::test_case((Some(1), Some(3)), 3; "at upper bound")]
#[test_case::test_case((Some(1), Some(3)), 2; "inside range")]
#[test_case::test_case((Some(1), Some(3)), 0; "below range")]
#[test_case::test_case((Some(1), Some(3)), 1; "at lower bound")]
#[test_case::test_case((Some(1), Some(3)), 4; "above range")]
#[test_case::test_case((Some(1), None), 1; "lower bound only - at bound")]
#[test_case::test_case((Some(1), None), 10; "lower bound only - above")]
#[test_case::test_case((Some(1), None), 0; "lower bound only - below")]
#[test_case::test_case((None, Some(3)), 3; "upper bound only - at bound")]
#[test_case::test_case((None, Some(3)), 0; "upper bound only - below")]
#[test_case::test_case((None, Some(3)), 4; "upper bound only - above")]
#[test_case::test_case((None, None), 0; "no bounds - always valid")]
#[test_case::test_case((None, None), 100; "no bounds - always valid 2")]
fn validity_window_works_in_public_transactions(
validity_window: (Option<BlockId>, Option<BlockId>),
block_id: BlockId,
) {
let block_validity_window: BlockValidityWindow = validity_window.try_into().unwrap();
let validity_window_program = crate::test_methods::validity_window();
let account_keys = test_public_account_keys_1();
let pre = AccountWithMetadata::new(Account::default(), false, account_keys.account_id());
let mut state = V03State::new().with_test_programs();
let tx = {
let account_ids = vec![pre.account_id];
let nonces = vec![];
let program_id = validity_window_program.id();
let instruction = (
block_validity_window,
TimestampValidityWindow::new_unbounded(),
);
let message =
public_transaction::Message::try_new(program_id, account_ids, nonces, instruction)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
PublicTransaction::new(message, witness_set)
};
let result = state.transition_from_public_transaction(&tx, block_id, 0);
let is_inside_validity_window =
match (block_validity_window.start(), block_validity_window.end()) {
(Some(s), Some(e)) => s <= block_id && block_id < e,
(Some(s), None) => s <= block_id,
(None, Some(e)) => block_id < e,
(None, None) => true,
};
if is_inside_validity_window {
assert!(result.is_ok());
} else {
assert!(matches!(result, Err(LeeError::OutOfValidityWindow)));
}
}
#[test_case::test_case((Some(1), Some(3)), 3; "at upper bound")]
#[test_case::test_case((Some(1), Some(3)), 2; "inside range")]
#[test_case::test_case((Some(1), Some(3)), 0; "below range")]
#[test_case::test_case((Some(1), Some(3)), 1; "at lower bound")]
#[test_case::test_case((Some(1), Some(3)), 4; "above range")]
#[test_case::test_case((Some(1), None), 1; "lower bound only - at bound")]
#[test_case::test_case((Some(1), None), 10; "lower bound only - above")]
#[test_case::test_case((Some(1), None), 0; "lower bound only - below")]
#[test_case::test_case((None, Some(3)), 3; "upper bound only - at bound")]
#[test_case::test_case((None, Some(3)), 0; "upper bound only - below")]
#[test_case::test_case((None, Some(3)), 4; "upper bound only - above")]
#[test_case::test_case((None, None), 0; "no bounds - always valid")]
#[test_case::test_case((None, None), 100; "no bounds - always valid 2")]
fn timestamp_validity_window_works_in_public_transactions(
validity_window: (Option<Timestamp>, Option<Timestamp>),
timestamp: Timestamp,
) {
let timestamp_validity_window: TimestampValidityWindow = validity_window.try_into().unwrap();
let validity_window_program = crate::test_methods::validity_window();
let account_keys = test_public_account_keys_1();
let pre = AccountWithMetadata::new(Account::default(), false, account_keys.account_id());
let mut state = V03State::new().with_test_programs();
let tx = {
let account_ids = vec![pre.account_id];
let nonces = vec![];
let program_id = validity_window_program.id();
let instruction = (
BlockValidityWindow::new_unbounded(),
timestamp_validity_window,
);
let message =
public_transaction::Message::try_new(program_id, account_ids, nonces, instruction)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
PublicTransaction::new(message, witness_set)
};
let result = state.transition_from_public_transaction(&tx, 1, timestamp);
let is_inside_validity_window = match (
timestamp_validity_window.start(),
timestamp_validity_window.end(),
) {
(Some(s), Some(e)) => s <= timestamp && timestamp < e,
(Some(s), None) => s <= timestamp,
(None, Some(e)) => timestamp < e,
(None, None) => true,
};
if is_inside_validity_window {
assert!(result.is_ok());
} else {
assert!(matches!(result, Err(LeeError::OutOfValidityWindow)));
}
}
#[test_case::test_case((Some(1), Some(3)), 3; "at upper bound")]
#[test_case::test_case((Some(1), Some(3)), 2; "inside range")]
#[test_case::test_case((Some(1), Some(3)), 0; "below range")]
#[test_case::test_case((Some(1), Some(3)), 1; "at lower bound")]
#[test_case::test_case((Some(1), Some(3)), 4; "above range")]
#[test_case::test_case((Some(1), None), 1; "lower bound only - at bound")]
#[test_case::test_case((Some(1), None), 10; "lower bound only - above")]
#[test_case::test_case((Some(1), None), 0; "lower bound only - below")]
#[test_case::test_case((None, Some(3)), 3; "upper bound only - at bound")]
#[test_case::test_case((None, Some(3)), 0; "upper bound only - below")]
#[test_case::test_case((None, Some(3)), 4; "upper bound only - above")]
#[test_case::test_case((None, None), 0; "no bounds - always valid")]
#[test_case::test_case((None, None), 100; "no bounds - always valid 2")]
fn validity_window_works_in_privacy_preserving_transactions(
validity_window: (Option<BlockId>, Option<BlockId>),
block_id: BlockId,
) {
let block_validity_window: BlockValidityWindow = validity_window.try_into().unwrap();
let validity_window_program = crate::test_methods::validity_window();
let account_keys = test_private_account_keys_1();
let pre = AccountWithMetadata::new(
Account::default(),
false,
(&account_keys.npk(), &account_keys.vpk(), 0),
);
let mut state = V03State::new().with_test_programs();
let tx = {
let instruction = (
block_validity_window,
TimestampValidityWindow::new_unbounded(),
);
let (output, proof) = crate::privacy_preserving_transaction::circuit::execute_and_prove(
vec![pre],
Program::serialize_instruction(instruction).unwrap(),
vec![InputAccountIdentity::PrivateUnauthorized {
vpk: account_keys.vpk(),
random_seed: [0; 32],
npk: account_keys.npk(),
identifier: 0,
commitment_root: DUMMY_COMMITMENT_HASH,
}],
&validity_window_program.into(),
)
.unwrap();
let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
PrivacyPreservingTransaction::new(message, witness_set)
};
let result = state.transition_from_privacy_preserving_transaction(&tx, block_id, 0);
let is_inside_validity_window =
match (block_validity_window.start(), block_validity_window.end()) {
(Some(s), Some(e)) => s <= block_id && block_id < e,
(Some(s), None) => s <= block_id,
(None, Some(e)) => block_id < e,
(None, None) => true,
};
if is_inside_validity_window {
assert!(result.is_ok());
} else {
assert!(matches!(result, Err(LeeError::OutOfValidityWindow)));
}
}
#[test_case::test_case((Some(1), Some(3)), 3; "at upper bound")]
#[test_case::test_case((Some(1), Some(3)), 2; "inside range")]
#[test_case::test_case((Some(1), Some(3)), 0; "below range")]
#[test_case::test_case((Some(1), Some(3)), 1; "at lower bound")]
#[test_case::test_case((Some(1), Some(3)), 4; "above range")]
#[test_case::test_case((Some(1), None), 1; "lower bound only - at bound")]
#[test_case::test_case((Some(1), None), 10; "lower bound only - above")]
#[test_case::test_case((Some(1), None), 0; "lower bound only - below")]
#[test_case::test_case((None, Some(3)), 3; "upper bound only - at bound")]
#[test_case::test_case((None, Some(3)), 0; "upper bound only - below")]
#[test_case::test_case((None, Some(3)), 4; "upper bound only - above")]
#[test_case::test_case((None, None), 0; "no bounds - always valid")]
#[test_case::test_case((None, None), 100; "no bounds - always valid 2")]
fn timestamp_validity_window_works_in_privacy_preserving_transactions(
validity_window: (Option<Timestamp>, Option<Timestamp>),
timestamp: Timestamp,
) {
let timestamp_validity_window: TimestampValidityWindow = validity_window.try_into().unwrap();
let validity_window_program = crate::test_methods::validity_window();
let account_keys = test_private_account_keys_1();
let pre = AccountWithMetadata::new(
Account::default(),
false,
(&account_keys.npk(), &account_keys.vpk(), 0),
);
let mut state = V03State::new().with_test_programs();
let tx = {
let instruction = (
BlockValidityWindow::new_unbounded(),
timestamp_validity_window,
);
let (output, proof) = crate::privacy_preserving_transaction::circuit::execute_and_prove(
vec![pre],
Program::serialize_instruction(instruction).unwrap(),
vec![InputAccountIdentity::PrivateUnauthorized {
vpk: account_keys.vpk(),
random_seed: [0; 32],
npk: account_keys.npk(),
identifier: 0,
commitment_root: DUMMY_COMMITMENT_HASH,
}],
&validity_window_program.into(),
)
.unwrap();
let message = Message::try_from_circuit_output(vec![], vec![], output).unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
PrivacyPreservingTransaction::new(message, witness_set)
};
let result = state.transition_from_privacy_preserving_transaction(&tx, 1, timestamp);
let is_inside_validity_window = match (
timestamp_validity_window.start(),
timestamp_validity_window.end(),
) {
(Some(s), Some(e)) => s <= timestamp && timestamp < e,
(Some(s), None) => s <= timestamp,
(None, Some(e)) => timestamp < e,
(None, None) => true,
};
if is_inside_validity_window {
assert!(result.is_ok());
} else {
assert!(matches!(result, Err(LeeError::OutOfValidityWindow)));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,521 @@
use std::{
collections::{HashMap, HashSet, VecDeque},
hash::Hash,
};
use lee_core::{
BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, Timestamp,
account::{Account, AccountId, AccountWithMetadata},
program::{
ChainedCall, Claim, DEFAULT_PROGRAM_ID, ProgramId, compute_public_authorized_pdas,
validate_execution,
},
};
use log::debug;
use crate::{
V03State, ensure,
error::{InvalidProgramBehaviorError, LeeError},
privacy_preserving_transaction::{
PrivacyPreservingTransaction, circuit::Proof, message::Message,
},
program::Program,
program_deployment_transaction::ProgramDeploymentTransaction,
public_transaction::PublicTransaction,
state::MAX_NUMBER_CHAINED_CALLS,
};
pub struct StateDiff {
pub signer_account_ids: Vec<AccountId>,
pub public_diff: HashMap<AccountId, Account>,
pub new_commitments: Vec<Commitment>,
pub new_nullifiers: Vec<Nullifier>,
pub program: Option<Program>,
}
/// The validated output of executing or verifying a transaction, ready to be applied to the state.
///
/// Can only be constructed by the transaction validation functions inside this crate, ensuring the
/// diff has been checked before any state mutation occurs.
pub struct ValidatedStateDiff(StateDiff);
impl ValidatedStateDiff {
pub fn from_public_transaction(
tx: &PublicTransaction,
state: &V03State,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<Self, LeeError> {
let signer_account_ids = authenticate_public_transaction_signers(tx, state)?;
let message = tx.message();
ensure!(
!message.account_ids.is_empty(),
LeeError::InvalidInput("Public transaction must have at least one account".into())
);
// All account_ids must be different
ensure!(
message.account_ids.iter().collect::<HashSet<_>>().len() == message.account_ids.len(),
LeeError::InvalidInput("Duplicate account_ids found in message".into(),)
);
// Build pre_states for execution
let input_pre_states: Vec<_> = message
.account_ids
.iter()
.map(|account_id| {
AccountWithMetadata::new(
state.get_account_by_id(*account_id),
signer_account_ids.contains(account_id),
*account_id,
)
})
.collect();
let mut state_diff: HashMap<AccountId, Account> = HashMap::new();
let initial_call = ChainedCall {
program_id: message.program_id,
instruction_data: message.instruction_data.clone(),
pre_states: input_pre_states,
pda_seeds: vec![],
};
let initial_caller_data = CallerData {
program_id: None,
authorized_accounts: signer_account_ids.iter().copied().collect(),
};
let mut chained_calls =
VecDeque::<(ChainedCall, CallerData)>::from_iter([(initial_call, initial_caller_data)]);
let mut chain_calls_counter = 0;
while let Some((chained_call, caller_data)) = chained_calls.pop_front() {
ensure!(
chain_calls_counter <= MAX_NUMBER_CHAINED_CALLS,
LeeError::MaxChainedCallsDepthExceeded
);
// Check that the `program_id` corresponds to a deployed program
let Some(program) = state.programs().get(&chained_call.program_id) else {
return Err(LeeError::InvalidInput("Unknown program".into()));
};
debug!(
"Program {:?} pre_states: {:?}, instruction_data: {:?}",
chained_call.program_id, chained_call.pre_states, chained_call.instruction_data
);
let mut program_output = program.execute(
caller_data.program_id,
&chained_call.pre_states,
&chained_call.instruction_data,
)?;
debug!(
"Program {:?} output: {:?}",
chained_call.program_id, program_output
);
let authorized_pdas =
compute_public_authorized_pdas(caller_data.program_id, &chained_call.pda_seeds);
// Account is authorized if it is either in the caller's authorized accounts or in the
// list of PDAs the caller has authorized.
let is_authorized = |account_id: &AccountId| {
authorized_pdas.contains(account_id)
|| caller_data.authorized_accounts.contains(account_id)
};
for pre in &program_output.pre_states {
let account_id = pre.account_id;
// Check that the program output pre_states coincide with the values in the public
// state or with any modifications to those values during the chain of calls.
let expected_pre = state_diff
.get(&account_id)
.cloned()
.unwrap_or_else(|| state.get_account_by_id(account_id));
ensure!(
pre.account == expected_pre,
InvalidProgramBehaviorError::InconsistentAccountPreState {
account_id,
expected: Box::new(expected_pre),
actual: Box::new(pre.account.clone())
}
);
// Check that the program output pre_states marked as authorized are indeed
// authorized, and vice-versa.
let is_indeed_authorized = is_authorized(&account_id);
ensure!(
!pre.is_authorized || is_indeed_authorized,
InvalidProgramBehaviorError::InvalidAccountAuthorization { account_id }
);
ensure!(
pre.is_authorized || !is_indeed_authorized,
InvalidProgramBehaviorError::AuthorizedAccountMarkedAsNotAuthorized {
account_id
}
);
}
// Verify that the program output's self_program_id matches the expected program ID.
ensure!(
program_output.self_program_id == chained_call.program_id,
InvalidProgramBehaviorError::MismatchedProgramId {
expected: chained_call.program_id,
actual: program_output.self_program_id
}
);
// Verify that the program output's caller_program_id matches the actual caller.
ensure!(
program_output.caller_program_id == caller_data.program_id,
InvalidProgramBehaviorError::MismatchedCallerProgramId {
expected: caller_data.program_id,
actual: program_output.caller_program_id,
}
);
// Verify execution corresponds to a well-behaved program.
// See the # Programs section for the definition of the `validate_execution` method.
validate_execution(
&program_output.pre_states,
&program_output.post_states,
chained_call.program_id,
)
.map_err(InvalidProgramBehaviorError::ExecutionValidationFailed)?;
// Verify validity window
ensure!(
program_output.block_validity_window.is_valid_for(block_id)
&& program_output
.timestamp_validity_window
.is_valid_for(timestamp),
LeeError::OutOfValidityWindow
);
for (i, post) in program_output.post_states.iter_mut().enumerate() {
let Some(claim) = post.required_claim() else {
continue;
};
let pre = &program_output.pre_states[i];
let account_id = pre.account_id;
// The invoked program can only claim accounts with default program id.
ensure!(
post.account().program_owner == DEFAULT_PROGRAM_ID,
InvalidProgramBehaviorError::ClaimedNonDefaultAccount { account_id }
);
match claim {
Claim::Authorized => {
// The program can only claim accounts that were authorized by the signer.
ensure!(
pre.is_authorized,
InvalidProgramBehaviorError::ClaimedUnauthorizedAccount { account_id }
);
}
Claim::Pda(seed) => {
// The program can only claim accounts that correspond to the PDAs it is
// authorized to claim. The public-execution path only sees public
// accounts, so the public-PDA derivation is the correct formula here.
let pda = AccountId::for_public_pda(&chained_call.program_id, &seed);
ensure!(
account_id == pda,
InvalidProgramBehaviorError::MismatchedPdaClaim {
expected: pda,
actual: account_id
}
);
}
}
post.account_mut().program_owner = chained_call.program_id;
}
// Update the state diff
for (pre, post) in program_output
.pre_states
.iter()
.zip(program_output.post_states.iter())
{
state_diff.insert(pre.account_id, post.account().clone());
}
// Source from `program_output.pre_states`, not `chained_call.pre_states`:
// the loop above already gates program_output's `is_authorized` via the
// `!pre.is_authorized || is_indeed_authorized` check, while `chained_call.
// pre_states` is caller-controlled and can be forged (audit-issue 91).
//
// Union with the caller's authorized set so that authorization is monotonically
// growing: once an account is authorized at any point in the chain it remains
// authorized for all subsequent calls.
let authorized_accounts: HashSet<_> = caller_data
.authorized_accounts
.into_iter()
.chain(
program_output
.pre_states
.iter()
.filter(|pre| pre.is_authorized)
.map(|pre| pre.account_id),
)
.collect();
for new_call in program_output.chained_calls.into_iter().rev() {
chained_calls.push_front((
new_call,
CallerData {
program_id: Some(chained_call.program_id),
authorized_accounts: authorized_accounts.clone(),
},
));
}
chain_calls_counter = chain_calls_counter
.checked_add(1)
.expect("we check the max depth at the beginning of the loop");
}
// Check that all modified uninitialized accounts where claimed
for (account_id, post) in state_diff.iter().filter_map(|(account_id, post)| {
let pre = state.get_account_by_id(*account_id);
if pre.program_owner != DEFAULT_PROGRAM_ID {
return None;
}
if pre == *post {
return None;
}
Some((*account_id, post))
}) {
ensure!(
post.program_owner != DEFAULT_PROGRAM_ID,
InvalidProgramBehaviorError::DefaultAccountModifiedWithoutClaim { account_id }
);
}
Ok(Self(StateDiff {
signer_account_ids,
public_diff: state_diff,
new_commitments: vec![],
new_nullifiers: vec![],
program: None,
}))
}
pub fn from_privacy_preserving_transaction(
tx: &PrivacyPreservingTransaction,
state: &V03State,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<Self, LeeError> {
let message = &tx.message;
let witness_set = &tx.witness_set;
// 1. Commitments or nullifiers are non empty
ensure!(
!message.new_commitments.is_empty() || !message.new_nullifiers.is_empty(),
LeeError::InvalidInput(
"Empty commitments and empty nullifiers found in message".into(),
)
);
// 2. Check there are no duplicate account_ids in the public_account_ids list.
ensure!(
n_unique(&message.public_account_ids) == message.public_account_ids.len(),
LeeError::InvalidInput("Duplicate account_ids found in message".into())
);
// Check there are no duplicate nullifiers in the new_nullifiers list
ensure!(
n_unique(
&message
.new_nullifiers
.iter()
.map(|(n, _)| n)
.collect::<Vec<_>>()
) == message.new_nullifiers.len(),
LeeError::InvalidInput("Duplicate nullifiers found in message".into())
);
// Check there are no duplicate commitments in the new_commitments list
ensure!(
n_unique(&message.new_commitments) == message.new_commitments.len(),
LeeError::InvalidInput("Duplicate commitments found in message".into())
);
// 3. Nonce checks and Valid signatures
// Check exactly one nonce is provided for each signature
ensure!(
message.nonces.len() == witness_set.signatures_and_public_keys.len(),
LeeError::InvalidInput(
"Mismatch between number of nonces and signatures/public keys".into(),
)
);
// Check the signatures are valid
ensure!(
witness_set.signatures_are_valid_for(message),
LeeError::InvalidInput("Invalid signature for given message and public key".into())
);
let signer_account_ids = tx.signer_account_ids();
// Check nonces corresponds to the current nonces on the public state.
for (account_id, nonce) in signer_account_ids.iter().zip(&message.nonces) {
let current_nonce = state.get_account_by_id(*account_id).nonce;
ensure!(
current_nonce == *nonce,
LeeError::InvalidInput("Nonce mismatch".into())
);
}
// Verify validity window
ensure!(
message.block_validity_window.is_valid_for(block_id)
&& message.timestamp_validity_window.is_valid_for(timestamp),
LeeError::OutOfValidityWindow
);
// Build pre_states for proof verification
let public_pre_states: Vec<_> = message
.public_account_ids
.iter()
.map(|account_id| {
AccountWithMetadata::new(
state.get_account_by_id(*account_id),
signer_account_ids.contains(account_id),
*account_id,
)
})
.collect();
// 4. Proof verification
check_privacy_preserving_circuit_proof_is_valid(
&witness_set.proof,
&public_pre_states,
message,
)?;
// 5. Commitment freshness
state.check_commitments_are_new(&message.new_commitments)?;
// 6. Nullifier uniqueness
state.check_nullifiers_are_valid(&message.new_nullifiers)?;
let public_diff = message
.public_account_ids
.iter()
.copied()
.zip(message.public_post_states.clone())
.collect();
let new_nullifiers = message
.new_nullifiers
.iter()
.copied()
.map(|(nullifier, _)| nullifier)
.collect();
Ok(Self(StateDiff {
signer_account_ids,
public_diff,
new_commitments: message.new_commitments.clone(),
new_nullifiers,
program: None,
}))
}
pub fn from_program_deployment_transaction(
tx: &ProgramDeploymentTransaction,
state: &V03State,
) -> Result<Self, LeeError> {
// TODO: remove clone
let program = Program::new(tx.message.bytecode.clone().into())?;
if state.programs().contains_key(&program.id()) {
return Err(LeeError::ProgramAlreadyExists);
}
Ok(Self(StateDiff {
signer_account_ids: vec![],
public_diff: HashMap::new(),
new_commitments: vec![],
new_nullifiers: vec![],
program: Some(program),
}))
}
/// Returns the public account changes produced by this transaction.
///
/// Used by callers (e.g. the sequencer) to inspect the diff before committing it, for example
/// to enforce that system accounts are not modified by user transactions.
#[must_use]
pub fn public_diff(&self) -> HashMap<AccountId, Account> {
self.0.public_diff.clone()
}
pub(crate) fn into_state_diff(self) -> StateDiff {
self.0
}
}
#[derive(Debug)]
struct CallerData {
program_id: Option<ProgramId>,
authorized_accounts: HashSet<AccountId>,
}
fn authenticate_public_transaction_signers(
tx: &PublicTransaction,
state: &V03State,
) -> Result<Vec<AccountId>, LeeError> {
let message = tx.message();
let witness_set = tx.witness_set();
ensure!(
message.nonces.len() == witness_set.signatures_and_public_keys.len(),
LeeError::InvalidInput(
"Mismatch between number of nonces and signatures/public keys".into(),
)
);
ensure!(
witness_set.is_valid_for(message),
LeeError::InvalidInput("Invalid signature for given message and public key".into())
);
let signer_account_ids = tx.signer_account_ids();
for (account_id, nonce) in signer_account_ids.iter().zip(&message.nonces) {
let current_nonce = state.get_account_by_id(*account_id).nonce;
ensure!(
current_nonce == *nonce,
LeeError::InvalidInput("Nonce mismatch".into())
);
}
Ok(signer_account_ids)
}
fn check_privacy_preserving_circuit_proof_is_valid(
proof: &Proof,
public_pre_states: &[AccountWithMetadata],
message: &Message,
) -> Result<(), LeeError> {
let output = PrivacyPreservingCircuitOutput {
public_pre_states: public_pre_states.to_vec(),
public_post_states: message.public_post_states.clone(),
encrypted_private_post_states: message.encrypted_private_post_states.clone(),
new_commitments: message.new_commitments.clone(),
new_nullifiers: message.new_nullifiers.clone(),
block_validity_window: message.block_validity_window,
timestamp_validity_window: message.timestamp_validity_window,
};
proof
.is_valid_for(&output)
.then_some(())
.ok_or(LeeError::InvalidPrivacyPreservingProof)
}
fn n_unique<T: Eq + Hash>(data: &[T]) -> usize {
let set: HashSet<&T> = data.iter().collect();
set.len()
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,526 @@
use std::collections::HashMap;
use lee_core::account::{Account, AccountId, Nonce};
use crate::{
PrivateKey, PublicKey, V03State,
error::{InvalidProgramBehaviorError, LeeError},
program::Program,
public_transaction::{Message, WitnessSet},
validated_state_diff::ValidatedStateDiff,
};
fn public_state_from_balances(initial_data: &[(AccountId, u128)]) -> HashMap<AccountId, Account> {
initial_data
.iter()
.copied()
.map(|(account_id, balance)| {
(
account_id,
Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
balance,
..Account::default()
},
)
})
.collect()
}
#[test]
fn public_diff_reflects_a_successful_transfer() {
// A successful native transfer must record the debited sender in
// `public_diff()`. Catches the mutation that replaces `public_diff` with
// `HashMap::new()` (which would hide every account change).
let from_key = PrivateKey::try_new([1_u8; 32]).unwrap();
let from = AccountId::from(&PublicKey::new_from_private_key(&from_key));
let to_key = PrivateKey::try_new([2_u8; 32]).unwrap();
let to = AccountId::from(&PublicKey::new_from_private_key(&to_key));
let state = V03State::new()
.with_public_accounts(public_state_from_balances(&[(from, 100)]))
.with_programs(std::iter::once(
crate::test_methods::simple_balance_transfer(),
));
let program_id = crate::test_methods::simple_balance_transfer().id();
let message =
Message::try_new(program_id, vec![from, to], vec![Nonce(0), Nonce(0)], 5_u128).unwrap();
let witness_set = WitnessSet::for_message(&message, &[&from_key, &to_key]);
let tx = crate::PublicTransaction::new(message, witness_set);
let diff = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0)
.expect("a valid native transfer must validate");
let public_diff = diff.public_diff();
assert!(
public_diff.contains_key(&from),
"public_diff must contain the debited sender",
);
assert_eq!(
public_diff[&from].balance, 95,
"sender balance in the diff must reflect the debit",
);
}
/// Privacy-path version of the authorization-injection attack. The test passes when the
/// attack is rejected and the victim's balance is left untouched.
///
/// `execute_and_prove` succeeds because each inner receipt is individually valid and the
/// outer circuit faithfully commits whatever the attacker's program output says, including
/// `victim(is_authorized=true)`. The circuit has no access to chain state and cannot know
/// the victim never signed.
///
/// The host-side validator is what catches the attack: it independently reconstructs
/// `public_pre_states` from chain state using `signer_account_ids.contains(victim_id) = false`,
/// so it expects `victim(is_authorized=false)`. The committed journal and the reconstructed
/// expected output diverge, `receipt.verify` fails, and `from_privacy_preserving_transaction`
/// returns an error before any state is applied.
#[test]
fn privacy_malicious_programs_cannot_drain_public_victim() {
use lee_core::{
Commitment, InputAccountIdentity,
account::{Account, AccountWithMetadata},
};
use crate::{
PrivacyPreservingTransaction,
privacy_preserving_transaction::{
circuit::{ProgramWithDependencies, execute_and_prove},
message::Message,
witness_set::WitnessSet,
},
state::{CommitmentSet, tests::test_private_account_keys_1},
};
type InjectorInstruction = (
lee_core::program::ProgramId, // p2_id
lee_core::program::ProgramId, // simple_balance_transfer_id
[u8; 32], // victim_id_raw
u128, // victim_balance
u128, // victim_nonce
lee_core::program::ProgramId, // victim_program_owner
[u8; 32], // recipient_id_raw
u128, // amount
);
// Attacker controls a private account.
let attacker_keys = test_private_account_keys_1();
let attacker_id =
AccountId::for_regular_private_account(&attacker_keys.npk(), &attacker_keys.vpk(), 0);
let victim_id = AccountId::new([20_u8; 32]);
let recipient_id = AccountId::new([42_u8; 32]);
let victim_balance = 5_000_u128;
// genesis sets program_owner = simple_balance_transfer_program.id() on all accounts.
let state = V03State::new()
.with_public_accounts(public_state_from_balances(&[
(victim_id, victim_balance),
(recipient_id, 0),
]))
.with_programs([
crate::test_methods::simple_balance_transfer(),
crate::test_methods::malicious_injector(),
crate::test_methods::malicious_launderer(),
]);
// Build attacker's private account and its local commitment tree.
let attacker_account = Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
balance: 100,
..Account::default()
};
let attacker_commitment = Commitment::new(&attacker_id, &attacker_account);
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&attacker_commitment));
let membership_proof = commitment_set
.get_proof_for(&attacker_commitment)
.expect("attacker commitment must be in the set");
let attacker_pre = AccountWithMetadata::new(attacker_account, true, attacker_id);
let victim_account = state.get_account_by_id(victim_id);
let instruction: InjectorInstruction = (
crate::test_methods::malicious_launderer().id(),
crate::test_methods::simple_balance_transfer().id(),
*victim_id.value(),
victim_account.balance,
victim_account.nonce.0,
victim_account.program_owner,
*recipient_id.value(),
victim_balance,
);
let instruction_data = Program::serialize_instruction(instruction).unwrap();
let p2 = crate::test_methods::malicious_launderer();
let at = crate::test_methods::simple_balance_transfer();
let program_with_deps = ProgramWithDependencies::new(
crate::test_methods::malicious_injector(),
[(p2.id(), p2), (at.id(), at)].into(),
);
// account_identities order must match self.pre_states as built by the circuit:
// [0] attacker — first seen in P1's program_output.pre_states
// [1] victim — first seen in simple_balance_transfer's program_output.pre_states
// [2] recipient — first seen in simple_balance_transfer's program_output.pre_states
let account_identities = vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: attacker_keys.vpk(),
random_seed: [0; 32],
nsk: attacker_keys.nsk,
membership_proof,
identifier: 0,
},
InputAccountIdentity::Public, // victim
InputAccountIdentity::Public, // recipient
];
// execute_and_prove succeeds: all inner receipts are valid.
// The outer circuit commits victim(is_authorized=true) to its journal.
let (circuit_output, proof) = execute_and_prove(
vec![attacker_pre],
instruction_data,
account_identities,
&program_with_deps,
)
.expect("execute_and_prove should succeed \u{2014} the programs execute correctly");
// public_account_ids lists the Public entries from account_identities, in order.
// The single ciphertext belongs to attacker's private account update.
let message = Message::try_from_circuit_output(
vec![victim_id, recipient_id],
vec![], // no public signers, no nonces
circuit_output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]); // no signatures
let tx = PrivacyPreservingTransaction::new(message, witness_set);
let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0);
assert!(
matches!(result, Err(LeeError::InvalidPrivacyPreservingProof)),
"attack privacy transaction should be rejected with InvalidPrivacyPreservingProof"
);
assert_eq!(state.get_account_by_id(victim_id).balance, victim_balance);
assert_eq!(state.get_account_by_id(recipient_id).balance, 0);
}
/// Private-victim variant of the authorization-injection attack. The test passes when the
/// attack is rejected and the recipient's balance remains zero.
///
/// After the circuit's Vacant branch accepts the injected `victim(is_authorized=true)`
/// verbatim, the attacker must choose how to declare the victim in `account_identities`.
/// There are two routes, both closed:
///
/// - **mask=1 (`PrivateAuthorizedUpdate`)**: the circuit derives `account_id =
/// AccountId::for_regular_private_account(&npk_from(nsk), identifier)` and asserts it matches
/// `pre_state.account_id`. Passing this check requires the victim's `nsk`, which the attacker
/// does not have. `execute_and_prove` panics inside the ZKVM and no proof is produced.
///
/// - **mask=0 (`Public`)**: the circuit places the account in `public_pre_states` and
/// `execute_and_prove` succeeds. The host-side validator then reconstructs `public_pre_states`
/// from chain state; `state.get_account_by_id(victim_id)` returns the default account (balance=0)
/// because the victim has no public state entry. The committed journal and the reconstructed
/// expected output diverge, `receipt.verify` fails, and `from_privacy_preserving_transaction`
/// returns an error before any state is applied. This test exercises this route.
#[test]
fn privacy_malicious_programs_cannot_drain_private_victim() {
use lee_core::{
Commitment, InputAccountIdentity,
account::{Account, AccountWithMetadata},
};
use crate::{
PrivacyPreservingTransaction,
privacy_preserving_transaction::{
circuit::{ProgramWithDependencies, execute_and_prove},
message::Message,
witness_set::WitnessSet,
},
state::{
CommitmentSet,
tests::{test_private_account_keys_1, test_private_account_keys_2},
},
};
type InjectorInstruction = (
lee_core::program::ProgramId, // p2_id
lee_core::program::ProgramId, // simple_balance_transfer_id
[u8; 32], // victim_id_raw
u128, // victim_balance
u128, // victim_nonce
lee_core::program::ProgramId, // victim_program_owner
[u8; 32], // recipient_id_raw
u128, // amount
);
// Attacker controls a private account.
let attacker_keys = test_private_account_keys_1();
let attacker_id =
AccountId::for_regular_private_account(&attacker_keys.npk(), &attacker_keys.vpk(), 0);
// Victim is a private account — not registered in public chain state.
let victim_keys = test_private_account_keys_2();
let victim_id =
AccountId::for_regular_private_account(&victim_keys.npk(), &victim_keys.vpk(), 0);
let victim_balance = 5_000_u128;
let recipient_id = AccountId::new([42_u8; 32]);
// Victim has no public state entry; only recipient is registered at genesis.
let state = V03State::new()
.with_public_accounts(public_state_from_balances(&[(recipient_id, 0)]))
.with_programs([
crate::test_methods::simple_balance_transfer(),
crate::test_methods::malicious_injector(),
crate::test_methods::malicious_launderer(),
]);
// Build attacker's private account and its local commitment tree.
let attacker_account = Account {
program_owner: crate::test_methods::simple_balance_transfer().id(),
balance: 100,
..Account::default()
};
let attacker_commitment = Commitment::new(&attacker_id, &attacker_account);
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&attacker_commitment));
let membership_proof = commitment_set
.get_proof_for(&attacker_commitment)
.expect("attacker commitment must be in the set");
let attacker_pre = AccountWithMetadata::new(attacker_account, true, attacker_id);
// The attacker supplies the victim's account data directly — it cannot be read from
// public state. The injected balance and program_owner allow simple_balance_transfer
// to succeed inside the circuit, which has no access to chain state and cannot detect
// that these values are fabricated.
let instruction: InjectorInstruction = (
crate::test_methods::malicious_launderer().id(),
crate::test_methods::simple_balance_transfer().id(),
*victim_id.value(),
victim_balance,
0_u128, // nonce
crate::test_methods::simple_balance_transfer().id(), // program_owner
*recipient_id.value(),
victim_balance,
);
let instruction_data = Program::serialize_instruction(instruction).unwrap();
let p2 = crate::test_methods::malicious_launderer();
let at = crate::test_methods::simple_balance_transfer();
let program_with_deps = ProgramWithDependencies::new(
crate::test_methods::malicious_injector(),
[(p2.id(), p2), (at.id(), at)].into(),
);
// account_identities order must match self.pre_states as built by the circuit:
// [0] attacker — first seen in P1's program_output.pre_states
// [1] victim — first seen in simple_balance_transfer's program_output.pre_states
// [2] recipient — first seen in simple_balance_transfer's program_output.pre_states
//
// Victim is marked Public: the attacker has no nsk for the victim's private account,
// so PrivateAuthorizedUpdate is not an option.
let account_identities = vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
vpk: attacker_keys.vpk(),
random_seed: [0; 32],
nsk: attacker_keys.nsk,
membership_proof,
identifier: 0,
},
InputAccountIdentity::Public, // victim — attacker lacks victim's nsk
InputAccountIdentity::Public, // recipient
];
// execute_and_prove succeeds: simple_balance_transfer runs against the injected
// victim(balance=5000, is_authorized=true) and produces valid inner receipts.
// The outer circuit commits victim(is_authorized=true) to public_pre_states.
let (circuit_output, proof) = execute_and_prove(
vec![attacker_pre],
instruction_data,
account_identities,
&program_with_deps,
)
.expect("execute_and_prove should succeed \u{2014} the programs execute correctly");
// public_account_ids lists the Public entries from account_identities, in order.
// The single ciphertext belongs to attacker's private account update.
let message = Message::try_from_circuit_output(
vec![victim_id, recipient_id],
vec![], // no public signers, no nonces
circuit_output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]); // no signatures
let tx = PrivacyPreservingTransaction::new(message, witness_set);
let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0);
assert!(
matches!(result, Err(LeeError::InvalidPrivacyPreservingProof)),
"attack on private victim should be rejected with InvalidPrivacyPreservingProof"
);
// Victim has no public balance to check; confirming the recipient received nothing
// is sufficient to show no funds moved.
assert_eq!(state.get_account_by_id(recipient_id).balance, 0);
}
/// Two malicious programs (injector + launderer) attempt to drain a victim's balance
/// without the victim signing anything. The test passes when the attack is rejected
/// and the victim's balance is left untouched.
///
/// Attack flow:
/// Transaction (attacker signs) → P1 (`malicious_injector`)
/// → injects `victim(is_authorized=true)` into chained-call `pre_states` for P2
/// P2 (`malicious_launderer`)
/// → outputs empty pre/post states, forwarding the forged flag to `simple_balance_transfer`
/// → if `authorized_accounts` were built from the injected `pre_states`,
/// `{victim}.contains(victim)` would pass and the transfer would execute.
///
/// The validator must reject this: `authorized_accounts` must be derived from the
/// parent program's own validated `program_output.pre_states`, not from the chained-call
/// input, so a forged `is_authorized=true` flag is never trusted.
#[test]
fn malicious_programs_cannot_drain_victim_without_signature() {
// p2_id, simple_balance_transfer_id, victim_id_raw, victim_balance, victim_nonce,
// victim_program_owner, recipient_id_raw, amount.
// Primitives only — AccountId/Account cannot round-trip through instruction_data
// via risc0_zkvm::serde (SerializeDisplay issue).
type InjectorInstruction = (
lee_core::program::ProgramId, // p2_id
lee_core::program::ProgramId, // simple_balance_transfer_id
[u8; 32], // victim_id_raw
u128, // victim_balance
u128, // victim_nonce
lee_core::program::ProgramId, // victim_program_owner
[u8; 32], // recipient_id_raw
u128, // amount
);
let attacker_key = PrivateKey::try_new([10; 32]).unwrap();
let attacker_id = AccountId::from(&PublicKey::new_from_private_key(&attacker_key));
let victim_key = PrivateKey::try_new([20; 32]).unwrap();
let victim_id = AccountId::from(&PublicKey::new_from_private_key(&victim_key));
let recipient_id = AccountId::new([42; 32]);
let victim_balance = 5_000_u128;
let state = V03State::new()
.with_public_accounts(public_state_from_balances(&[
(attacker_id, 100),
(victim_id, victim_balance),
(recipient_id, 0),
]))
.with_programs([
crate::test_methods::simple_balance_transfer(),
crate::test_methods::malicious_injector(),
crate::test_methods::malicious_launderer(),
]);
// Read victim state from chain, exactly as the attacker would.
let victim_account = state.get_account_by_id(victim_id);
let instruction: InjectorInstruction = (
crate::test_methods::malicious_launderer().id(),
crate::test_methods::simple_balance_transfer().id(),
*victim_id.value(),
victim_account.balance,
victim_account.nonce.0,
victim_account.program_owner,
*recipient_id.value(),
victim_balance,
);
let message = Message::try_new(
crate::test_methods::malicious_injector().id(),
vec![attacker_id],
vec![Nonce(0)],
instruction,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, &[&attacker_key]);
let tx = crate::PublicTransaction::new(message, witness_set);
let result = ValidatedStateDiff::from_public_transaction(&tx, &state, 1, 0);
assert!(
matches!(
result,
Err(LeeError::InvalidProgramBehavior(
InvalidProgramBehaviorError::InvalidAccountAuthorization { account_id }
)) if account_id == victim_id
),
"attack transaction should be rejected with InvalidAccountAuthorization for the victim"
);
// Confirm the victim's balance is untouched.
let victim_balance_after = state.get_account_by_id(victim_id).balance;
let recipient_balance_after = state.get_account_by_id(recipient_id).balance;
assert_eq!(
victim_balance_after, victim_balance,
"victim balance should be unchanged"
);
assert_eq!(
recipient_balance_after, 0,
"recipient should receive nothing"
);
}
/// Regression test: a `PrivacyPreservingTransaction` carrying a structurally invalid
/// proof must be rejected with a clean `Err`.
#[test]
fn privacy_garbage_proof_is_rejected() {
use lee_core::{
Commitment,
account::Account,
program::{BlockValidityWindow, TimestampValidityWindow},
};
use crate::{
PrivacyPreservingTransaction,
privacy_preserving_transaction::{
circuit::Proof, message::Message, witness_set::WitnessSet,
},
};
let state = V03State::new();
// Minimal message that passes every check up to proof verification: a single
// commitment satisfies the non-empty requirement, no signers makes the
// nonce/signature checks vacuously true, and unbounded validity windows are valid
// for any block/timestamp.
let account_id = AccountId::from(&PublicKey::new_from_private_key(
&PrivateKey::try_new([1_u8; 32]).unwrap(),
));
let commitment = Commitment::new(&account_id, &Account::default());
let message = Message {
public_account_ids: vec![],
nonces: vec![],
public_post_states: vec![],
encrypted_private_post_states: vec![],
new_commitments: vec![commitment],
new_nullifiers: vec![],
block_validity_window: BlockValidityWindow::new_unbounded(),
timestamp_validity_window: TimestampValidityWindow::new_unbounded(),
};
// Garbage proof bytes: not a valid borsh-encoded `InnerReceipt`.
let garbage_proof = Proof::from_inner(vec![0xff_u8; 64]);
let witness_set = WitnessSet::for_message(&message, garbage_proof, &[]);
let tx = PrivacyPreservingTransaction::new(message, witness_set);
let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0);
match result {
Err(LeeError::InvalidPrivacyPreservingProof) => {}
Err(other) => panic!("expected InvalidPrivacyPreservingProof, got {other:?}"),
Ok(_) => panic!("garbage proof was accepted instead of rejected"),
}
}

View File

@ -55,6 +55,31 @@ pub struct Block {
pub bedrock_status: BedrockStatus,
}
impl Block {
/// Recomputes the hash from this block's contents, for integrity verification
/// against the value stored in `header.hash`.
#[must_use]
pub fn recompute_hash(&self) -> BlockHash {
HashableBlockData {
block_id: self.header.block_id,
prev_block_hash: self.header.prev_block_hash,
timestamp: self.header.timestamp,
transactions: self.body.transactions.clone(),
}
.compute_hash()
}
/// Recomputes the signed hash from the block contents and checks the header
/// signature against `expected_pubkey`. Used to pin a peer zone's
/// block-signing key, so a block inscribed by anyone other than that zone's
/// sequencer is rejected even if it reached the channel.
#[must_use]
pub fn is_signed_by(&self, expected_pubkey: &lee::PublicKey) -> bool {
let hash = HashableBlockData::from(self.clone()).compute_hash();
self.header.signature.is_valid_for(&hash.0, expected_pubkey)
}
}
impl Serialize for Block {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
crate::borsh_base64::serialize(self, serializer)
@ -76,11 +101,13 @@ pub struct HashableBlockData {
}
impl HashableBlockData {
/// Domain-separated hash of the block contents: `SHA256(PREFIX || borsh(self))`.
/// The single source of truth for both producing and verifying a block hash.
#[must_use]
pub fn into_pending_block(self, signing_key: &lee::PrivateKey) -> Block {
pub fn compute_hash(&self) -> BlockHash {
const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Block/\x00\x00\x00\x00\x00\x00\x00\x00";
let data_bytes = borsh::to_vec(&self).unwrap();
let data_bytes = borsh::to_vec(self).unwrap();
let mut bytes = Vec::with_capacity(
PREFIX
.len()
@ -89,8 +116,12 @@ impl HashableBlockData {
);
bytes.extend_from_slice(PREFIX);
bytes.extend_from_slice(&data_bytes);
OwnHasher::hash(&bytes)
}
let hash = OwnHasher::hash(&bytes);
#[must_use]
pub fn into_pending_block(self, signing_key: &lee::PrivateKey) -> Block {
let hash = self.compute_hash();
let signature = lee::Signature::new(signing_key, &hash.0);
Block {
header: BlockHeader {
@ -132,4 +163,33 @@ mod tests {
let block_from_bytes = borsh::from_slice::<HashableBlockData>(&bytes).unwrap();
assert_eq!(hashable, block_from_bytes);
}
#[test]
fn recompute_hash_matches_header_for_well_formed_block() {
let key = lee::PrivateKey::try_new([7_u8; 32]).expect("valid key");
let block = HashableBlockData {
block_id: 5,
prev_block_hash: HashType([9_u8; 32]),
timestamp: 42,
transactions: vec![test_utils::produce_dummy_empty_transaction()],
}
.into_pending_block(&key);
assert_eq!(block.recompute_hash(), block.header.hash);
}
#[test]
fn recompute_hash_detects_tampering() {
let key = lee::PrivateKey::try_new([7_u8; 32]).expect("valid key");
let block = HashableBlockData {
block_id: 5,
prev_block_hash: HashType([9_u8; 32]),
timestamp: 42,
transactions: vec![test_utils::produce_dummy_empty_transaction()],
}
.into_pending_block(&key);
let mut tampered = block;
tampered.header.timestamp = 99; // header changed; stale hash no longer matches
assert_ne!(tampered.recompute_hash(), tampered.header.hash);
}
}

View File

@ -92,9 +92,8 @@ impl LeeTransaction {
Ok(diff)
}
/// Computes the validated state diff without enforcing the system-account
/// restriction. Shared by [`Self::validate_on_state`] and
/// [`Self::execute_without_system_accounts_check_on_state`].
/// Computes the validated state diff. Shared by [`Self::validate_on_state`]
/// (which adds the system-account guards) and [`Self::execute_on_state`].
fn compute_state_diff(
&self,
state: &V03State,
@ -129,16 +128,12 @@ impl LeeTransaction {
Ok(self)
}
/// Similar to [`Self::execute_check_on_state`], but skips the system-account guard.
/// Executes the transaction against the current state and applies the resulting diff,
/// without the system-account guards enforced by [`Self::execute_check_on_state`].
///
/// FIXME: HOT FIX (testnet v0.2): the indexer replays blocks the sequencer already
/// accepted, including sequencer-generated deposit transactions that
/// legitimately modify the bridge account. The `TransactionOrigin::Sequencer`
/// tag that lets the sequencer bypass the guard is not carried in the block,
/// so the indexer cannot yet distinguish deposit txs from user txs.
///
/// REMOVE ME when the indexer can authenticate deposit transactions.
pub fn execute_without_system_accounts_check_on_state(
/// The indexer replays blocks the sequencer already validated and inscribed on Bedrock,
/// so it trusts those inscriptions and re-derives state without re-validating them.
pub fn execute_on_state(
self,
state: &mut V03State,
block_id: BlockId,

View File

@ -3,5 +3,6 @@
"bedrock_config": {
"addr": "http://logos-blockchain-node-0:18080"
},
"channel_id": "0101010101010101010101010101010101010101010101010101010101010101"
"channel_id": "0101010101010101010101010101010101010101010101010101010101010101",
"allow_chain_reset": true
}

17
lez/cross_zone/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "cross_zone"
version = "0.1.0"
edition = "2024"
license = { workspace = true }
[lints]
workspace = true
[dependencies]
lee.workspace = true
lee_core.workspace = true
programs.workspace = true
cross_zone_inbox_core.workspace = true
bridge_lock_core.workspace = true
ping_core.workspace = true
risc0-zkvm.workspace = true

189
lez/cross_zone/src/lib.rs Normal file
View File

@ -0,0 +1,189 @@
//! Host-side cross-zone helpers that need program ids (`programs`) or the state
//! machine (`lee`), kept out of the guest-pure cores. Mirrors `system_accounts`:
//! it resolves builtin program ids and bakes them into transactions and genesis
//! accounts for the watcher (sequencer) and verifier (indexer).
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,
};
use lee_core::{
account::{Account, AccountId},
program::ProgramId,
};
/// The cross-zone emission fields a watcher or verifier reads off a source
/// transaction, common to every emitter program.
pub struct Emission {
pub target_zone: ZoneId,
pub target_program_id: ProgramId,
pub target_accounts: Vec<[u8; 32]>,
pub payload: Vec<u8>,
}
/// 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
/// must be rejected at ingress, since `TransactionOrigin` is not carried in the
/// block.
#[must_use]
pub fn is_sequencer_only_program(program_id: ProgramId) -> bool {
program_id == programs::cross_zone_inbox().id()
}
/// Extracts the cross-zone emission from a source transaction.
///
/// Recognizes the known emitter programs (`ping_sender`, `bridge_lock`). The
/// watcher and verifier both use this so they agree on what a given source tx
/// emits.
#[must_use]
pub fn extract_emission(program_id: ProgramId, instruction_data: &[u32]) -> Option<Emission> {
if program_id == programs::ping_sender().id() {
let ping_core::SenderInstruction::Send {
target_zone,
target_program_id,
target_accounts,
payload,
..
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
Some(Emission {
target_zone,
target_program_id,
target_accounts,
payload,
})
} else if program_id == programs::bridge_lock().id() {
let bridge_lock_core::Instruction::Lock {
target_zone,
target_program_id,
target_accounts,
payload,
..
} = risc0_zkvm::serde::from_slice(instruction_data).ok()?;
Some(Emission {
target_zone,
target_program_id,
target_accounts,
payload,
})
} else {
None
}
}
/// Builds the sequencer-origin dispatch transaction. Pure for fixed inputs, so
/// the watcher's injected tx and the indexer's re-derived tx are byte-identical.
fn build_inbox_dispatch_tx(
inbox_id: ProgramId,
msg: &CrossZoneMessage,
target_account_ids: Vec<AccountId>,
) -> lee::PublicTransaction {
let mut account_ids = Vec::with_capacity(target_account_ids.len().saturating_add(2));
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,
));
account_ids.extend(target_account_ids);
let message = lee::public_transaction::Message::try_new(
inbox_id,
account_ids,
vec![],
Instruction::Dispatch(msg.clone()),
)
.expect("inbox dispatch instruction must serialize");
lee::PublicTransaction::new(
message,
lee::public_transaction::WitnessSet::from_raw_parts(vec![]),
)
}
/// Builds the dispatch transaction for one peer emission.
///
/// Both the sequencer's watcher and the indexer's verifier go through this so
/// their transactions are byte-identical for the same emission (the basis of the
/// 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,
target_program_id: ProgramId,
target_accounts: &[[u8; 32]],
payload: Vec<u8>,
) -> lee::PublicTransaction {
let msg = CrossZoneMessage {
src_zone,
src_block_id,
src_tx_index,
src_program_id,
target_program_id,
payload,
l1_inclusion_witness: None,
};
let target_ids = target_accounts
.iter()
.copied()
.map(AccountId::new)
.collect();
build_inbox_dispatch_tx(programs::cross_zone_inbox().id(), &msg, target_ids)
}
/// Builds the inbox config account a zone seeds into genesis state.
///
/// Lets the inbox guest authorize inbound peer messages. The sequencer and
/// indexer seed the same account from the same config, keeping their replayed
/// state consistent.
#[must_use]
pub fn build_inbox_config_account(
self_zone: ZoneId,
cross_zone: &CrossZoneConfig,
) -> (AccountId, Account) {
let inbox_id = programs::cross_zone_inbox().id();
let mut allowed_targets = BTreeMap::new();
for peer in &cross_zone.peers {
allowed_targets.insert(peer.channel_id, peer.allowed_targets.clone());
}
let config = InboxConfig {
self_zone,
allowed_peers: BTreeMap::new(),
allowed_targets,
};
let account = Account {
program_owner: inbox_id,
balance: 0,
data: config
.to_bytes()
.try_into()
.expect("inbox config fits in account data"),
nonce: 0_u128.into(),
};
(inbox_config_account_id(inbox_id), account)
}
/// Builds the genesis holding account funding a holder's bridgeable balance.
///
/// Owned by `bridge_lock`, data is the LE balance. Not produced by any
/// transaction, so the sequencer and indexer both seed it through this one
/// builder.
#[must_use]
pub fn build_holding_account(holder: AccountId, amount: u128) -> (AccountId, Account) {
let account = Account {
program_owner: programs::bridge_lock().id(),
data: bridge_lock_core::balance_bytes(amount)
.to_vec()
.try_into()
.expect("balance fits in account data"),
..Default::default()
};
(holder, account)
}

View File

@ -0,0 +1,58 @@
use indexer_service_protocol::AccountId;
use itertools::{EitherOrBoth, Itertools as _};
use leptos::prelude::*;
use leptos_router::components::A;
#[component]
pub fn AccountNonceList(account_ids: Vec<AccountId>, nonces: Vec<u128>) -> impl IntoView {
view! {
<div class="accounts-list">
{account_ids
.into_iter()
.zip_longest(nonces.into_iter())
.map(|maybe_pair| {
match maybe_pair {
EitherOrBoth::Both(account_id, nonce) => {
let account_id_str = account_id.to_string();
view! {
<div class="account-item">
<A href=format!("/account/{}", account_id_str)>
<span class="hash">{account_id_str}</span>
</A>
<span class="nonce">
" (nonce: " {nonce.to_string()} ")"
</span>
</div>
}
}
EitherOrBoth::Left(account_id) => {
let account_id_str = account_id.to_string();
view! {
<div class="account-item">
<A href=format!("/account/{}", account_id_str)>
<span class="hash">{account_id_str}</span>
</A>
<span class="nonce">
" (nonce: "{"Not affected by this transaction".to_owned()}" )"
</span>
</div>
}
}
EitherOrBoth::Right(_) => {
view! {
<div class="account-item">
<A href=format!("/account/{}", "Account not found")>
<span class="hash">{"Account not found"}</span>
</A>
<span class="nonce">
" (nonce: "{"Account not found".to_owned()}" )"
</span>
</div>
}
}
}
})
.collect::<Vec<_>>()}
</div>
}
}

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