diff --git a/artifacts/token-idl.json b/artifacts/token-idl.json index 0986ea2..59c150d 100644 --- a/artifacts/token-idl.json +++ b/artifacts/token-idl.json @@ -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": [ diff --git a/programs/token/core/src/lib.rs b/programs/token/core/src/lib.rs index 2236ddd..c0bff3f 100644 --- a/programs/token/core/src/lib.rs +++ b/programs/token/core/src/lib.rs @@ -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, }, NonFungible { name: String, diff --git a/programs/token/methods/guest/src/bin/token.rs b/programs/token/methods/guest/src/bin/token.rs index f756071..a5971a8 100644 --- a/programs/token/methods/guest/src/bin/token.rs +++ b/programs/token/methods/guest/src/bin/token.rs @@ -128,6 +128,7 @@ mod token { #[account(mut, signer)] definition_account: AccountWithMetadata, user_holding_account: AccountWithMetadata, + authority_accounts: Vec, 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, new_authority: Option, ) -> 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![], )) } diff --git a/programs/token/src/mint.rs b/programs/token/src/mint.rs index d2fdc42..b38c572 100644 --- a/programs/token/src/mint.rs +++ b/programs/token/src/mint.rs @@ -9,6 +9,7 @@ pub fn mint( definition_account: AccountWithMetadata, user_holding_account: AccountWithMetadata, amount_to_mint: u128, + authority_accounts: Vec, token_program_id: ProgramId, ) -> Vec { 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 } diff --git a/programs/token/src/new_definition.rs b/programs/token/src/new_definition.rs index 32bad97..2298f3d 100644 --- a/programs/token/src/new_definition.rs +++ b/programs/token/src/new_definition.rs @@ -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, diff --git a/programs/token/src/set_authority.rs b/programs/token/src/set_authority.rs index 91b4b1b..d0fabdd 100644 --- a/programs/token/src/set_authority.rs +++ b/programs/token/src/set_authority.rs @@ -8,20 +8,23 @@ use token_core::TokenDefinition; pub fn set_authority( definition_account: AccountWithMetadata, new_authority: Option, + authority_accounts: Vec, ) -> Vec { 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 } diff --git a/programs/token/src/tests.rs b/programs/token/src/tests.rs index 502aa2b..75317e4 100644 --- a/programs/token/src/tests.rs +++ b/programs/token/src/tests.rs @@ -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, + ); + } } diff --git a/scripts/demo-full-flow.sh b/scripts/demo-full-flow.sh index ef4e38c..ef7a3a7 100755 --- a/scripts/demo-full-flow.sh +++ b/scripts/demo-full-flow.sh @@ -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}"