fix(token): support external mint authority; intentional metadata supply; demo path resolver

Addresses review on PR #125 (LP-0013):

#1 authority transfer now hands control to the new signer. mint and
set_authority take a trailing authority_accounts (Vec<AccountWithMetadata>):
empty preserves the original self/PDA-authority behavior (AMM unchanged),
one entry lets an external/rotated authority actually mint or rotate again.
Tests: rotated_authority_can_mint, rotated_authority_old_key_cannot_mint.

#2 metadata-backed fungibles take a real mint_authority instead of a
hardcoded Authority::renounced(), matching the plain-fungible supply model.
Test: test_metadata_fungible_with_authority_is_mintable.

#3 demo-full-flow.sh resolves TOKEN_BIN from the README-documented
cargo risczero build output, falling back to the workspace build, with an
explicit TOKEN_BIN override still respected.

Regenerated token-idl.json for the new trailing authority_accounts.
This commit is contained in:
bristinWild 2026-06-19 17:51:56 +05:30
parent dd8328cf9f
commit 160ff8ee4a
8 changed files with 238 additions and 28 deletions

View File

@ -150,6 +150,13 @@
"writable": true,
"signer": false,
"init": false
},
{
"name": "authority_accounts",
"writable": false,
"signer": false,
"init": false,
"rest": true
}
],
"args": [
@ -167,6 +174,13 @@
"writable": false,
"signer": false,
"init": false
},
{
"name": "authority_accounts",
"writable": false,
"signer": false,
"init": false,
"rest": true
}
],
"args": [

View File

@ -90,6 +90,9 @@ pub enum NewTokenDefinition {
Fungible {
name: String,
total_supply: u128,
/// Mint authority. `Some(id)` makes the token mintable by `id`; `None`
/// fixes the supply.
mint_authority: Option<AccountId>,
},
NonFungible {
name: String,

View File

@ -128,6 +128,7 @@ mod token {
#[account(mut, signer)]
definition_account: AccountWithMetadata,
user_holding_account: AccountWithMetadata,
authority_accounts: Vec<AccountWithMetadata>,
amount_to_mint: u128,
) -> SpelResult {
Ok(spel_framework::SpelOutput::execute(
@ -135,6 +136,7 @@ mod token {
definition_account,
user_holding_account,
amount_to_mint,
authority_accounts,
ctx.self_program_id,
),
vec![],
@ -147,10 +149,15 @@ mod token {
#[instruction]
pub fn set_authority(
definition_account: AccountWithMetadata,
authority_accounts: Vec<AccountWithMetadata>,
new_authority: Option<AccountId>,
) -> SpelResult {
Ok(spel_framework::SpelOutput::execute(
token_program::set_authority::set_authority(definition_account, new_authority),
token_program::set_authority::set_authority(
definition_account,
new_authority,
authority_accounts,
),
vec![],
))
}

View File

@ -9,6 +9,7 @@ pub fn mint(
definition_account: AccountWithMetadata,
user_holding_account: AccountWithMetadata,
amount_to_mint: u128,
authority_accounts: Vec<AccountWithMetadata>,
token_program_id: ProgramId,
) -> Vec<AccountPostState> {
assert_eq!(
@ -19,17 +20,22 @@ pub fn mint(
let mut definition = TokenDefinition::try_from(&definition_account.account.data)
.expect("Token Definition account must be valid");
// Minting is gated on the definition's mint authority: the definition account
// must be authorized in this transaction and its id must match the stored
// authority. This holds for an external owner that signs the definition key,
// and for a program-controlled PDA authorized via its seeds (e.g. the AMM's
// pool definition minting LP tokens).
// Minting is gated on the definition's stored mint authority. The proof of
// authority is whichever account is presented as authorized AND whose id
// matches the stored authority:
//
// - When `authority_accounts` is empty, the definition account itself must be the authority
// (self/PDA authority — e.g. the AMM's LP definition minting under its own seed). This is the
// original mint behavior.
// - When `authority_accounts` has one entry, that account is the external authority (e.g. a
// rotated owner key). This lets a transferred authority actually mint, as RFP-001 requires.
if let TokenDefinition::Fungible { .. } = &definition {
let authority = authority_accounts.first().unwrap_or(&definition_account);
assert!(
definition_account.is_authorized,
authority.is_authorized,
"Mint authority must authorize the transaction"
);
let signer: [u8; 32] = definition_account
let signer: [u8; 32] = authority
.account_id
.as_ref()
.try_into()
@ -88,8 +94,17 @@ pub fn mint(
let mut holding_post = user_holding_account.account;
holding_post.data = Data::from(&holding);
vec![
AccountPostState::new(definition_post),
AccountPostState::new_claimed_if_default(holding_post, Claim::Authorized),
]
// Post-states must match pre-state order and count. Pre-state order is
// [definition, holding, ...authority_accounts]; authority accounts are
// read-only and pass through unchanged.
let mut post_states = Vec::with_capacity(authority_accounts.len().saturating_add(2));
post_states.push(AccountPostState::new(definition_post));
post_states.push(AccountPostState::new_claimed_if_default(
holding_post,
Claim::Authorized,
));
for authority in authority_accounts {
post_states.push(AccountPostState::new(authority.account));
}
post_states
}

View File

@ -116,12 +116,16 @@ pub fn new_definition_with_metadata(
);
let (token_definition, token_holding) = match new_definition {
NewTokenDefinition::Fungible { name, total_supply } => (
NewTokenDefinition::Fungible {
name,
total_supply,
mint_authority,
} => (
TokenDefinition::Fungible {
name,
total_supply,
metadata_id: Some(metadata_target_account.account_id),
authority: Authority::renounced(),
authority: authority_from(mint_authority),
},
TokenHolding::Fungible {
definition_id: definition_target_account.account_id,

View File

@ -8,20 +8,23 @@ use token_core::TokenDefinition;
pub fn set_authority(
definition_account: AccountWithMetadata,
new_authority: Option<AccountId>,
authority_accounts: Vec<AccountWithMetadata>,
) -> Vec<AccountPostState> {
let mut definition = TokenDefinition::try_from(&definition_account.account.data)
.expect("Token Definition account must be valid");
match &mut definition {
TokenDefinition::Fungible { .. } => {
// The current mint authority must authorize this transaction: the
// definition account must be authorized and its id must match the
// stored authority.
// The current mint authority must authorize this transaction. As in
// `mint`, the proof is either the definition account itself (empty
// `authority_accounts`, self/PDA authority) or an explicit external
// authority account (one entry), so a rotated authority can act.
let authority = authority_accounts.first().unwrap_or(&definition_account);
assert!(
definition_account.is_authorized,
authority.is_authorized,
"Mint authority must authorize the transaction"
);
let signer: [u8; 32] = definition_account
let signer: [u8; 32] = authority
.account_id
.as_ref()
.try_into()
@ -56,5 +59,11 @@ pub fn set_authority(
let mut definition_post = definition_account.account;
definition_post.data = Data::from(&definition);
vec![AccountPostState::new(definition_post)]
// Post-states match pre-state order/count: [definition, ...authority_accounts].
let mut post_states = Vec::with_capacity(authority_accounts.len().saturating_add(1));
post_states.push(AccountPostState::new(definition_post));
for authority in authority_accounts {
post_states.push(AccountPostState::new(authority.account));
}
post_states
}

View File

@ -911,6 +911,7 @@ fn test_mint_not_valid_holding_account() {
definition_account,
holding_account,
BalanceForTests::mint_success(),
vec![],
TOKEN_PROGRAM_ID,
);
}
@ -924,6 +925,7 @@ fn test_mint_not_valid_definition_account() {
definition_account,
holding_account,
BalanceForTests::mint_success(),
vec![],
TOKEN_PROGRAM_ID,
);
}
@ -939,6 +941,7 @@ fn test_mint_missing_authorization() {
definition_account,
holding_account,
BalanceForTests::mint_success(),
vec![],
TOKEN_PROGRAM_ID,
);
}
@ -952,6 +955,7 @@ fn test_mint_rejects_foreign_owned_definition() {
definition_account,
holding_account,
BalanceForTests::mint_success(),
vec![],
TOKEN_PROGRAM_ID,
);
}
@ -966,6 +970,7 @@ fn test_mint_mismatched_token_definition() {
definition_account,
holding_account,
BalanceForTests::mint_success(),
vec![],
TOKEN_PROGRAM_ID,
);
}
@ -978,6 +983,7 @@ fn test_mint_success() {
definition_account,
holding_account,
BalanceForTests::mint_success(),
vec![],
TOKEN_PROGRAM_ID,
);
@ -1003,6 +1009,7 @@ fn test_mint_uninit_holding_success() {
definition_account,
holding_account,
BalanceForTests::mint_success(),
vec![],
TOKEN_PROGRAM_ID,
);
@ -1029,6 +1036,7 @@ fn test_mint_total_supply_overflow() {
definition_account,
holding_account,
BalanceForTests::mint_overflow(),
vec![],
TOKEN_PROGRAM_ID,
);
}
@ -1042,6 +1050,7 @@ fn test_mint_holding_account_overflow() {
definition_account,
holding_account,
BalanceForTests::mint_overflow(),
vec![],
TOKEN_PROGRAM_ID,
);
}
@ -1055,6 +1064,7 @@ fn test_mint_cannot_mint_unmintable_tokens() {
definition_account,
holding_account,
BalanceForTests::mint_success(),
vec![],
TOKEN_PROGRAM_ID,
);
}
@ -1067,6 +1077,7 @@ fn test_new_definition_with_metadata_success() {
let new_definition = NewTokenDefinition::Fungible {
name: String::from("test"),
total_supply: 15u128,
mint_authority: None,
};
let metadata = NewTokenMetadata {
standard: MetadataStandard::Simple,
@ -1088,6 +1099,42 @@ fn test_new_definition_with_metadata_success() {
assert_eq!(metadata_post.required_claim(), Some(Claim::Authorized));
}
/// Comment #2: a metadata-backed fungible created with `mint_authority: Some(..)`
/// carries a real, non-renounced authority and is therefore mintable — no longer
/// force-fixed-supply the way the hardcoded `Authority::renounced()` made it.
#[test]
fn test_metadata_fungible_with_authority_is_mintable() {
let definition_account = AccountForTests::definition_account_uninit_auth();
let holding_account = AccountForTests::holding_account_uninit_auth();
let metadata_account = AccountForTests::metadata_account_uninit_auth();
let new_definition = NewTokenDefinition::Fungible {
name: String::from("test"),
total_supply: 15u128,
mint_authority: Some(AccountId::new([15_u8; 32])),
};
let metadata = NewTokenMetadata {
standard: MetadataStandard::Simple,
uri: "test_uri".to_string(),
creators: "test_creators".to_string(),
};
let post_states = new_definition_with_metadata(
definition_account,
holding_account,
metadata_account,
new_definition,
metadata,
);
let [definition_post, _holding_post, _metadata_post] = post_states.try_into().unwrap();
// The stored authority must be the requested key, NOT renounced.
let def = TokenDefinition::try_from(&definition_post.account().data).unwrap();
let stored = match def {
TokenDefinition::Fungible { authority, .. } => authority.authority(),
_ => None,
};
assert_eq!(stored, Some([15_u8; 32]));
}
#[should_panic(expected = "Definition target account must be authorized")]
#[test]
fn test_call_new_definition_metadata_requires_authorized_definition() {
@ -1097,6 +1144,7 @@ fn test_call_new_definition_metadata_requires_authorized_definition() {
let new_definition = NewTokenDefinition::Fungible {
name: String::from("test"),
total_supply: 15u128,
mint_authority: None,
};
let metadata = NewTokenMetadata {
standard: MetadataStandard::Simple,
@ -1121,6 +1169,7 @@ fn test_call_new_definition_metadata_requires_authorized_holding() {
let new_definition = NewTokenDefinition::Fungible {
name: String::from("test"),
total_supply: 15u128,
mint_authority: None,
};
let metadata = NewTokenMetadata {
standard: MetadataStandard::Simple,
@ -1149,6 +1198,7 @@ fn test_call_new_definition_metadata_requires_authorized_metadata() {
let new_definition = NewTokenDefinition::Fungible {
name: String::from("test"),
total_supply: 15u128,
mint_authority: None,
};
let metadata = NewTokenMetadata {
standard: MetadataStandard::Simple,
@ -1181,6 +1231,7 @@ fn test_call_new_definition_metadata_with_init_definition() {
let new_definition = NewTokenDefinition::Fungible {
name: String::from("test"),
total_supply: 15u128,
mint_authority: None,
};
let metadata = NewTokenMetadata {
standard: MetadataStandard::Simple,
@ -1213,6 +1264,7 @@ fn test_call_new_definition_metadata_with_init_metadata() {
let new_definition = NewTokenDefinition::Fungible {
name: String::from("test"),
total_supply: 15u128,
mint_authority: None,
};
let metadata = NewTokenMetadata {
standard: MetadataStandard::Simple,
@ -1245,6 +1297,7 @@ fn test_call_new_definition_metadata_with_init_holding() {
let new_definition = NewTokenDefinition::Fungible {
name: String::from("test"),
total_supply: 15u128,
mint_authority: None,
};
let metadata = NewTokenMetadata {
standard: MetadataStandard::Simple,
@ -1418,6 +1471,7 @@ mod authority_tests {
def_with_authority(),
holding_account(),
50_000,
vec![],
TOKEN_PROGRAM_ID,
);
let [def_post, holding_post] = post_states.try_into().unwrap();
@ -1448,6 +1502,7 @@ mod authority_tests {
def_with_authority_revoked(),
holding_account(),
50_000,
vec![],
TOKEN_PROGRAM_ID,
);
}
@ -1457,7 +1512,7 @@ mod authority_tests {
fn mint_without_is_authorized_fails() {
let mut def = def_with_authority();
def.is_authorized = false;
let _ = mint(def, holding_account(), 50_000, TOKEN_PROGRAM_ID);
let _ = mint(def, holding_account(), 50_000, vec![], TOKEN_PROGRAM_ID);
}
#[test]
@ -1467,6 +1522,7 @@ mod authority_tests {
def_wrong_authority(),
holding_account(),
50_000,
vec![],
TOKEN_PROGRAM_ID,
);
}
@ -1474,13 +1530,17 @@ mod authority_tests {
#[test]
#[should_panic(expected = "New mint authority must be a valid non-zero account ID")]
fn set_authority_rejects_zero_new_authority() {
let _ = set_authority(def_with_authority(), Some(AccountId::new([0u8; 32])));
let _ = set_authority(
def_with_authority(),
Some(AccountId::new([0u8; 32])),
vec![],
);
}
#[test]
fn set_authority_rotates_to_new_key() {
let new_key = AccountId::new([7_u8; 32]);
let post_states = set_authority(def_with_authority(), Some(new_key));
let post_states = set_authority(def_with_authority(), Some(new_key), vec![]);
let [def_post] = post_states.try_into().unwrap();
let def = TokenDefinition::try_from(&def_post.account().data).unwrap();
@ -1493,7 +1553,7 @@ mod authority_tests {
#[test]
fn set_authority_revokes_permanently() {
let post_states = set_authority(def_with_authority(), None);
let post_states = set_authority(def_with_authority(), None, vec![]);
let [def_post] = post_states.try_into().unwrap();
let def = TokenDefinition::try_from(&def_post.account().data).unwrap();
@ -1510,6 +1570,7 @@ mod authority_tests {
let _ = set_authority(
def_with_authority_revoked(),
Some(AccountId::new([7_u8; 32])),
vec![],
);
}
@ -1518,19 +1579,23 @@ mod authority_tests {
fn set_authority_without_is_authorized_fails() {
let mut def = def_with_authority();
def.is_authorized = false;
let _ = set_authority(def, Some(AccountId::new([7_u8; 32])));
let _ = set_authority(def, Some(AccountId::new([7_u8; 32])), vec![]);
}
#[test]
#[should_panic(expected = "SetAuthority failed")]
fn set_authority_wrong_signer_fails() {
let _ = set_authority(def_wrong_authority(), Some(AccountId::new([7_u8; 32])));
let _ = set_authority(
def_wrong_authority(),
Some(AccountId::new([7_u8; 32])),
vec![],
);
}
#[test]
fn set_authority_rotate_then_old_cannot_mint() {
let new_key = AccountId::new([7_u8; 32]);
let post_states = set_authority(def_with_authority(), Some(new_key));
let post_states = set_authority(def_with_authority(), Some(new_key), vec![]);
let [def_post] = post_states.try_into().unwrap();
let def = TokenDefinition::try_from(&def_post.account().data).unwrap();
@ -1542,4 +1607,82 @@ mod authority_tests {
assert_eq!(auth, Some([7_u8; 32]));
assert_ne!(auth, Some(AUTHORITY));
}
/// Authority signer for the rotated key B ([7;32]), authorized.
fn new_authority_signer() -> AccountWithMetadata {
AccountWithMetadata {
account: Account::default(),
is_authorized: true,
account_id: AccountId::new([7_u8; 32]),
}
}
/// RFP-001 end-to-end (comment #1): after rotating authority A -> B, the new
/// authority B can actually mint by presenting itself in `authority_accounts`.
#[test]
fn rotated_authority_can_mint() {
// Rotate A ([15;32]) -> B ([7;32]), signed by A via self-authority.
let rotate_post = set_authority(
def_with_authority(),
Some(AccountId::new([7_u8; 32])),
vec![],
);
let [def_post] = rotate_post.try_into().unwrap();
// Rebuild the definition carrying the rotated authority, re-authorized.
let mut rotated_def = def_with_authority();
rotated_def.account = def_post.account().clone();
// B mints by presenting itself as the external authority.
let mint_post = mint(
rotated_def,
holding_account(),
10_000,
vec![new_authority_signer()],
TOKEN_PROGRAM_ID,
);
let [def_after, holding_after, _auth] = mint_post.try_into().unwrap();
let minted = TokenDefinition::try_from(&def_after.account().data).unwrap();
assert!(matches!(
minted,
TokenDefinition::Fungible {
total_supply: 110_000,
..
}
));
let holding = TokenHolding::try_from(&holding_after.account().data).unwrap();
assert!(matches!(
holding,
TokenHolding::Fungible {
balance: 11_000,
..
}
));
}
/// Comment #1 negative: after rotation to B, the OLD authority A can no
/// longer mint. Here A attempts self-authority (empty `authority_accounts`),
/// but the definition's own id no longer matches the stored authority B.
#[test]
#[should_panic(expected = "Mint authority check failed")]
fn rotated_authority_old_key_cannot_mint() {
let rotate_post = set_authority(
def_with_authority(),
Some(AccountId::new([7_u8; 32])),
vec![],
);
let [def_post] = rotate_post.try_into().unwrap();
let mut rotated_def = def_with_authority();
rotated_def.account = def_post.account().clone();
// A ([15;32]) is no longer the authority; self-authority must fail.
let _ = mint(
rotated_def,
holding_account(),
10_000,
vec![],
TOKEN_PROGRAM_ID,
);
}
}

View File

@ -35,7 +35,22 @@ fi
SPEL="${SPEL:-$HOME/rebase-lez/spel/target/release/spel}"
LEZ_PROGRAMS="${LEZ_PROGRAMS:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
IDL="${IDL:-$LEZ_PROGRAMS/artifacts/token-idl.json}"
TOKEN_BIN="${TOKEN_BIN:-$LEZ_PROGRAMS/target/riscv-guest/token-methods/token-guest/riscv32im-risc0-zkvm-elf/release/token.bin}"
# Resolve the token guest binary from either build layout, in priority order:
# 1. `cargo risczero build --manifest-path programs/token/methods/guest/Cargo.toml`
# -> programs/token/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/token.bin
# (the build command documented in the README)
# 2. workspace build (`cargo build` / `cargo test`)
# -> target/riscv-guest/token-methods/token-guest/riscv32im-risc0-zkvm-elf/release/token.bin
# An explicit TOKEN_BIN env var always takes precedence.
_risc0_token_bin="$LEZ_PROGRAMS/programs/token/methods/guest/target/riscv32im-risc0-zkvm-elf/docker/token.bin"
_workspace_token_bin="$LEZ_PROGRAMS/target/riscv-guest/token-methods/token-guest/riscv32im-risc0-zkvm-elf/release/token.bin"
if [ -z "${TOKEN_BIN:-}" ]; then
if [ -f "$_risc0_token_bin" ]; then
TOKEN_BIN="$_risc0_token_bin"
else
TOKEN_BIN="$_workspace_token_bin"
fi
fi
DEMO_DIR="${DEMO_DIR:-$(pwd)}"
WALLET_DIR="${WALLET_DIR:-$DEMO_DIR/.scaffold/wallet}"