test(privacy): extend Stablecoin/ATA privacy coverage and close Token/ATA gaps

Add Stablecoin privacy-preserving tests for WithdrawCollateral and RepayDebt
(personal and group-owned variants), plus a regression test confirming
OpenPosition is incompatible with the privacy circuit (chained-call
re-authorization). Close the last planned Token row (MintWithAuthority to a
private holding) and the ATA owner-signer gap for Transfer (personal and
group-owned), plus a defensive Create/group-owner test.

Extract shared privacy-test helpers (identity builders, GroupOwner
seal/unseal handshake) into integration_tests/src/lib.rs and use them
throughout token.rs, collapsing duplicated InputAccountIdentity/account
construction. Update docs/privacy-test-matrix.md with all new findings.
This commit is contained in:
Marvin Jones
2026-07-08 16:54:31 -04:00
parent bbc8079391
commit 3eeb5059bb
5 changed files with 2030 additions and 663 deletions
+120
View File
@@ -1 +1,121 @@
//! Shared account/key setup helpers for privacy-preserving integration tests.
use key_protocol::key_management::{
group_key_holder::{GroupKeyHolder, SealingPublicKey},
secret_holders::SecretSpendingKey,
};
use nssa::SharedSecretKey;
use nssa_core::{
account::AccountId,
encryption::{EphemeralPublicKey, ViewingPublicKey},
EncryptedAccountData, InputAccountIdentity, MembershipProof, NullifierPublicKey,
NullifierSecretKey,
};
/// Builds a `PrivateUnauthorized` identity: a third party credits a fresh private account it
/// does not control (no `nsk`, `is_authorized` must be `false` on the paired pre-state).
pub fn private_unauthorized_identity(
npk: NullifierPublicKey,
vpk: &ViewingPublicKey,
output_index: u32,
) -> InputAccountIdentity {
InputAccountIdentity::PrivateUnauthorized {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&npk, vpk),
npk,
ssk: SharedSecretKey::encapsulate_deterministic(vpk, &[0u8; 32], output_index).0,
identifier: 0,
}
}
/// Builds a `PrivateAuthorizedInit` identity: the owner self-initializes a fresh private
/// account by supplying its own `nsk` directly (`is_authorized` must be `true`).
pub fn private_authorized_init_identity(
nsk: NullifierSecretKey,
vpk: &ViewingPublicKey,
output_index: u32,
) -> InputAccountIdentity {
let npk = NullifierPublicKey::from(&nsk);
InputAccountIdentity::PrivateAuthorizedInit {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&npk, vpk),
ssk: SharedSecretKey::encapsulate_deterministic(vpk, &[0u8; 32], output_index).0,
nsk,
identifier: 0,
}
}
/// Builds a `PrivateAuthorizedUpdate` identity: spends/credits an *existing* private account,
/// requiring its own `nsk` and a membership proof of its current committed state.
pub fn private_authorized_update_identity(
nsk: NullifierSecretKey,
vpk: &ViewingPublicKey,
membership_proof: MembershipProof,
output_index: u32,
) -> InputAccountIdentity {
let npk = NullifierPublicKey::from(&nsk);
InputAccountIdentity::PrivateAuthorizedUpdate {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&npk, vpk),
ssk: SharedSecretKey::encapsulate_deterministic(vpk, &[0u8; 32], output_index).0,
nsk,
membership_proof,
identifier: 0,
}
}
/// "Alice": creates a shared private account's `GroupKeyHolder` (Group Master Secret) and
/// derives its public identity. The GMS itself never leaves this struct — other parties only
/// ever receive it through [`GroupOwner::admit_member`]'s real seal/unseal ML-KEM-768 handshake,
/// never by handing over key material directly.
pub struct GroupOwner {
holder: GroupKeyHolder,
derivation_seed: [u8; 32],
pub npk: NullifierPublicKey,
pub vpk: ViewingPublicKey,
pub id: AccountId,
}
impl GroupOwner {
/// Creates the group and derives the shared account's public identity from
/// `derivation_seed`.
#[must_use]
pub fn new(derivation_seed: [u8; 32]) -> Self {
let holder = GroupKeyHolder::new();
let keys = holder.derive_keys_for_shared_account(&derivation_seed);
let npk = keys.generate_nullifier_public_key();
let vpk = keys.generate_viewing_public_key();
let id = AccountId::for_regular_private_account(&npk, 0);
Self {
holder,
derivation_seed,
npk,
vpk,
id,
}
}
/// "Bob": distributes the GMS to a new member via the real seal/unseal handshake and
/// returns that member's independently re-derived secret key — the member never touches
/// this `GroupOwner`'s `GroupKeyHolder`, only the sealed bytes.
#[must_use]
pub fn admit_member(&self) -> NullifierSecretKey {
let member_sealing_keys = SecretSpendingKey([9_u8; 32]).produce_private_key_holder(None);
let member_sealing_vpk = member_sealing_keys.generate_viewing_public_key();
let member_sealing_vsk = member_sealing_keys.viewing_secret_key;
let sealed_gms = self.holder.seal_for(&SealingPublicKey::from_bytes(
member_sealing_vpk.to_bytes().to_vec(),
));
let member_holder = GroupKeyHolder::unseal(&sealed_gms, &member_sealing_vsk)
.expect("member must unseal the GMS");
let member_keys = member_holder.derive_keys_for_shared_account(&self.derivation_seed);
let member_nsk = member_keys.nullifier_secret_key;
assert_eq!(
member_keys.generate_nullifier_public_key(),
self.npk,
"member must derive the identical npk as the group owner from the shared GMS"
);
member_nsk
}
}
+302 -46
View File
@@ -1,6 +1,9 @@
use std::collections::HashMap;
use ata_core::{compute_ata_seed, get_associated_token_account_id};
use integration_tests::{
private_authorized_init_identity, private_unauthorized_identity, GroupOwner,
};
use key_protocol::key_management::{
group_key_holder::{GroupKeyHolder, SealingPublicKey},
secret_holders::SecretSpendingKey,
@@ -597,21 +600,7 @@ fn ata_create_from_private_owner() {
);
}
// Marvin-todo
/// Documents a confirmed protocol gap (`PDA` Q2 dimension): the ATA holding can never be made
/// a private account as ATA is currently coded. `Create`'s `ChainedCall.pda_seeds` authorizes
/// Token to mutate `for_public_pda(ata_program_id, seed)` — a *public*-form PDA match. Per
/// `resolve_authorization_and_record_bindings` in `lee_core`'s `execution_state.rs`, a
/// caller-seed match only gets recorded in `private_pda_bound_positions` when it matches under
/// `for_private_pda` (`is_private_form == true`); a public-form match authorizes the account
/// but never binds it as a private PDA. Since `PrivatePdaInit`/`PrivatePdaUpdate` require their
/// position to appear in that binding map (`execution_state.rs:211`), and ATA's own
/// `verify_ata_and_get_seed` independently requires the account id to equal
/// `for_public_pda(ata_program_id, seed)` (never `for_private_pda`'s output, by construction),
/// these two requirements can never both hold for the same account_id. This is not
/// program-specific friction — it's structural: fixing it would require `ata_core` (and
/// equally amm_core / stablecoin_core) to derive their PDAs via `for_private_pda` instead,
/// which is a source change to the program, not a test workaround.
/// ATA cannot be created as a private account.
#[test]
fn ata_create_private_ata_holding_is_not_expressible() {
let mut state = V03State::new();
@@ -684,22 +673,7 @@ fn ata_create_private_ata_holding_is_not_expressible() {
);
}
// Marvin-todo
/// Credits an *already-existing* private holding through ATA's chained call to Token, and
/// documents a structural finding along the way:
/// `ata_program::transfer::transfer_from_associated_token_account` hard-asserts `recipient.account
/// != Account::default()` ("Recipient token holding must be initialized"), so a *fresh* private
/// recipient (shield-style, `PrivateUnauthorized`) can never be created through `ATA::Transfer` —
/// only an existing account can be credited. That collapses what would otherwise be separate `BASE`
/// and `EXIST` tests into one: this test necessarily exercises both "private account through a
/// chained call" (`CHAIN`) and "sending to an existing private account" (`EXIST`, requiring the
/// recipient's cooperation via `PrivateAuthorizedUpdate`, per the finding already confirmed in
/// `token.rs`).
///
/// The private holding is funded beforehand via a direct (non-ATA) `Token::Transfer` shield
/// from a throwaway public holder, since neither `ATA::Transfer` (blocked by the assert above)
/// nor `Token::Mint` (this test fixture's definition has `authority: None`, fixed supply) can
/// create it.
/// Verifies ATA account can be used to transfer to a private account.
#[test]
fn ata_transfer_to_existing_private_recipient() {
let mut state = state_for_ata_tests();
@@ -878,14 +852,7 @@ fn ata_transfer_to_existing_private_recipient() {
.is_some());
}
// Marvin-todo
/// Tests a previously-untried combination: `Burn`'s guest requires `owner` to be a *signer*
/// (`#[account(signer)]`) — every existing private-owner test so far
/// (`ata_create_from_private_owner`) only used owner as a passive `PrivateUnauthorized` recipient
/// in `Create`, which doesn't need signer authorization at all. Here, owner self-initializes *and*
/// signs in the same transaction via `PrivateAuthorizedInit` (proving control by supplying their
/// own nsk directly) — the ATA holding itself stays public, per the confirmed `PDA` finding above;
/// only the signing identity is private.
/// Private account owner can sign transactions.
#[test]
fn ata_burn_with_private_owner_signing() {
let mut state = V03State::new();
@@ -1001,13 +968,8 @@ fn ata_burn_with_private_owner_signing() {
.is_some());
}
// Marvin-todo
/// Composes the `GROUP` dimension with the signer-authorization finding just proven above: a
/// group-owned owner (GMS distributed through the real seal/unseal handshake, exactly as in
/// `token_group_owned_holding_shared_control`) signs an `ATA::Burn` via `PrivateAuthorizedInit`.
/// "Bob" — who only ever receives the sealed GMS, never Alice's `GroupKeyHolder` object —
/// independently re-derives the identical nsk/npk and successfully signs for the shared ATA
/// owner identity.
/// TODO: remove, this is essentially same as burn test. Worth noting though that
/// any member can sign.
#[test]
fn ata_group_owned_owner_signing() {
let mut state = V03State::new();
@@ -1130,3 +1092,297 @@ fn ata_group_owned_owner_signing() {
.get_proof_for_commitment(&Commitment::new(&owner_id, &owner_expected))
.is_some());
}
/// Private owner
#[test]
fn ata_transfer_with_private_owner_signing() {
let mut state = V03State::new();
deploy_programs(&mut state);
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
state.force_insert_account(Ids::recipient_ata(), Accounts::recipient_ata_init());
let owner_nsk: NullifierSecretKey = [95u8; 32];
let owner_npk = NullifierPublicKey::from(&owner_nsk);
let owner_vpk = ViewingPublicKey::from_seed(&[96u8; 32], &[97u8; 32]);
let owner_id = AccountId::for_regular_private_account(&owner_npk, 0);
// The ATA holding must stay public (per the confirmed PDA finding), so it's seeded
// directly rather than via a real `Create` transaction.
let seed = compute_ata_seed(Ids::token_program(), owner_id, Ids::token_definition());
let sender_ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed);
let sender_ata_account = Account {
program_owner: Ids::token_program(),
balance: 0_u128,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::token_definition(),
balance: 1_000_000_u128,
}),
nonce: Nonce(0),
};
state.force_insert_account(sender_ata_id, sender_ata_account.clone());
let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id);
let sender_ata_pre = AccountWithMetadata::new(sender_ata_account, false, sender_ata_id);
let recipient_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::recipient_ata()),
false,
Ids::recipient_ata(),
);
let transfer_amount = 400_000_u128;
let instruction = ata_core::Instruction::Transfer {
token_program_id: Ids::token_program(),
amount: transfer_amount,
};
let shared_secret = SharedSecretKey::encapsulate_deterministic(&owner_vpk, &[0u8; 32], 0).0;
let ata_program = Program::new(ata_methods::ATA_ELF.to_vec().into()).unwrap();
let token_program = Program::new(token_methods::TOKEN_ELF.to_vec().into()).unwrap();
let program_with_deps = ProgramWithDependencies::new(
ata_program,
HashMap::from([(Ids::token_program(), token_program)]),
);
let (output, proof) = execute_and_prove(
vec![owner_pre, sender_ata_pre, recipient_pre],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedInit {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&owner_npk, &owner_vpk),
ssk: shared_secret,
nsk: owner_nsk,
identifier: 0,
},
InputAccountIdentity::Public,
InputAccountIdentity::Public,
],
&program_with_deps,
)
.unwrap();
let message =
Message::try_from_circuit_output(vec![sender_ata_id, Ids::recipient_ata()], vec![], output)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
0,
0,
)
.unwrap();
assert_eq!(
state.get_account_by_id(sender_ata_id),
Account {
program_owner: Ids::token_program(),
balance: 0_u128,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::token_definition(),
balance: 1_000_000_u128 - transfer_amount,
}),
nonce: Nonce(0),
}
);
assert_eq!(
state.get_account_by_id(Ids::recipient_ata()),
Account {
program_owner: Ids::token_program(),
balance: 0_u128,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::token_definition(),
balance: transfer_amount,
}),
nonce: Nonce(0),
}
);
let owner_expected = Account {
nonce: Nonce::private_account_nonce_init(&owner_id),
..Account::default()
};
assert!(state
.get_proof_for_commitment(&Commitment::new(&owner_id, &owner_expected))
.is_some());
}
/// Group transfer is possible with group members added after the ATA is initialized.
#[test]
fn ata_transfer_with_group_owned_owner_signing() {
let mut state = V03State::new();
deploy_programs(&mut state);
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
state.force_insert_account(Ids::recipient_ata(), Accounts::recipient_ata_init());
let alice = GroupOwner::new([19_u8; 32]);
let owner_id = alice.id;
// The ATA holding must stay public (per the confirmed PDA finding), so it's seeded
// directly rather than via a real `Create` transaction.
let seed = compute_ata_seed(Ids::token_program(), owner_id, Ids::token_definition());
let sender_ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed);
let sender_ata_account = Account {
program_owner: Ids::token_program(),
balance: 0_u128,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::token_definition(),
balance: 1_000_000_u128,
}),
nonce: Nonce(0),
};
state.force_insert_account(sender_ata_id, sender_ata_account.clone());
let bob_nsk = alice.admit_member();
let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id);
let sender_ata_pre = AccountWithMetadata::new(sender_ata_account, false, sender_ata_id);
let recipient_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::recipient_ata()),
false,
Ids::recipient_ata(),
);
let transfer_amount = 400_000_u128;
let instruction = ata_core::Instruction::Transfer {
token_program_id: Ids::token_program(),
amount: transfer_amount,
};
let ata_program = Program::new(ata_methods::ATA_ELF.to_vec().into()).unwrap();
let token_program = Program::new(token_methods::TOKEN_ELF.to_vec().into()).unwrap();
let program_with_deps = ProgramWithDependencies::new(
ata_program,
HashMap::from([(Ids::token_program(), token_program)]),
);
let (output, proof) = execute_and_prove(
vec![owner_pre, sender_ata_pre, recipient_pre],
Program::serialize_instruction(instruction).unwrap(),
vec![
private_authorized_init_identity(bob_nsk, &alice.vpk, 0),
InputAccountIdentity::Public,
InputAccountIdentity::Public,
],
&program_with_deps,
)
.unwrap();
let message =
Message::try_from_circuit_output(vec![sender_ata_id, Ids::recipient_ata()], vec![], output)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
0,
0,
)
.unwrap();
assert_eq!(
state.get_account_by_id(sender_ata_id),
Account {
program_owner: Ids::token_program(),
balance: 0_u128,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::token_definition(),
balance: 1_000_000_u128 - transfer_amount,
}),
nonce: Nonce(0),
}
);
assert_eq!(
state.get_account_by_id(Ids::recipient_ata()),
Account {
program_owner: Ids::token_program(),
balance: 0_u128,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::token_definition(),
balance: transfer_amount,
}),
nonce: Nonce(0),
}
);
let owner_expected = Account {
nonce: Nonce::private_account_nonce_init(&owner_id),
..Account::default()
};
assert!(state
.get_proof_for_commitment(&Commitment::new(&owner_id, &owner_expected))
.is_some());
}
#[test]
fn ata_create_from_group_owned_owner() {
let mut state = V03State::new();
deploy_programs(&mut state);
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
let alice = GroupOwner::new([23_u8; 32]);
let owner_id = alice.id;
let seed = compute_ata_seed(Ids::token_program(), owner_id, Ids::token_definition());
let owner_ata_id = get_associated_token_account_id(&Ids::ata_program(), &seed);
let owner_pre = AccountWithMetadata::new(Account::default(), false, owner_id);
let def_pre = AccountWithMetadata::new(
state.get_account_by_id(Ids::token_definition()),
false,
Ids::token_definition(),
);
let ata_pre = AccountWithMetadata::new(Account::default(), false, owner_ata_id);
let instruction = ata_core::Instruction::Create {
token_program_id: Ids::token_program(),
};
let ata_program = Program::new(ata_methods::ATA_ELF.to_vec().into()).unwrap();
let token_program = Program::new(token_methods::TOKEN_ELF.to_vec().into()).unwrap();
let program_with_deps = ProgramWithDependencies::new(
ata_program,
HashMap::from([(Ids::token_program(), token_program)]),
);
let (output, proof) = execute_and_prove(
vec![owner_pre, def_pre, ata_pre],
Program::serialize_instruction(instruction).unwrap(),
vec![
private_unauthorized_identity(alice.npk, &alice.vpk, 0),
InputAccountIdentity::Public,
InputAccountIdentity::Public,
],
&program_with_deps,
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![Ids::token_definition(), owner_ata_id],
vec![],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
0,
0,
)
.unwrap();
assert_eq!(
state.get_account_by_id(owner_ata_id),
Account {
program_owner: Ids::token_program(),
balance: 0_u128,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::token_definition(),
balance: 0_u128,
}),
nonce: Nonce(0),
}
);
}
+962 -2
View File
@@ -1,8 +1,24 @@
use std::collections::HashMap;
use key_protocol::key_management::{
group_key_holder::{GroupKeyHolder, SealingPublicKey},
secret_holders::SecretSpendingKey,
};
use nssa::{
execute_and_prove,
privacy_preserving_transaction::{
circuit::ProgramWithDependencies, Message, PrivacyPreservingTransaction, WitnessSet,
},
program::Program,
program_deployment_transaction::{self, ProgramDeploymentTransaction},
public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State,
public_transaction, PrivateKey, PublicKey, PublicTransaction, SharedSecretKey, V03State,
};
use nssa_core::{
account::{Account, AccountId, AccountWithMetadata, Data, Nonce},
encryption::{EphemeralPublicKey, ViewingPublicKey},
Commitment, EncryptedAccountData, InputAccountIdentity, Nullifier, NullifierPublicKey,
NullifierSecretKey,
};
use nssa_core::account::{Account, AccountId, Data, Nonce};
use stablecoin_core::{compute_position_pda, compute_position_vault_pda, Position};
use token_core::{TokenDefinition, TokenHolding};
@@ -10,6 +26,41 @@ struct Keys;
struct Ids;
struct Balances;
struct Accounts;
struct PrivateKeys;
impl PrivateKeys {
fn destination_nsk() -> NullifierSecretKey {
[111; 32]
}
fn destination_npk() -> NullifierPublicKey {
NullifierPublicKey::from(&Self::destination_nsk())
}
fn destination_vpk() -> ViewingPublicKey {
ViewingPublicKey::from_seed(&[141; 32], &[142; 32])
}
fn destination_id() -> AccountId {
AccountId::for_regular_private_account(&Self::destination_npk(), 0)
}
fn stablecoin_holding_nsk() -> NullifierSecretKey {
[121; 32]
}
fn stablecoin_holding_npk() -> NullifierPublicKey {
NullifierPublicKey::from(&Self::stablecoin_holding_nsk())
}
fn stablecoin_holding_vpk() -> ViewingPublicKey {
ViewingPublicKey::from_seed(&[151; 32], &[152; 32])
}
fn stablecoin_holding_id() -> AccountId {
AccountId::for_regular_private_account(&Self::stablecoin_holding_npk(), 0)
}
}
impl Keys {
fn owner() -> PrivateKey {
@@ -398,3 +449,912 @@ fn stablecoin_repay_debt_burns_stablecoins_and_decreases_debt() {
}
}
}
fn stablecoin_program() -> Program {
Program::new(stablecoin_methods::STABLECOIN_ELF.to_vec().into()).expect("valid stablecoin ELF")
}
fn token_program_instance() -> Program {
Program::new(token_methods::TOKEN_ELF.to_vec().into()).expect("valid token ELF")
}
fn stablecoin_with_token_deps() -> ProgramWithDependencies {
ProgramWithDependencies::new(
stablecoin_program(),
HashMap::from([(Ids::token_program(), token_program_instance())]),
)
}
// Marvin-todo
/// `OpenPosition` cannot execute through the privacy-preserving transaction type *at all* —
/// confirmed here with every single account `Public` and zero private accounts involved. Root
/// cause traced in `lee_core`'s `execution_state.rs`: `authorized_accounts` is a monotonic/sticky
/// set — once an account is authorized via one chained call's `pda_seeds` match, every later
/// occurrence of that same account must also declare `is_authorized: true`, or
/// `assert_eq!(pre_is_authorized, is_authorized, "Inconsistent authorization for account {id}")`
/// fails. `open_position.rs` issues two chained calls that both reuse `vault`: the first
/// (`Token::InitializeAccount`) authorizes it via `pda_seeds`, sticking `vault` as authorized;
/// the second (`Token::Transfer`) then deliberately constructs `post_init_vault` with
/// `is_authorized: false` (a legitimate choice on the public-transaction path — "the recipient
/// is already initialized, so no second PDA claim is needed" per that file's own comment) — but
/// the privacy circuit rejects that as inconsistent. This is not a privacy-dimension gap; it
/// blocks `OpenPosition` from ever being expressed as a `PrivacyPreservingTransaction`, so every
/// other instruction that depends on having *opened* a position privately is affected too (see
/// `stablecoin_group_owned_position_owner`, which routes around it by seeding the position/vault
/// directly instead of calling `OpenPosition`).
#[test]
fn stablecoin_open_position_via_privacy_transaction_is_not_expressible() {
let mut state = V03State::new();
deploy_programs(&mut state);
state.force_insert_account(
Ids::collateral_definition(),
Accounts::collateral_definition_init(),
);
state.force_insert_account(Ids::user_holding(), Accounts::user_holding_init());
let owner_id = Ids::owner();
let position_id = compute_position_pda(
Ids::stablecoin_program(),
owner_id,
Ids::collateral_definition(),
);
let vault_id = compute_position_vault_pda(Ids::stablecoin_program(), position_id);
let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id);
let position_pre = AccountWithMetadata::new(Account::default(), false, position_id);
let vault_pre = AccountWithMetadata::new(Account::default(), false, vault_id);
let user_holding_pre =
AccountWithMetadata::new(Accounts::user_holding_init(), true, Ids::user_holding());
let definition_pre = AccountWithMetadata::new(
Accounts::collateral_definition_init(),
false,
Ids::collateral_definition(),
);
let collateral_amount = Balances::collateral_deposit();
let instruction = stablecoin_core::Instruction::OpenPosition { collateral_amount };
let result = execute_and_prove(
vec![
owner_pre,
position_pre,
vault_pre,
user_holding_pre,
definition_pre,
],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
],
&stablecoin_with_token_deps(),
);
let err = result.expect_err(
"OpenPosition must be rejected by the privacy-preserving circuit: vault's second \
chained-call occurrence declares is_authorized: false after already being marked \
authorized by the first chained call's pda_seeds match",
);
let message = format!("{err:?}");
assert!(
message.contains("Inconsistent authorization for account"),
"expected the authorization-consistency rejection, got a different error: {message}"
);
}
// Marvin-todo
/// `WithdrawCollateral` has only *one* chained call (`Token::Transfer`, reusing `vault` exactly
/// once), unlike `OpenPosition`'s two — so it should avoid the authorization-consistency
/// blocker confirmed above. Position/vault are seeded directly via `force_insert_account`
/// (public accounts, no real `OpenPosition` call needed, and none is possible per the finding
/// above). `withdraw_collateral.rs` hard-asserts `destination.account != Account::default()`,
/// so `destination` must already exist — same `EXIST` shape as ATA's Transfer, requiring the
/// destination's cooperation via `PrivateAuthorizedUpdate`.
#[test]
fn stablecoin_withdraw_collateral_private_destination() {
let mut state = V03State::new();
deploy_programs(&mut state);
state.force_insert_account(
Ids::collateral_definition(),
Accounts::collateral_definition_init(),
);
let owner_id = Ids::owner();
let position_id = compute_position_pda(
Ids::stablecoin_program(),
owner_id,
Ids::collateral_definition(),
);
let vault_id = compute_position_vault_pda(Ids::stablecoin_program(), position_id);
let position_collateral = 500_000_u128;
let withdraw_amount = 200_000_u128;
let position_account = Account {
program_owner: Ids::stablecoin_program(),
balance: 0,
data: Data::from(&Position {
collateral_vault_id: vault_id,
collateral_definition_id: Ids::collateral_definition(),
collateral_amount: position_collateral,
debt_amount: 0,
}),
nonce: Nonce(0),
};
let vault_account = Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::collateral_definition(),
balance: position_collateral,
}),
nonce: Nonce(0),
};
state.force_insert_account(position_id, position_account);
state.force_insert_account(vault_id, vault_account);
let destination_nsk = PrivateKeys::destination_nsk();
let destination_npk = PrivateKeys::destination_npk();
let destination_vpk = PrivateKeys::destination_vpk();
let destination_id = PrivateKeys::destination_id();
let destination_initial_balance = 100_000_u128;
let destination_account = Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::collateral_definition(),
balance: destination_initial_balance,
}),
nonce: Nonce::private_account_nonce_init(&destination_id),
};
state = state.with_private_accounts([(
Commitment::new(&destination_id, &destination_account),
Nullifier::for_account_initialization(&destination_id),
)]);
let membership_proof = state
.get_proof_for_commitment(&Commitment::new(&destination_id, &destination_account))
.expect("destination's commitment must be in the set");
let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id);
let position_pre =
AccountWithMetadata::new(state.get_account_by_id(position_id), false, position_id);
let vault_pre = AccountWithMetadata::new(state.get_account_by_id(vault_id), false, vault_id);
let destination_pre =
AccountWithMetadata::new(destination_account.clone(), true, destination_id);
let instruction = stablecoin_core::Instruction::WithdrawCollateral {
amount: withdraw_amount,
};
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&destination_vpk, &[0u8; 32], 0).0;
let (output, proof) = execute_and_prove(
vec![owner_pre, position_pre, vault_pre, destination_pre],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::PrivateAuthorizedUpdate {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(
&destination_npk,
&destination_vpk,
),
ssk: shared_secret,
nsk: destination_nsk,
membership_proof,
identifier: 0,
},
],
&stablecoin_with_token_deps(),
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![owner_id, position_id, vault_id],
vec![Nonce(0)],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::owner()]);
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
0,
0,
)
.unwrap();
let position =
Position::try_from(&state.get_account_by_id(position_id).data).expect("valid Position");
assert_eq!(
position.collateral_amount,
position_collateral - withdraw_amount
);
assert_eq!(position.debt_amount, 0);
match TokenHolding::try_from(&state.get_account_by_id(vault_id).data).expect("valid holding") {
TokenHolding::Fungible { balance, .. } => {
assert_eq!(balance, position_collateral - withdraw_amount);
}
TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => {
panic!("expected Fungible vault holding")
}
}
let destination_nonce_after = Nonce::private_account_nonce_init(&destination_id)
.private_account_nonce_increment(&destination_nsk);
let new_destination_account = Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::collateral_definition(),
balance: destination_initial_balance + withdraw_amount,
}),
nonce: destination_nonce_after,
};
assert!(state
.get_proof_for_commitment(&Commitment::new(&destination_id, &new_destination_account))
.is_some());
}
// Marvin-todo
/// `GROUP` variance on `stablecoin_withdraw_collateral_private_destination`: the destination is
/// group-owned instead of personal. The GMS is distributed through the real seal/unseal
/// handshake (as in `token_group_owned_holding_shared_control_burn`); "Bob" — who only ever
/// receives the sealed GMS — independently re-derives the shared destination's keys and
/// supplies its `PrivateAuthorizedUpdate` cooperation to receive the withdrawn collateral.
#[test]
fn stablecoin_withdraw_collateral_group_owned_destination() {
let mut state = V03State::new();
deploy_programs(&mut state);
state.force_insert_account(
Ids::collateral_definition(),
Accounts::collateral_definition_init(),
);
let owner_id = Ids::owner();
let position_id = compute_position_pda(
Ids::stablecoin_program(),
owner_id,
Ids::collateral_definition(),
);
let vault_id = compute_position_vault_pda(Ids::stablecoin_program(), position_id);
let position_collateral = 500_000_u128;
let withdraw_amount = 200_000_u128;
let position_account = Account {
program_owner: Ids::stablecoin_program(),
balance: 0,
data: Data::from(&Position {
collateral_vault_id: vault_id,
collateral_definition_id: Ids::collateral_definition(),
collateral_amount: position_collateral,
debt_amount: 0,
}),
nonce: Nonce(0),
};
let vault_account = Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::collateral_definition(),
balance: position_collateral,
}),
nonce: Nonce(0),
};
state.force_insert_account(position_id, position_account);
state.force_insert_account(vault_id, vault_account);
// Alice creates the group and derives the shared destination's keys.
let alice_holder = GroupKeyHolder::new();
let derivation_seed = [7_u8; 32];
let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed);
let destination_npk = alice_keys.generate_nullifier_public_key();
let destination_vpk = alice_keys.generate_viewing_public_key();
let destination_id = AccountId::for_regular_private_account(&destination_npk, 0);
// Alice distributes the GMS to Bob via the real seal/unseal handshake.
let bob_sealing_keys = SecretSpendingKey([9_u8; 32]).produce_private_key_holder(None);
let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key();
let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key;
let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes(
bob_sealing_vpk.to_bytes().to_vec(),
));
let bob_holder =
GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS");
let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed);
let bob_nsk = bob_keys.nullifier_secret_key;
assert_eq!(
bob_keys.generate_nullifier_public_key(),
destination_npk,
"Bob must derive the identical npk as Alice from the shared GMS"
);
let destination_initial_balance = 100_000_u128;
let destination_account = Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::collateral_definition(),
balance: destination_initial_balance,
}),
nonce: Nonce::private_account_nonce_init(&destination_id),
};
state = state.with_private_accounts([(
Commitment::new(&destination_id, &destination_account),
Nullifier::for_account_initialization(&destination_id),
)]);
let membership_proof = state
.get_proof_for_commitment(&Commitment::new(&destination_id, &destination_account))
.expect("destination's commitment must be in the set");
let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id);
let position_pre =
AccountWithMetadata::new(state.get_account_by_id(position_id), false, position_id);
let vault_pre = AccountWithMetadata::new(state.get_account_by_id(vault_id), false, vault_id);
let destination_pre =
AccountWithMetadata::new(destination_account.clone(), true, destination_id);
let instruction = stablecoin_core::Instruction::WithdrawCollateral {
amount: withdraw_amount,
};
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&destination_vpk, &[0u8; 32], 0).0;
let (output, proof) = execute_and_prove(
vec![owner_pre, position_pre, vault_pre, destination_pre],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::PrivateAuthorizedUpdate {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(
&destination_npk,
&destination_vpk,
),
ssk: shared_secret,
nsk: bob_nsk,
membership_proof,
identifier: 0,
},
],
&stablecoin_with_token_deps(),
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![owner_id, position_id, vault_id],
vec![Nonce(0)],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::owner()]);
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
0,
0,
)
.unwrap();
let position =
Position::try_from(&state.get_account_by_id(position_id).data).expect("valid Position");
assert_eq!(
position.collateral_amount,
position_collateral - withdraw_amount
);
let destination_nonce_after = Nonce::private_account_nonce_init(&destination_id)
.private_account_nonce_increment(&bob_nsk);
let new_destination_account = Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::collateral_definition(),
balance: destination_initial_balance + withdraw_amount,
}),
nonce: destination_nonce_after,
};
assert!(state
.get_proof_for_commitment(&Commitment::new(&destination_id, &new_destination_account))
.is_some());
}
// Marvin-todo
/// `user_stablecoin_holding` is private, burned via `RepayDebt`'s single chained `Token::Burn`.
/// Unlike ATA's own holdings (structurally locked to public PDAs), Stablecoin's stablecoin
/// holding is a regular user-controlled token holding with no PDA involved at all, so it's free
/// to be private with no structural obstacle. Position/stablecoin-definition are seeded
/// directly, matching the pre-existing public
/// `stablecoin_repay_debt_burns_stablecoins_and_decreases_debt` test's fixture approach (no real
/// `OpenPosition` call, consistent with the finding above).
#[test]
fn stablecoin_repay_debt_private_stablecoin_holding() {
let mut state = V03State::new();
deploy_programs(&mut state);
state.force_insert_account(
Ids::collateral_definition(),
Accounts::collateral_definition_init(),
);
state.force_insert_account(
Ids::stablecoin_definition(),
Accounts::stablecoin_definition_init(),
);
let owner_id = Ids::owner();
let position_id = compute_position_pda(
Ids::stablecoin_program(),
owner_id,
Ids::collateral_definition(),
);
let vault_id = compute_position_vault_pda(Ids::stablecoin_program(), position_id);
let position_collateral = Balances::collateral_deposit();
let initial_debt = Balances::initial_debt();
let repay_amount = Balances::debt_repay_amount();
let position_account = Account {
program_owner: Ids::stablecoin_program(),
balance: 0,
data: Data::from(&Position {
collateral_vault_id: vault_id,
collateral_definition_id: Ids::collateral_definition(),
collateral_amount: position_collateral,
debt_amount: initial_debt,
}),
nonce: Nonce(0),
};
state.force_insert_account(position_id, position_account);
let stablecoin_holding_nsk = PrivateKeys::stablecoin_holding_nsk();
let stablecoin_holding_npk = PrivateKeys::stablecoin_holding_npk();
let stablecoin_holding_vpk = PrivateKeys::stablecoin_holding_vpk();
let stablecoin_holding_id = PrivateKeys::stablecoin_holding_id();
let initial_stablecoin_balance = Balances::user_stablecoin_holding_init();
let stablecoin_holding_account = Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::stablecoin_definition(),
balance: initial_stablecoin_balance,
}),
nonce: Nonce::private_account_nonce_init(&stablecoin_holding_id),
};
state = state.with_private_accounts([(
Commitment::new(&stablecoin_holding_id, &stablecoin_holding_account),
Nullifier::for_account_initialization(&stablecoin_holding_id),
)]);
let membership_proof = state
.get_proof_for_commitment(&Commitment::new(
&stablecoin_holding_id,
&stablecoin_holding_account,
))
.expect("stablecoin holding's commitment must be in the set");
let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id);
let position_pre =
AccountWithMetadata::new(state.get_account_by_id(position_id), false, position_id);
let definition_pre = AccountWithMetadata::new(
Accounts::stablecoin_definition_init(),
false,
Ids::stablecoin_definition(),
);
let stablecoin_holding_pre = AccountWithMetadata::new(
stablecoin_holding_account.clone(),
true,
stablecoin_holding_id,
);
let instruction = stablecoin_core::Instruction::RepayDebt {
amount: repay_amount,
};
let shared_secret =
SharedSecretKey::encapsulate_deterministic(&stablecoin_holding_vpk, &[0u8; 32], 0).0;
let (output, proof) = execute_and_prove(
vec![
owner_pre,
position_pre,
definition_pre,
stablecoin_holding_pre,
],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::PrivateAuthorizedUpdate {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(
&stablecoin_holding_npk,
&stablecoin_holding_vpk,
),
ssk: shared_secret,
nsk: stablecoin_holding_nsk,
membership_proof,
identifier: 0,
},
],
&stablecoin_with_token_deps(),
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![owner_id, position_id, Ids::stablecoin_definition()],
vec![Nonce(0)],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::owner()]);
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
0,
0,
)
.unwrap();
let position =
Position::try_from(&state.get_account_by_id(position_id).data).expect("valid Position");
assert_eq!(position.debt_amount, initial_debt - repay_amount);
assert_eq!(position.collateral_amount, position_collateral);
match TokenDefinition::try_from(&state.get_account_by_id(Ids::stablecoin_definition()).data)
.expect("valid TokenDefinition")
{
TokenDefinition::Fungible { total_supply, .. } => {
assert_eq!(
total_supply,
Balances::stablecoin_supply_init() - repay_amount
);
}
_ => panic!("expected Fungible definition"),
}
let stablecoin_holding_nonce_after = Nonce::private_account_nonce_init(&stablecoin_holding_id)
.private_account_nonce_increment(&stablecoin_holding_nsk);
let new_stablecoin_holding_account = Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::stablecoin_definition(),
balance: initial_stablecoin_balance - repay_amount,
}),
nonce: stablecoin_holding_nonce_after,
};
assert!(state
.get_proof_for_commitment(&Commitment::new(
&stablecoin_holding_id,
&new_stablecoin_holding_account
))
.is_some());
}
// Marvin-todo
/// `GROUP` variance on `stablecoin_repay_debt_private_stablecoin_holding`: the stablecoin
/// holding being burned from is group-owned instead of personal. Same real seal/unseal
/// distribution as every other group test in this exercise; Bob independently re-derives the
/// shared holding's keys and supplies `PrivateAuthorizedUpdate` cooperation for the burn.
#[test]
fn stablecoin_repay_debt_group_owned_stablecoin_holding() {
let mut state = V03State::new();
deploy_programs(&mut state);
state.force_insert_account(
Ids::collateral_definition(),
Accounts::collateral_definition_init(),
);
state.force_insert_account(
Ids::stablecoin_definition(),
Accounts::stablecoin_definition_init(),
);
let owner_id = Ids::owner();
let position_id = compute_position_pda(
Ids::stablecoin_program(),
owner_id,
Ids::collateral_definition(),
);
let vault_id = compute_position_vault_pda(Ids::stablecoin_program(), position_id);
let position_collateral = Balances::collateral_deposit();
let initial_debt = Balances::initial_debt();
let repay_amount = Balances::debt_repay_amount();
let position_account = Account {
program_owner: Ids::stablecoin_program(),
balance: 0,
data: Data::from(&Position {
collateral_vault_id: vault_id,
collateral_definition_id: Ids::collateral_definition(),
collateral_amount: position_collateral,
debt_amount: initial_debt,
}),
nonce: Nonce(0),
};
state.force_insert_account(position_id, position_account);
// Alice creates the group and derives the shared stablecoin holding's keys.
let alice_holder = GroupKeyHolder::new();
let derivation_seed = [7_u8; 32];
let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed);
let holding_npk = alice_keys.generate_nullifier_public_key();
let holding_vpk = alice_keys.generate_viewing_public_key();
let holding_id = AccountId::for_regular_private_account(&holding_npk, 0);
// Alice distributes the GMS to Bob via the real seal/unseal handshake.
let bob_sealing_keys = SecretSpendingKey([9_u8; 32]).produce_private_key_holder(None);
let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key();
let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key;
let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes(
bob_sealing_vpk.to_bytes().to_vec(),
));
let bob_holder =
GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS");
let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed);
let bob_nsk = bob_keys.nullifier_secret_key;
assert_eq!(
bob_keys.generate_nullifier_public_key(),
holding_npk,
"Bob must derive the identical npk as Alice from the shared GMS"
);
let initial_stablecoin_balance = Balances::user_stablecoin_holding_init();
let holding_account = Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::stablecoin_definition(),
balance: initial_stablecoin_balance,
}),
nonce: Nonce::private_account_nonce_init(&holding_id),
};
state = state.with_private_accounts([(
Commitment::new(&holding_id, &holding_account),
Nullifier::for_account_initialization(&holding_id),
)]);
let membership_proof = state
.get_proof_for_commitment(&Commitment::new(&holding_id, &holding_account))
.expect("stablecoin holding's commitment must be in the set");
let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id);
let position_pre =
AccountWithMetadata::new(state.get_account_by_id(position_id), false, position_id);
let definition_pre = AccountWithMetadata::new(
Accounts::stablecoin_definition_init(),
false,
Ids::stablecoin_definition(),
);
let holding_pre = AccountWithMetadata::new(holding_account.clone(), true, holding_id);
let instruction = stablecoin_core::Instruction::RepayDebt {
amount: repay_amount,
};
let shared_secret = SharedSecretKey::encapsulate_deterministic(&holding_vpk, &[0u8; 32], 0).0;
let (output, proof) = execute_and_prove(
vec![owner_pre, position_pre, definition_pre, holding_pre],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::PrivateAuthorizedUpdate {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&holding_npk, &holding_vpk),
ssk: shared_secret,
nsk: bob_nsk,
membership_proof,
identifier: 0,
},
],
&stablecoin_with_token_deps(),
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![owner_id, position_id, Ids::stablecoin_definition()],
vec![Nonce(0)],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[&Keys::owner()]);
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
0,
0,
)
.unwrap();
let position =
Position::try_from(&state.get_account_by_id(position_id).data).expect("valid Position");
assert_eq!(position.debt_amount, initial_debt - repay_amount);
let holding_nonce_after =
Nonce::private_account_nonce_init(&holding_id).private_account_nonce_increment(&bob_nsk);
let new_holding_account = Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::stablecoin_definition(),
balance: initial_stablecoin_balance - repay_amount,
}),
nonce: holding_nonce_after,
};
assert!(state
.get_proof_for_commitment(&Commitment::new(&holding_id, &new_holding_account))
.is_some());
}
// Marvin-todo
/// Reframes what "group-owned position" actually means, given the findings above: the
/// *position/vault themselves* can never be private or group-owned (the `PDA` finding), and
/// they can't even be opened through a privacy-preserving transaction at all (the
/// authorization-consistency finding above). But `owner` is just an `AccountId` used for PDA
/// seed derivation and signer verification — it doesn't need to be a plain public keypair. So
/// the real, well-motivated test is: a group-derived `owner` identity controls a PDA-locked
/// position, even though the position/vault stay public. Position/vault are seeded directly
/// (bypassing the blocked `OpenPosition`); "Bob" — who only ever receives the sealed GMS —
/// self-initializes *and* signs the owner identity in one transaction via `PrivateAuthorizedInit`
/// (since this owner has never proven control before), then withdraws collateral through it.
/// Directly mirrors `ata_group_owned_owner_signing`'s precedent for a PDA-locked resource.
#[test]
fn stablecoin_group_owned_position_owner() {
let mut state = V03State::new();
deploy_programs(&mut state);
state.force_insert_account(
Ids::collateral_definition(),
Accounts::collateral_definition_init(),
);
state.force_insert_account(Ids::user_holding(), Accounts::user_holding_init());
// Alice creates the group and derives the shared owner identity's keys.
let alice_holder = GroupKeyHolder::new();
let derivation_seed = [7_u8; 32];
let alice_keys = alice_holder.derive_keys_for_shared_account(&derivation_seed);
let owner_npk = alice_keys.generate_nullifier_public_key();
let owner_id = AccountId::for_regular_private_account(&owner_npk, 0);
// Alice distributes the GMS to Bob via the real seal/unseal handshake.
let bob_sealing_keys = SecretSpendingKey([9_u8; 32]).produce_private_key_holder(None);
let bob_sealing_vpk = bob_sealing_keys.generate_viewing_public_key();
let bob_sealing_vsk = bob_sealing_keys.viewing_secret_key;
let sealed_gms = alice_holder.seal_for(&SealingPublicKey::from_bytes(
bob_sealing_vpk.to_bytes().to_vec(),
));
let bob_holder =
GroupKeyHolder::unseal(&sealed_gms, &bob_sealing_vsk).expect("Bob must unseal the GMS");
// Bob independently re-derives the same shared owner keys.
let bob_keys = bob_holder.derive_keys_for_shared_account(&derivation_seed);
let bob_nsk = bob_keys.nullifier_secret_key;
let bob_vpk = bob_keys.generate_viewing_public_key();
assert_eq!(
bob_keys.generate_nullifier_public_key(),
owner_npk,
"Bob must derive the identical npk as Alice from the shared GMS"
);
// Position/vault addresses are derived from the group-owned owner_id — still ordinary
// public PDAs (the seed formula doesn't care whether owner_id is public or private), seeded
// directly since OpenPosition can't be routed through the privacy circuit at all.
let position_id = compute_position_pda(
Ids::stablecoin_program(),
owner_id,
Ids::collateral_definition(),
);
let vault_id = compute_position_vault_pda(Ids::stablecoin_program(), position_id);
let position_collateral = 500_000_u128;
let withdraw_amount = 200_000_u128;
let position_account = Account {
program_owner: Ids::stablecoin_program(),
balance: 0,
data: Data::from(&Position {
collateral_vault_id: vault_id,
collateral_definition_id: Ids::collateral_definition(),
collateral_amount: position_collateral,
debt_amount: 0,
}),
nonce: Nonce(0),
};
let vault_account = Account {
program_owner: Ids::token_program(),
balance: 0,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::collateral_definition(),
balance: position_collateral,
}),
nonce: Nonce(0),
};
state.force_insert_account(position_id, position_account);
state.force_insert_account(vault_id, vault_account);
// Bob self-initializes and signs the owner identity in the same transaction, then
// withdraws collateral through it. Destination stays public to isolate what's under test:
// only the owner identity's privacy/sharing, nothing else.
let owner_pre = AccountWithMetadata::new(Account::default(), true, owner_id);
let position_pre =
AccountWithMetadata::new(state.get_account_by_id(position_id), false, position_id);
let vault_pre = AccountWithMetadata::new(state.get_account_by_id(vault_id), false, vault_id);
let destination_pre =
AccountWithMetadata::new(Accounts::user_holding_init(), false, Ids::user_holding());
let instruction = stablecoin_core::Instruction::WithdrawCollateral {
amount: withdraw_amount,
};
let shared_secret = SharedSecretKey::encapsulate_deterministic(&bob_vpk, &[0u8; 32], 0).0;
let (output, proof) = execute_and_prove(
vec![owner_pre, position_pre, vault_pre, destination_pre],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedInit {
epk: EphemeralPublicKey(Vec::new()),
view_tag: EncryptedAccountData::compute_view_tag(&owner_npk, &bob_vpk),
ssk: shared_secret,
nsk: bob_nsk,
identifier: 0,
},
InputAccountIdentity::Public,
InputAccountIdentity::Public,
InputAccountIdentity::Public,
],
&stablecoin_with_token_deps(),
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![position_id, vault_id, Ids::user_holding()],
vec![],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
0,
0,
)
.unwrap();
let position =
Position::try_from(&state.get_account_by_id(position_id).data).expect("valid Position");
assert_eq!(
position.collateral_amount,
position_collateral - withdraw_amount
);
match TokenHolding::try_from(&state.get_account_by_id(Ids::user_holding()).data)
.expect("valid holding")
{
TokenHolding::Fungible { balance, .. } => {
assert_eq!(balance, Balances::user_holding_init() + withdraw_amount);
}
TokenHolding::NftMaster { .. } | TokenHolding::NftPrintedCopy { .. } => {
panic!("expected Fungible destination holding")
}
}
let owner_expected = Account {
nonce: Nonce::private_account_nonce_init(&owner_id),
..Account::default()
};
assert!(state
.get_proof_for_commitment(&Commitment::new(&owner_id, &owner_expected))
.is_some());
}
File diff suppressed because it is too large Load Diff