mirror of
https://github.com/logos-blockchain/lez-programs.git
synced 2026-07-03 05:29:50 +00:00
Add an optional mint authority to fungible tokens for controlled supply:
create with a designated minter, mint additional supply, rotate the
authority to a new key, or permanently revoke it to fix the supply.
The authority is stored inline on `TokenDefinition::Fungible` as
`authority: Option<AccountId>` (`Some(id)` = mintable by `id`, `None` =
fixed supply). Keeping it a plain `Option<AccountId>` rather than a custom
wrapper type leaves account state decodable by `spel inspect`; the
require/rotate/revoke guard logic lives inline in the handlers.
LEZ rejects a transaction that lists the same account id twice, so one
instruction cannot statically express both "the definition account is the
authority and signs" (self/PDA authority) and "a distinct rotated account
signs" (external authority) — they need opposite signer markers. Each
privileged operation is therefore split into a self and an external
variant:
- `Mint` / `SetAuthority` — the definition account is the signer.
- `MintWithAuthority` / `SetAuthorityWithAuthority` — a distinct authority
account is the signer; the definition account does not sign.
Creation via `NewFungibleDefinition { mint_authority, .. }`; an all-zero
authority id is rejected. The AMM's LP token uses self/PDA authority — its
stored authority is the LP definition PDA, minted only by the pool via
chained calls.
Covered by token unit tests and zkVM integration tests: creation with and
without an authority, self- and external-authority mint, rotation, and
external rotate/revoke. IDLs regenerated.
595 lines
19 KiB
Rust
595 lines
19 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use ata_core::{compute_ata_seed, get_associated_token_account_id};
|
|
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, SharedSecretKey, V03State,
|
|
};
|
|
use nssa_core::{
|
|
account::{Account, AccountId, AccountWithMetadata, Data, Nonce},
|
|
encryption::{EphemeralPublicKey, ViewingPublicKey},
|
|
EncryptedAccountData, InputAccountIdentity, NullifierPublicKey, NullifierSecretKey,
|
|
};
|
|
use token_core::{TokenDefinition, TokenHolding};
|
|
|
|
struct Keys;
|
|
struct Ids;
|
|
struct Accounts;
|
|
|
|
impl Keys {
|
|
fn def_key() -> PrivateKey {
|
|
PrivateKey::try_new([10; 32]).expect("valid private key")
|
|
}
|
|
|
|
fn owner_key() -> PrivateKey {
|
|
PrivateKey::try_new([11; 32]).expect("valid private key")
|
|
}
|
|
|
|
fn recipient_key() -> PrivateKey {
|
|
PrivateKey::try_new([12; 32]).expect("valid private key")
|
|
}
|
|
}
|
|
|
|
impl Ids {
|
|
fn token_program() -> nssa_core::program::ProgramId {
|
|
token_methods::TOKEN_ID
|
|
}
|
|
|
|
fn ata_program() -> nssa_core::program::ProgramId {
|
|
ata_methods::ATA_ID
|
|
}
|
|
|
|
fn token_definition() -> AccountId {
|
|
AccountId::from(&PublicKey::new_from_private_key(&Keys::def_key()))
|
|
}
|
|
|
|
fn owner() -> AccountId {
|
|
AccountId::from(&PublicKey::new_from_private_key(&Keys::owner_key()))
|
|
}
|
|
|
|
fn recipient() -> AccountId {
|
|
AccountId::from(&PublicKey::new_from_private_key(&Keys::recipient_key()))
|
|
}
|
|
|
|
fn owner_ata() -> AccountId {
|
|
let seed = compute_ata_seed(
|
|
Self::token_program(),
|
|
Self::owner(),
|
|
Self::token_definition(),
|
|
);
|
|
get_associated_token_account_id(&Self::ata_program(), &seed)
|
|
}
|
|
|
|
fn recipient_ata() -> AccountId {
|
|
let seed = compute_ata_seed(
|
|
Self::token_program(),
|
|
Self::recipient(),
|
|
Self::token_definition(),
|
|
);
|
|
get_associated_token_account_id(&Self::ata_program(), &seed)
|
|
}
|
|
}
|
|
|
|
impl Accounts {
|
|
fn token_definition_init() -> Account {
|
|
Account {
|
|
program_owner: Ids::token_program(),
|
|
balance: 0_u128,
|
|
data: Data::from(&TokenDefinition::Fungible {
|
|
name: String::from("Gold"),
|
|
total_supply: 1_000_000_u128,
|
|
metadata_id: None,
|
|
authority: None,
|
|
}),
|
|
nonce: Nonce(0),
|
|
}
|
|
}
|
|
|
|
fn owner_ata_init() -> 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),
|
|
}
|
|
}
|
|
|
|
fn recipient_ata_init() -> Account {
|
|
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),
|
|
}
|
|
}
|
|
|
|
fn foreign_owned_token_definition() -> Account {
|
|
Account {
|
|
program_owner: [99; 8],
|
|
balance: 0_u128,
|
|
data: Data::from(&TokenDefinition::Fungible {
|
|
name: String::from("Foreign Gold"),
|
|
total_supply: 1_000_000_u128,
|
|
metadata_id: None,
|
|
authority: None,
|
|
}),
|
|
nonce: Nonce(0),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn deploy_programs(state: &mut V03State) {
|
|
let token_message =
|
|
program_deployment_transaction::Message::new(token_methods::TOKEN_ELF.to_vec());
|
|
state
|
|
.transition_from_program_deployment_transaction(&ProgramDeploymentTransaction::new(
|
|
token_message,
|
|
))
|
|
.expect("token program deployment must succeed");
|
|
|
|
let ata_message = program_deployment_transaction::Message::new(ata_methods::ATA_ELF.to_vec());
|
|
state
|
|
.transition_from_program_deployment_transaction(&ProgramDeploymentTransaction::new(
|
|
ata_message,
|
|
))
|
|
.expect("ata program deployment must succeed");
|
|
}
|
|
|
|
fn state_for_ata_tests() -> V03State {
|
|
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::owner_ata(), Accounts::owner_ata_init());
|
|
state
|
|
}
|
|
|
|
fn state_for_ata_tests_with_precreated_recipient_ata() -> V03State {
|
|
let mut state = state_for_ata_tests();
|
|
state.force_insert_account(Ids::recipient_ata(), Accounts::recipient_ata_init());
|
|
state
|
|
}
|
|
|
|
#[test]
|
|
fn ata_create() {
|
|
let mut state = V03State::new();
|
|
deploy_programs(&mut state);
|
|
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
|
|
|
|
let instruction = ata_core::Instruction::Create {
|
|
token_program_id: Ids::token_program(),
|
|
};
|
|
|
|
let message = public_transaction::Message::try_new(
|
|
Ids::ata_program(),
|
|
vec![Ids::owner(), Ids::token_definition(), Ids::owner_ata()],
|
|
vec![Nonce(0)],
|
|
instruction,
|
|
)
|
|
.unwrap();
|
|
|
|
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
|
|
|
let tx = PublicTransaction::new(message, witness_set);
|
|
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
|
|
|
|
assert_eq!(
|
|
state.get_account_by_id(Ids::owner_ata()),
|
|
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),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ata_create_is_idempotent() {
|
|
let mut state = state_for_ata_tests();
|
|
|
|
let instruction = ata_core::Instruction::Create {
|
|
token_program_id: Ids::token_program(),
|
|
};
|
|
|
|
let message = public_transaction::Message::try_new(
|
|
Ids::ata_program(),
|
|
vec![Ids::owner(), Ids::token_definition(), Ids::owner_ata()],
|
|
vec![Nonce(0)],
|
|
instruction,
|
|
)
|
|
.unwrap();
|
|
|
|
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
|
|
|
let tx = PublicTransaction::new(message, witness_set);
|
|
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
|
|
|
|
// Already initialized — should remain unchanged
|
|
assert_eq!(
|
|
state.get_account_by_id(Ids::owner_ata()),
|
|
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),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ata_create_rejects_definition_owned_by_unexpected_token_program() {
|
|
let mut state = V03State::new();
|
|
deploy_programs(&mut state);
|
|
state.force_insert_account(
|
|
Ids::token_definition(),
|
|
Accounts::foreign_owned_token_definition(),
|
|
);
|
|
|
|
let instruction = ata_core::Instruction::Create {
|
|
token_program_id: Ids::token_program(),
|
|
};
|
|
|
|
let message = public_transaction::Message::try_new(
|
|
Ids::ata_program(),
|
|
vec![Ids::owner(), Ids::token_definition(), Ids::owner_ata()],
|
|
vec![Nonce(0)],
|
|
instruction,
|
|
)
|
|
.unwrap();
|
|
|
|
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
|
|
|
let tx = PublicTransaction::new(message, witness_set);
|
|
assert!(state.transition_from_public_transaction(&tx, 0, 0).is_err());
|
|
assert_eq!(
|
|
state.get_account_by_id(Ids::owner_ata()),
|
|
Account::default()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ata_create_rejects_existing_ata_owned_by_unexpected_token_program() {
|
|
let mut state = V03State::new();
|
|
deploy_programs(&mut state);
|
|
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
|
|
|
|
let mut foreign_ata = Accounts::owner_ata_init();
|
|
foreign_ata.program_owner = [99; 8];
|
|
state.force_insert_account(Ids::owner_ata(), foreign_ata.clone());
|
|
|
|
let instruction = ata_core::Instruction::Create {
|
|
token_program_id: Ids::token_program(),
|
|
};
|
|
|
|
let message = public_transaction::Message::try_new(
|
|
Ids::ata_program(),
|
|
vec![Ids::owner(), Ids::token_definition(), Ids::owner_ata()],
|
|
vec![Nonce(0)],
|
|
instruction,
|
|
)
|
|
.unwrap();
|
|
|
|
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
|
|
|
let tx = PublicTransaction::new(message, witness_set);
|
|
assert!(state.transition_from_public_transaction(&tx, 0, 0).is_err());
|
|
assert_eq!(state.get_account_by_id(Ids::owner_ata()), foreign_ata);
|
|
}
|
|
|
|
#[test]
|
|
fn ata_create_rejects_existing_ata_with_mismatched_definition() {
|
|
let mut state = V03State::new();
|
|
deploy_programs(&mut state);
|
|
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
|
|
|
|
let mut mismatched_ata = Accounts::owner_ata_init();
|
|
mismatched_ata.data = Data::from(&TokenHolding::Fungible {
|
|
definition_id: Ids::recipient(),
|
|
balance: 1_000_000_u128,
|
|
});
|
|
state.force_insert_account(Ids::owner_ata(), mismatched_ata.clone());
|
|
|
|
let instruction = ata_core::Instruction::Create {
|
|
token_program_id: Ids::token_program(),
|
|
};
|
|
|
|
let message = public_transaction::Message::try_new(
|
|
Ids::ata_program(),
|
|
vec![Ids::owner(), Ids::token_definition(), Ids::owner_ata()],
|
|
vec![Nonce(0)],
|
|
instruction,
|
|
)
|
|
.unwrap();
|
|
|
|
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
|
|
|
let tx = PublicTransaction::new(message, witness_set);
|
|
assert!(state.transition_from_public_transaction(&tx, 0, 0).is_err());
|
|
assert_eq!(state.get_account_by_id(Ids::owner_ata()), mismatched_ata);
|
|
}
|
|
|
|
#[test]
|
|
fn ata_transfer() {
|
|
let mut state = state_for_ata_tests_with_precreated_recipient_ata();
|
|
|
|
let instruction = ata_core::Instruction::Transfer {
|
|
token_program_id: Ids::token_program(),
|
|
amount: 400_000_u128,
|
|
};
|
|
|
|
let message = public_transaction::Message::try_new(
|
|
Ids::ata_program(),
|
|
vec![Ids::owner(), Ids::owner_ata(), Ids::recipient_ata()],
|
|
vec![Nonce(0)],
|
|
instruction,
|
|
)
|
|
.unwrap();
|
|
|
|
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
|
|
|
let tx = PublicTransaction::new(message, witness_set);
|
|
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
|
|
|
|
assert_eq!(
|
|
state.get_account_by_id(Ids::owner_ata()),
|
|
Account {
|
|
program_owner: Ids::token_program(),
|
|
balance: 0_u128,
|
|
data: Data::from(&TokenHolding::Fungible {
|
|
definition_id: Ids::token_definition(),
|
|
balance: 600_000_u128,
|
|
}),
|
|
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: 400_000_u128,
|
|
}),
|
|
nonce: Nonce(0),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ata_transfer_rejects_default_recipient() {
|
|
let mut state = state_for_ata_tests();
|
|
|
|
let instruction = ata_core::Instruction::Transfer {
|
|
token_program_id: Ids::token_program(),
|
|
amount: 1_u128,
|
|
};
|
|
|
|
let message = public_transaction::Message::try_new(
|
|
Ids::ata_program(),
|
|
vec![Ids::owner(), Ids::owner_ata(), Ids::recipient_ata()],
|
|
vec![Nonce(0)],
|
|
instruction,
|
|
)
|
|
.unwrap();
|
|
|
|
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
|
|
|
let tx = PublicTransaction::new(message, witness_set);
|
|
assert!(state.transition_from_public_transaction(&tx, 0, 0).is_err());
|
|
|
|
assert_eq!(
|
|
state.get_account_by_id(Ids::owner_ata()),
|
|
Accounts::owner_ata_init()
|
|
);
|
|
assert_eq!(
|
|
state.get_account_by_id(Ids::recipient_ata()),
|
|
Account::default()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ata_transfer_rejects_mismatched_definition_recipient() {
|
|
let mut state = state_for_ata_tests_with_precreated_recipient_ata();
|
|
|
|
// Replace the recipient ATA with a token holding pointing at a different definition.
|
|
let foreign_definition_id = AccountId::from(&PublicKey::new_from_private_key(
|
|
&PrivateKey::try_new([42; 32]).expect("valid private key"),
|
|
));
|
|
let mismatched_recipient = Account {
|
|
program_owner: Ids::token_program(),
|
|
balance: 0_u128,
|
|
data: Data::from(&TokenHolding::Fungible {
|
|
definition_id: foreign_definition_id,
|
|
balance: 0_u128,
|
|
}),
|
|
nonce: Nonce(0),
|
|
};
|
|
state.force_insert_account(Ids::recipient_ata(), mismatched_recipient.clone());
|
|
|
|
let instruction = ata_core::Instruction::Transfer {
|
|
token_program_id: Ids::token_program(),
|
|
amount: 1_u128,
|
|
};
|
|
|
|
let message = public_transaction::Message::try_new(
|
|
Ids::ata_program(),
|
|
vec![Ids::owner(), Ids::owner_ata(), Ids::recipient_ata()],
|
|
vec![Nonce(0)],
|
|
instruction,
|
|
)
|
|
.unwrap();
|
|
|
|
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
|
|
|
let tx = PublicTransaction::new(message, witness_set);
|
|
assert!(state.transition_from_public_transaction(&tx, 0, 0).is_err());
|
|
|
|
assert_eq!(
|
|
state.get_account_by_id(Ids::owner_ata()),
|
|
Accounts::owner_ata_init()
|
|
);
|
|
assert_eq!(
|
|
state.get_account_by_id(Ids::recipient_ata()),
|
|
mismatched_recipient
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ata_burn() {
|
|
let mut state = state_for_ata_tests();
|
|
|
|
let instruction = ata_core::Instruction::Burn {
|
|
token_program_id: Ids::token_program(),
|
|
amount: 300_000_u128,
|
|
};
|
|
|
|
let message = public_transaction::Message::try_new(
|
|
Ids::ata_program(),
|
|
vec![Ids::owner(), Ids::owner_ata(), Ids::token_definition()],
|
|
vec![Nonce(0)],
|
|
instruction,
|
|
)
|
|
.unwrap();
|
|
|
|
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::owner_key()]);
|
|
|
|
let tx = PublicTransaction::new(message, witness_set);
|
|
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
|
|
|
|
assert_eq!(
|
|
state.get_account_by_id(Ids::owner_ata()),
|
|
Account {
|
|
program_owner: Ids::token_program(),
|
|
balance: 0_u128,
|
|
data: Data::from(&TokenHolding::Fungible {
|
|
definition_id: Ids::token_definition(),
|
|
balance: 700_000_u128,
|
|
}),
|
|
nonce: Nonce(0),
|
|
}
|
|
);
|
|
|
|
assert_eq!(
|
|
state.get_account_by_id(Ids::token_definition()),
|
|
Account {
|
|
program_owner: Ids::token_program(),
|
|
balance: 0_u128,
|
|
data: Data::from(&TokenDefinition::Fungible {
|
|
name: String::from("Gold"),
|
|
total_supply: 700_000_u128,
|
|
metadata_id: None,
|
|
authority: None,
|
|
}),
|
|
nonce: Nonce(0),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ata_create_from_private_owner() {
|
|
let mut state = V03State::new();
|
|
deploy_programs(&mut state);
|
|
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
|
|
|
|
// Private owner key material
|
|
let owner_nsk: NullifierSecretKey = [13u8; 32];
|
|
let owner_npk = NullifierPublicKey::from(&owner_nsk);
|
|
// `ViewingPublicKey::from_seed` needs two 32-byte halves `(d, z)`.
|
|
let owner_vpk = ViewingPublicKey::from_seed(&[31u8; 32], &[32u8; 32]);
|
|
let owner_id = AccountId::for_regular_private_account(&owner_npk, 0);
|
|
|
|
// ATA derived from the private owner
|
|
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);
|
|
|
|
// Pre-states: private uninitialized owner, public token definition, public uninitialized ATA.
|
|
let owner_pre = AccountWithMetadata::new(Account::default(), false, owner_id);
|
|
let def_pre = AccountWithMetadata::new(
|
|
Accounts::token_definition_init(),
|
|
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 instruction_data = Program::serialize_instruction(instruction).unwrap();
|
|
|
|
// Encapsulate a shared secret against the owner's viewing key; the circuit fills the EPK.
|
|
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, def_pre, ata_pre],
|
|
instruction_data,
|
|
vec![
|
|
// owner: new private account, not owned/spent by the caller (no nsk, no proof).
|
|
InputAccountIdentity::PrivateUnauthorized {
|
|
epk: EphemeralPublicKey(Vec::new()),
|
|
view_tag: EncryptedAccountData::compute_view_tag(&owner_npk, &owner_vpk),
|
|
npk: owner_npk,
|
|
ssk: shared_secret,
|
|
identifier: 0,
|
|
},
|
|
// token_definition: public
|
|
InputAccountIdentity::Public,
|
|
// ata: 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, &[]);
|
|
let tx = PrivacyPreservingTransaction::new(message, witness_set);
|
|
state
|
|
.transition_from_privacy_preserving_transaction(&tx, 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),
|
|
}
|
|
);
|
|
}
|