feat(token): add mint authority model to token program

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.
This commit is contained in:
bristinWild
2026-07-02 19:19:23 +02:00
committed by r4bbit
parent 751d4ac530
commit fe4c7a96da
22 changed files with 1448 additions and 353 deletions
+8
View File
@@ -401,6 +401,7 @@ impl Accounts {
name: String::from("test"),
total_supply: Balances::token_a_supply(),
metadata_id: None,
authority: None,
}),
nonce: Nonce(0),
}
@@ -414,6 +415,7 @@ impl Accounts {
name: String::from("test"),
total_supply: Balances::token_b_supply(),
metadata_id: None,
authority: None,
}),
nonce: Nonce(0),
}
@@ -427,6 +429,7 @@ impl Accounts {
name: String::from("LP Token"),
total_supply: Balances::token_lp_supply(),
metadata_id: None,
authority: Some(Ids::token_lp_definition()),
}),
nonce: Nonce(0),
}
@@ -705,6 +708,7 @@ impl Accounts {
name: String::from("LP Token"),
total_supply: Balances::token_lp_supply_add(),
metadata_id: None,
authority: Some(Ids::token_lp_definition()),
}),
nonce: Nonce(0),
}
@@ -797,6 +801,7 @@ impl Accounts {
name: String::from("LP Token"),
total_supply: Balances::token_lp_supply_remove(),
metadata_id: None,
authority: Some(Ids::token_lp_definition()),
}),
nonce: Nonce(0),
}
@@ -810,6 +815,7 @@ impl Accounts {
name: String::from("LP Token"),
total_supply: 0,
metadata_id: None,
authority: Some(Ids::token_lp_definition()),
}),
nonce: Nonce(0),
}
@@ -902,6 +908,7 @@ impl Accounts {
name: String::from("LP Token"),
total_supply: Balances::lp_supply_init(),
metadata_id: None,
authority: Some(Ids::token_lp_definition()),
}),
nonce: Nonce(0),
}
@@ -1390,6 +1397,7 @@ fn fungible_total_supply(account: &Account) -> u128 {
name: _,
total_supply,
metadata_id: _,
authority: _,
} = definition
else {
panic!("expected fungible token definition")
+3
View File
@@ -84,6 +84,7 @@ impl Accounts {
name: String::from("Gold"),
total_supply: 1_000_000_u128,
metadata_id: None,
authority: None,
}),
nonce: Nonce(0),
}
@@ -121,6 +122,7 @@ impl Accounts {
name: String::from("Foreign Gold"),
total_supply: 1_000_000_u128,
metadata_id: None,
authority: None,
}),
nonce: Nonce(0),
}
@@ -495,6 +497,7 @@ fn ata_burn() {
name: String::from("Gold"),
total_supply: 700_000_u128,
metadata_id: None,
authority: None,
}),
nonce: Nonce(0),
}
@@ -108,6 +108,7 @@ impl Accounts {
name: String::from("Gold"),
total_supply: Balances::user_holding_init(),
metadata_id: None,
authority: None,
}),
nonce: Nonce(0),
}
@@ -133,6 +134,7 @@ impl Accounts {
name: String::from("DAI"),
total_supply: Balances::stablecoin_supply_init(),
metadata_id: None,
authority: None,
}),
nonce: Nonce(0),
}
+342
View File
@@ -28,6 +28,10 @@ impl Keys {
fn recipient_key() -> PrivateKey {
PrivateKey::try_new([12; 32]).expect("valid private key")
}
fn authority_key() -> PrivateKey {
PrivateKey::try_new([13; 32]).expect("valid private key")
}
}
impl Ids {
@@ -50,6 +54,10 @@ impl Ids {
fn recipient() -> AccountId {
AccountId::from(&PublicKey::new_from_private_key(&Keys::recipient_key()))
}
fn authority() -> AccountId {
AccountId::from(&PublicKey::new_from_private_key(&Keys::authority_key()))
}
}
impl Accounts {
@@ -61,6 +69,7 @@ impl Accounts {
name: String::from("Gold"),
total_supply: 1_000_000_u128,
metadata_id: None,
authority: Some(Ids::token_definition()),
}),
nonce: Nonce(0),
}
@@ -74,6 +83,7 @@ impl Accounts {
name: String::from("Gold"),
total_supply: 1_000_000_u128,
metadata_id: None,
authority: Some(Ids::token_definition()),
}),
nonce: Nonce(0),
}
@@ -102,6 +112,15 @@ impl Accounts {
nonce: Nonce(0),
}
}
fn authority_init() -> Account {
Account {
program_owner: Ids::token_program(),
balance: 0_u128,
data: Data::default(),
nonce: Nonce(0),
}
}
}
fn deploy_token(state: &mut V03State) {
@@ -118,6 +137,7 @@ fn state_for_token_tests() -> V03State {
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
state.force_insert_account(Ids::holder(), Accounts::holder_init());
state.force_insert_account(Ids::recipient(), Accounts::recipient_init());
state.force_insert_account(Ids::authority(), Accounts::authority_init());
state
}
@@ -126,6 +146,7 @@ fn state_for_token_tests_without_recipient() -> V03State {
deploy_token(&mut state);
state.force_insert_account(Ids::token_definition(), Accounts::token_definition_init());
state.force_insert_account(Ids::holder(), Accounts::holder_init());
state.force_insert_account(Ids::authority(), Accounts::authority_init());
state
}
@@ -137,6 +158,7 @@ fn token_new_fungible_definition() {
let instruction = token_core::Instruction::NewFungibleDefinition {
name: String::from("Gold"),
total_supply: 1_000_000_u128,
mint_authority: None,
};
let message = public_transaction::Message::try_new(
@@ -164,6 +186,7 @@ fn token_new_fungible_definition() {
name: String::from("Gold"),
total_supply: 1_000_000_u128,
metadata_id: None,
authority: None,
}),
nonce: Nonce(1),
}
@@ -415,6 +438,7 @@ fn token_burn() {
name: String::from("Gold"),
total_supply: 800_000_u128,
metadata_id: None,
authority: Some(Ids::token_definition()),
}),
nonce: Nonce(0),
}
@@ -464,6 +488,7 @@ fn token_mint() {
name: String::from("Gold"),
total_supply: 1_500_000_u128,
metadata_id: None,
authority: Some(Ids::token_definition()),
}),
nonce: Nonce(1),
}
@@ -585,6 +610,7 @@ fn token_mint_fresh_authorized_public_recipient() {
name: String::from("Gold"),
total_supply: 1_500_000_u128,
metadata_id: None,
authority: Some(Ids::token_definition()),
}),
nonce: Nonce(1),
}
@@ -915,3 +941,319 @@ fn token_deshielded_transfer() {
.get_proof_for_commitment(&Commitment::new(&sender_id, &new_sender_account))
.is_some());
}
#[test]
fn token_new_fungible_definition_with_authority() {
let mut state = V03State::new();
deploy_token(&mut state);
let authority_key: [u8; 32] = Ids::token_definition()
.as_ref()
.try_into()
.expect("AccountId is always 32 bytes");
let instruction = token_core::Instruction::NewFungibleDefinition {
name: String::from("AuthCoin"),
total_supply: 1_000_000_u128,
mint_authority: Some(AccountId::new(authority_key)),
};
let message = public_transaction::Message::try_new(
Ids::token_program(),
vec![Ids::token_definition(), Ids::holder()],
vec![Nonce(0), Nonce(0)],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(
&message,
&[&Keys::def_key(), &Keys::holder_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::token_definition()),
Account {
program_owner: Ids::token_program(),
balance: 0_u128,
data: Data::from(&TokenDefinition::Fungible {
name: String::from("AuthCoin"),
total_supply: 1_000_000_u128,
metadata_id: None,
authority: Some(AccountId::new(authority_key)),
}),
nonce: Nonce(1),
}
);
}
#[test]
fn token_set_authority_revoke() {
let mut state = V03State::new();
deploy_token(&mut state);
let authority_key: [u8; 32] = Ids::token_definition()
.as_ref()
.try_into()
.expect("AccountId is always 32 bytes");
// Create token with authority
let instruction = token_core::Instruction::NewFungibleDefinition {
name: String::from("AuthCoin"),
total_supply: 1_000_000_u128,
mint_authority: Some(AccountId::new(authority_key)),
};
let message = public_transaction::Message::try_new(
Ids::token_program(),
vec![Ids::token_definition(), Ids::holder()],
vec![Nonce(0), Nonce(0)],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(
&message,
&[&Keys::def_key(), &Keys::holder_key()],
);
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
// Seed the authority account so it can sign the revoke
state.force_insert_account(Ids::authority(), Accounts::authority_init());
// Revoke authority
let instruction = token_core::Instruction::SetAuthority {
new_authority: None,
};
let message = public_transaction::Message::try_new(
Ids::token_program(),
vec![Ids::token_definition()],
vec![Nonce(1)],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::def_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::token_definition()),
Account {
program_owner: Ids::token_program(),
balance: 0_u128,
data: Data::from(&TokenDefinition::Fungible {
name: String::from("AuthCoin"),
total_supply: 1_000_000_u128,
metadata_id: None,
authority: None,
}),
nonce: Nonce(2),
}
);
}
/// After the authority is rotated to an external key, that external key can rotate
/// or revoke again via `SetAuthorityWithAuthority` — signing as a distinct authority
/// account while the definition account does not sign.
#[test]
fn token_set_authority_with_authority_revokes() {
let mut state = V03State::new();
deploy_token(&mut state);
// Create with self-authority (definition is the initial mint authority).
let instruction = token_core::Instruction::NewFungibleDefinition {
name: String::from("RotCoin"),
total_supply: 1_000_000_u128,
mint_authority: Some(Ids::token_definition()),
};
let message = public_transaction::Message::try_new(
Ids::token_program(),
vec![Ids::token_definition(), Ids::holder()],
vec![Nonce(0), Nonce(0)],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(
&message,
&[&Keys::def_key(), &Keys::holder_key()],
);
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
// Rotate to the external authority via self-authority (def_key signs).
let instruction = token_core::Instruction::SetAuthority {
new_authority: Some(Ids::authority()),
};
let message = public_transaction::Message::try_new(
Ids::token_program(),
vec![Ids::token_definition()],
vec![Nonce(1)],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::def_key()]);
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
// Seed the external authority so it can sign.
state.force_insert_account(Ids::authority(), Accounts::authority_init());
// The external authority revokes via SetAuthorityWithAuthority. Accounts:
// [definition, authority]; only the authority signs.
let instruction = token_core::Instruction::SetAuthorityWithAuthority {
new_authority: None,
};
let message = public_transaction::Message::try_new(
Ids::token_program(),
vec![Ids::token_definition(), Ids::authority()],
vec![Nonce(0)],
instruction,
)
.unwrap();
let witness_set =
public_transaction::WitnessSet::for_message(&message, &[&Keys::authority_key()]);
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
let def = state.get_account_by_id(Ids::token_definition());
let stored = match TokenDefinition::try_from(&def.data).unwrap() {
TokenDefinition::Fungible { authority, .. } => authority,
_ => None,
};
assert_eq!(stored, None, "authority must be permanently revoked");
}
/// Integration test for RFP-001 authority rotation flow:
/// 1. Create a token where `Ids::token_definition()` is the initial mint authority
/// (self-authority).
/// 2. Rotate the mint authority to `Ids::authority()` (an external key).
/// 3. Verify that the new external authority can mint by presenting itself as a rest account.
/// 4. Verify that the OLD authority (def key) can no longer mint after rotation.
#[test]
fn token_rotate_authority_then_new_authority_can_mint() {
let mut state = V03State::new();
deploy_token(&mut state);
let authority_key: [u8; 32] = Ids::authority()
.as_ref()
.try_into()
.expect("AccountId is always 32 bytes");
// Step 1: Create token with self-authority (def account is initial mint authority).
let instruction = token_core::Instruction::NewFungibleDefinition {
name: String::from("RotCoin"),
total_supply: 1_000_000_u128,
mint_authority: Some(AccountId::new(
Ids::token_definition()
.as_ref()
.try_into()
.expect("AccountId is always 32 bytes"),
)),
};
let message = public_transaction::Message::try_new(
Ids::token_program(),
vec![Ids::token_definition(), Ids::holder()],
vec![Nonce(0), Nonce(0)],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(
&message,
&[&Keys::def_key(), &Keys::holder_key()],
);
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
// Step 2: Rotate mint authority from def_key to Ids::authority() (external key).
// Self-authority path: no rest accounts; def_key signs.
let instruction = token_core::Instruction::SetAuthority {
new_authority: Some(AccountId::new(authority_key)),
};
let message = public_transaction::Message::try_new(
Ids::token_program(),
vec![Ids::token_definition()],
vec![Nonce(1)],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::def_key()]);
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
// Verify the authority slot now holds Ids::authority().
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("RotCoin"),
total_supply: 1_000_000_u128,
metadata_id: None,
authority: Some(AccountId::new(authority_key)),
}),
nonce: Nonce(2),
}
);
// Seed the external authority account and the holder so they exist in state.
state.force_insert_account(Ids::authority(), Accounts::authority_init());
state.force_insert_account(Ids::holder(), Accounts::holder_init());
// Step 3: New external authority mints via MintWithAuthority, signing as a
// distinct authority account. Accounts: [definition, holder, authority].
let instruction = token_core::Instruction::MintWithAuthority {
amount_to_mint: 500_000_u128,
};
let message = public_transaction::Message::try_new(
Ids::token_program(),
vec![Ids::token_definition(), Ids::holder(), Ids::authority()],
vec![Nonce(0)],
instruction,
)
.unwrap();
let witness_set =
public_transaction::WitnessSet::for_message(&message, &[&Keys::authority_key()]);
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
// Verify total_supply increased and holder balance reflects the mint.
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("RotCoin"),
total_supply: 1_500_000_u128,
metadata_id: None,
authority: Some(AccountId::new(authority_key)),
}),
nonce: Nonce(2),
}
);
assert_eq!(
state.get_account_by_id(Ids::holder()),
Account {
program_owner: Ids::token_program(),
balance: 0_u128,
data: Data::from(&TokenHolding::Fungible {
definition_id: Ids::token_definition(),
balance: 1_500_000_u128,
}),
nonce: Nonce(0),
}
);
// Step 4: OLD authority (def_key self-authority path) must be rejected after rotation.
let instruction = token_core::Instruction::Mint {
amount_to_mint: 1_u128,
};
let message = public_transaction::Message::try_new(
Ids::token_program(),
vec![Ids::token_definition(), Ids::holder()],
vec![Nonce(0)],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&Keys::def_key()]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 0, 0);
assert!(
result.is_err(),
"Old authority must be rejected after rotation"
);
}