From 5b9cf95c474e06ec7151987beb6697bbfd74d8aa Mon Sep 17 00:00:00 2001 From: Moudy Date: Mon, 27 Apr 2026 02:44:16 +0200 Subject: [PATCH] feat: add group PDA test program, unit tests, and integration test --- .github/workflows/ci.yml | 2 +- Cargo.lock | 2 + integration_tests/Cargo.toml | 1 + integration_tests/tests/group_pda.rs | 145 ++++++++++++++++++ nssa/src/program.rs | 10 ++ nssa/src/state.rs | 94 ++++++++++++ .../guest/src/bin/group_pda_spender.rs | 118 ++++++++++++++ 7 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 integration_tests/tests/group_pda.rs create mode 100644 test_program_methods/guest/src/bin/group_pda_spender.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02381dfc..f10532a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,7 +225,7 @@ jobs: - uses: ./.github/actions/install-risc0 - name: Install just - run: cargo install just + run: cargo install --locked just - name: Build artifacts run: just build-artifacts diff --git a/Cargo.lock b/Cargo.lock index ca46abde..4fd6116e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3600,6 +3600,7 @@ dependencies = [ "sequencer_service_rpc", "serde_json", "tempfile", + "test_program_methods", "testcontainers", "testnet_initial_state", "token_core", @@ -3982,6 +3983,7 @@ dependencies = [ "aes-gcm", "anyhow", "base58", + "bincode", "bip39", "common", "hex", diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index cb5277d2..feedf24b 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -22,6 +22,7 @@ ata_core.workspace = true indexer_service_rpc.workspace = true sequencer_service_rpc = { workspace = true, features = ["client"] } wallet-ffi.workspace = true +test_program_methods.workspace = true testnet_initial_state.workspace = true url.workspace = true diff --git a/integration_tests/tests/group_pda.rs b/integration_tests/tests/group_pda.rs new file mode 100644 index 00000000..fba54a16 --- /dev/null +++ b/integration_tests/tests/group_pda.rs @@ -0,0 +1,145 @@ +#![expect( + clippy::tests_outside_test_module, + reason = "Integration test file, not inside a #[cfg(test)] module" +)] + +//! Group-owned private PDA lifecycle integration test. +//! +//! Demonstrates: +//! 1. GMS creation and sealed distribution between controllers. +//! 2. Key agreement: both controllers derive identical keys from the shared GMS. +//! 3. Forward secrecy: ratcheting the GMS produces different keys, locking out removed members. + +use anyhow::{Context as _, Result}; +use integration_tests::TestContext; +use key_protocol::key_management::group_key_holder::GroupKeyHolder; +use log::info; +use nssa::{AccountId, program::Program}; +use nssa_core::program::PdaSeed; +use tokio::test; + +/// Group PDA lifecycle: create group, distribute GMS, verify key agreement, revoke. +#[test] +async fn group_pda_lifecycle() -> Result<()> { + let ctx = TestContext::new().await?; + + let alice_holder = GroupKeyHolder::new(); + assert_eq!(alice_holder.epoch(), 0); + let pda_seed = PdaSeed::new([42_u8; 32]); + let group_pda_spender = + Program::new(test_program_methods::GROUP_PDA_SPENDER_ELF.to_vec()).unwrap(); + + // ----------------------------------------------------------------------- + // Act 1: GMS creation and sealed distribution + // ----------------------------------------------------------------------- + + info!("Act 1: creating group and distributing GMS"); + + let alice_npk = alice_holder + .derive_keys_for_pda(&pda_seed) + .generate_nullifier_public_key(); + + let bob_private_account = ctx.existing_private_accounts()[1]; + let (bob_keychain, _) = ctx + .wallet() + .storage() + .user_data + .get_private_account(bob_private_account) + .cloned() + .context("Bob's private account not found")?; + + // Alice seals GMS for Bob, Bob unseals + let sealed = alice_holder.seal_for(&bob_keychain.viewing_public_key); + let bob_holder = + GroupKeyHolder::unseal(&sealed, &bob_keychain.private_key_holder.viewing_secret_key) + .expect("Bob should unseal the GMS"); + + // ----------------------------------------------------------------------- + // Act 2: Key agreement + // + // Both controllers independently derive identical keys for the same PDA + // seed. Neither communicated any per-PDA keys — they derived them from + // the shared GMS. + // ----------------------------------------------------------------------- + + info!("Act 2: verifying key agreement"); + + let bob_npk = bob_holder + .derive_keys_for_pda(&pda_seed) + .generate_nullifier_public_key(); + assert_eq!( + alice_npk, bob_npk, + "Key agreement: identical NPK from shared GMS" + ); + + let group_account_id = + AccountId::for_private_pda(&group_pda_spender.id(), &pda_seed, &alice_npk); + info!("Group PDA AccountId: {group_account_id}"); + + // Both derive the same AccountId independently + let bob_account_id = AccountId::for_private_pda(&group_pda_spender.id(), &pda_seed, &bob_npk); + assert_eq!(group_account_id, bob_account_id); + + info!("Act 2 complete: key agreement verified"); + + // ----------------------------------------------------------------------- + // Act 3: Revocation and forward secrecy + // + // Alice ratchets the GMS to exclude Bob. The new keys produce a different + // NPK and therefore a different AccountId. Bob's frozen holder can no + // longer derive the new keys. + // ----------------------------------------------------------------------- + + info!("Act 3: ratchet and forward secrecy"); + + let mut ratcheted_holder = alice_holder; + ratcheted_holder.ratchet([99_u8; 32]); + assert_eq!(ratcheted_holder.epoch(), 1); + + let ratcheted_npk = ratcheted_holder + .derive_keys_for_pda(&pda_seed) + .generate_nullifier_public_key(); + + let bob_stale_npk = bob_holder + .derive_keys_for_pda(&pda_seed) + .generate_nullifier_public_key(); + + // Forward secrecy: ratcheted keys differ from Bob's stale keys + assert_ne!(ratcheted_npk, bob_stale_npk); + assert_ne!(ratcheted_npk, alice_npk); + + // Different AccountId after ratchet + let new_account_id = + AccountId::for_private_pda(&group_pda_spender.id(), &pda_seed, &ratcheted_npk); + assert_ne!(group_account_id, new_account_id); + + // Bob's stale keys still point to the old address + let bob_stale_account_id = + AccountId::for_private_pda(&group_pda_spender.id(), &pda_seed, &bob_stale_npk); + assert_eq!(bob_stale_account_id, group_account_id); + assert_ne!(bob_stale_account_id, new_account_id); + + // Sealed round-trip of ratcheted GMS + let (alice_kc, _) = ctx + .wallet() + .storage() + .user_data + .get_private_account(ctx.existing_private_accounts()[0]) + .cloned() + .context("Alice's keys not found")?; + let sealed_ratcheted = ratcheted_holder.seal_for(&alice_kc.viewing_public_key); + let restored = GroupKeyHolder::unseal( + &sealed_ratcheted, + &alice_kc.private_key_holder.viewing_secret_key, + ) + .expect("Should unseal ratcheted GMS"); + assert_eq!( + restored.dangerous_raw_gms(), + ratcheted_holder.dangerous_raw_gms() + ); + assert_eq!(restored.epoch(), 1); + + info!("Act 3 complete: forward secrecy verified"); + info!("Group PDA lifecycle test complete"); + Ok(()) +} diff --git a/nssa/src/program.rs b/nssa/src/program.rs index b8c3fe77..954c0525 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -312,6 +312,16 @@ mod tests { } } + #[must_use] + pub fn group_pda_spender() -> Self { + use test_program_methods::{GROUP_PDA_SPENDER_ELF, GROUP_PDA_SPENDER_ID}; + + Self { + id: GROUP_PDA_SPENDER_ID, + elf: GROUP_PDA_SPENDER_ELF.to_vec(), + } + } + #[must_use] pub fn two_pda_claimer() -> Self { use test_program_methods::{TWO_PDA_CLAIMER_ELF, TWO_PDA_CLAIMER_ID}; diff --git a/nssa/src/state.rs b/nssa/src/state.rs index f86f429f..63f0f650 100644 --- a/nssa/src/state.rs +++ b/nssa/src/state.rs @@ -2568,6 +2568,100 @@ pub mod tests { assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); } + /// Group PDA deposit: creates a new PDA and transfers balance from the + /// counterparty. Both accounts owned by `group_pda_spender`. + #[test] + fn group_pda_deposit() { + let program = Program::group_pda_spender(); + let noop = Program::noop(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk()); + + // PDA (new, mask 3) + let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk); + let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); + + // Sender (mask 0, public, owned by this program, has balance) + let sender_id = AccountId::new([99; 32]); + let sender_pre = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 10000, + ..Account::default() + }, + true, + sender_id, + ); + + let noop_id = noop.id(); + let program_with_deps = ProgramWithDependencies::new(program, [(noop_id, noop)].into()); + + let instruction = Program::serialize_instruction((seed, noop_id, 500_u128, true)).unwrap(); + + // PDA is mask 3 (private PDA), sender is mask 0 (public). + // Public accounts don't need keys, nsks, or membership proofs. + let result = execute_and_prove( + vec![pda_pre, sender_pre], + instruction, + vec![3, 0], + vec![(npk, shared_secret_pda)], + vec![], + vec![None], + &program_with_deps, + ); + + let (output, _proof) = result.expect("group PDA deposit should succeed"); + // Only PDA (mask 3) produces a commitment; sender (mask 0) is public. + assert_eq!(output.new_commitments.len(), 1); + } + + /// Group PDA spend binding: the noop chained call with `pda_seeds` establishes + /// the mask-3 binding for an existing-but-default PDA. Uses amount=0 because + /// testing with a pre-funded PDA requires a two-tx sequence with membership proofs. + #[test] + fn group_pda_spend_binding() { + let program = Program::group_pda_spender(); + let noop = Program::noop(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk()); + + let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk); + let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); + + let bob_id = AccountId::new([88; 32]); + let bob_pre = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 10000, + ..Account::default() + }, + true, + bob_id, + ); + + let noop_id = noop.id(); + let program_with_deps = ProgramWithDependencies::new(program, [(noop_id, noop)].into()); + + let instruction = Program::serialize_instruction((seed, noop_id, 0_u128, false)).unwrap(); + + let result = execute_and_prove( + vec![pda_pre, bob_pre], + instruction, + vec![3, 0], + vec![(npk, shared_secret_pda)], + vec![], + vec![None], + &program_with_deps, + ); + + let (output, _proof) = result.expect("group PDA spend binding should succeed"); + assert_eq!(output.new_commitments.len(), 1); + } + #[test] fn circuit_should_fail_with_too_many_nonces() { let program = Program::simple_balance_transfer(); diff --git a/test_program_methods/guest/src/bin/group_pda_spender.rs b/test_program_methods/guest/src/bin/group_pda_spender.rs new file mode 100644 index 00000000..04ef91a4 --- /dev/null +++ b/test_program_methods/guest/src/bin/group_pda_spender.rs @@ -0,0 +1,118 @@ +use nssa_core::program::{ + AccountPostState, ChainedCall, Claim, PdaSeed, ProgramId, ProgramInput, ProgramOutput, + read_nssa_inputs, +}; + +/// Single program for group PDA operations. Owns and operates the PDA directly. +/// +/// Instruction: `(pda_seed, noop_program_id, amount, is_deposit)`. +/// Pre-states: `[group_pda, counterparty]`. +/// +/// **Deposit** (`is_deposit = true`, new PDA): +/// Claims PDA via `Claim::Pda(seed)`, increases PDA balance, decreases counterparty. +/// Counterparty must be authorized and owned by this program (or uninitialized). +/// +/// **Spend** (`is_deposit = false`, existing PDA): +/// Decreases PDA balance (this program owns it), increases counterparty. +/// Chains to a noop callee with `pda_seeds` to establish the mask-3 binding +/// that the circuit requires for existing private PDAs. +type Instruction = (PdaSeed, ProgramId, u128, bool); + +#[expect( + clippy::allow_attributes, + reason = "allow is needed because the clones are only redundant in test compilation" +)] +#[allow( + clippy::redundant_clone, + reason = "clones needed in non-test compilation" +)] +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: (pda_seed, noop_id, amount, is_deposit), + }, + instruction_words, + ) = read_nssa_inputs::(); + + let Ok([pda_pre, counterparty_pre]) = <[_; 2]>::try_from(pre_states.clone()) else { + panic!("expected exactly 2 pre_states: [group_pda, counterparty]"); + }; + + if is_deposit { + // Deposit: claim PDA, transfer balance from counterparty to PDA. + // Both accounts must be owned by this program (or uninitialized) for + // validate_execution to allow balance changes. + assert!( + counterparty_pre.is_authorized, + "Counterparty must be authorized to deposit" + ); + + let mut pda_account = pda_pre.account; + let mut counterparty_account = counterparty_pre.account; + + pda_account.balance = pda_account + .balance + .checked_add(amount) + .expect("PDA balance overflow"); + counterparty_account.balance = counterparty_account + .balance + .checked_sub(amount) + .expect("Counterparty has insufficient balance"); + + let pda_post = AccountPostState::new_claimed_if_default(pda_account, Claim::Pda(pda_seed)); + let counterparty_post = AccountPostState::new(counterparty_account); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + vec![pda_post, counterparty_post], + ) + .write(); + } else { + // Spend: decrease PDA balance (owned by this program), increase counterparty. + // Chain to noop with pda_seeds to establish the mask-3 binding for the + // existing PDA. The noop's pre_states must match our post_states. + // Authorization is enforced by the circuit's binding check, not here. + + let mut pda_account = pda_pre.account.clone(); + let mut counterparty_account = counterparty_pre.account.clone(); + + pda_account.balance = pda_account + .balance + .checked_sub(amount) + .expect("PDA has insufficient balance"); + counterparty_account.balance = counterparty_account + .balance + .checked_add(amount) + .expect("Counterparty balance overflow"); + + let pda_post = AccountPostState::new(pda_account.clone()); + let counterparty_post = AccountPostState::new(counterparty_account.clone()); + + // Chain to noop solely to establish the mask-3 binding via pda_seeds. + let mut noop_pda_pre = pda_pre; + noop_pda_pre.account = pda_account; + noop_pda_pre.is_authorized = true; + + let mut noop_counterparty_pre = counterparty_pre; + noop_counterparty_pre.account = counterparty_account; + + let noop_call = ChainedCall::new(noop_id, vec![noop_pda_pre, noop_counterparty_pre], &()) + .with_pda_seeds(vec![pda_seed]); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + vec![pda_post, counterparty_post], + ) + .with_chained_calls(vec![noop_call]) + .write(); + } +}