mirror of
https://github.com/logos-blockchain/logos-execution-zone.git
synced 2026-05-08 09:09:31 +00:00
feat: add group PDA test program, unit tests, and integration test
This commit is contained in:
parent
48f95b1b7a
commit
5b9cf95c47
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
145
integration_tests/tests/group_pda.rs
Normal file
145
integration_tests/tests/group_pda.rs
Normal file
@ -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(())
|
||||
}
|
||||
@ -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};
|
||||
|
||||
@ -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();
|
||||
|
||||
118
test_program_methods/guest/src/bin/group_pda_spender.rs
Normal file
118
test_program_methods/guest/src/bin/group_pda_spender.rs
Normal file
@ -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::<Instruction>();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user