From 90a2ab0c6f57b71f5f7cae34893b2046fa1ef79a Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Mon, 4 May 2026 10:34:10 -0300 Subject: [PATCH 1/4] fix(token): reject foreign-owned definitions on initialize --- integration_tests/tests/token.rs | 51 ++++++++++++ token/methods/guest/src/bin/token.rs | 112 ++++++++++++++++++++++++++- token/src/initialize.rs | 11 +-- token/src/tests.rs | 38 +++++++-- 4 files changed, 201 insertions(+), 11 deletions(-) diff --git a/integration_tests/tests/token.rs b/integration_tests/tests/token.rs index aed4d8a..93b1eae 100644 --- a/integration_tests/tests/token.rs +++ b/integration_tests/tests/token.rs @@ -36,6 +36,10 @@ impl Ids { token_methods::TOKEN_ID } + fn foreign_token_program() -> nssa_core::program::ProgramId { + [0xfeed_u32; 8] + } + fn token_definition() -> AccountId { AccountId::from(&PublicKey::new_from_private_key(&Keys::def_key())) } @@ -63,6 +67,19 @@ impl Accounts { } } + fn token_definition_foreign_owner() -> Account { + Account { + program_owner: Ids::foreign_token_program(), + balance: 0_u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("Gold"), + total_supply: 1_000_000_u128, + metadata_id: None, + }), + nonce: Nonce(0), + } + } + fn holder_init() -> Account { Account { program_owner: Ids::token_program(), @@ -167,6 +184,40 @@ fn token_new_fungible_definition() { ); } +#[test] +fn token_initialize_account_rejects_foreign_owned_definition() { + let mut state = state_for_token_tests_without_recipient(); + state.force_insert_account( + Ids::token_definition(), + Accounts::token_definition_foreign_owner(), + ); + + let instruction = token_core::Instruction::InitializeAccount; + + let message = public_transaction::Message::try_new( + Ids::token_program(), + vec![Ids::token_definition(), Ids::recipient()], + vec![Nonce(0)], + instruction, + ) + .unwrap(); + + let witness_set = + public_transaction::WitnessSet::for_message(&message, &[&Keys::recipient_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::token_definition()), + Accounts::token_definition_foreign_owner() + ); + assert_eq!( + state.get_account_by_id(Ids::recipient()), + Account::default() + ); +} + #[test] fn token_transfer() { let mut state = state_for_token_tests(); diff --git a/token/methods/guest/src/bin/token.rs b/token/methods/guest/src/bin/token.rs index 73ae46a..308d9d3 100644 --- a/token/methods/guest/src/bin/token.rs +++ b/token/methods/guest/src/bin/token.rs @@ -1,10 +1,119 @@ #![no_main] +use nssa_core::{ + account::AccountWithMetadata, + program::{AccountPostState, ProgramInput, ProgramOutput}, +}; +#[cfg(test)] use spel_framework::prelude::*; -use nssa_core::account::AccountWithMetadata; +use token_core::Instruction; risc0_zkvm::guest::entry!(main); +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction, + }, + instruction_words, + ) = nssa_core::program::read_nssa_inputs::(); + let pre_states_clone = pre_states.clone(); + + let post_states = dispatch(instruction, pre_states, self_program_id); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states_clone, + post_states, + ) + .write(); +} + +fn dispatch( + instruction: Instruction, + pre_states: Vec, + self_program_id: nssa_core::program::ProgramId, +) -> Vec { + match instruction { + Instruction::Transfer { amount_to_transfer } => { + let [sender, recipient] = expect_accounts(pre_states); + token_program::transfer::transfer(sender, recipient, amount_to_transfer) + } + Instruction::NewFungibleDefinition { name, total_supply } => { + let [definition_target_account, holding_target_account] = + expect_accounts(pre_states); + token_program::new_definition::new_fungible_definition( + definition_target_account, + holding_target_account, + name, + total_supply, + ) + } + Instruction::NewDefinitionWithMetadata { + new_definition, + metadata, + } => { + let [ + definition_target_account, + holding_target_account, + metadata_target_account, + ] = expect_accounts(pre_states); + token_program::new_definition::new_definition_with_metadata( + definition_target_account, + holding_target_account, + metadata_target_account, + new_definition, + *metadata, + ) + } + Instruction::InitializeAccount => { + let [definition_account, account_to_initialize] = expect_accounts(pre_states); + token_program::initialize::initialize_account( + definition_account, + account_to_initialize, + self_program_id, + ) + } + Instruction::Burn { amount_to_burn } => { + let [definition_account, user_holding_account] = expect_accounts(pre_states); + token_program::burn::burn( + definition_account, + user_holding_account, + amount_to_burn, + ) + } + Instruction::Mint { amount_to_mint } => { + let [definition_account, user_holding_account] = expect_accounts(pre_states); + token_program::mint::mint( + definition_account, + user_holding_account, + amount_to_mint, + ) + } + Instruction::PrintNft => { + let [master_account, printed_account] = expect_accounts(pre_states); + token_program::print_nft::print_nft(master_account, printed_account) + } + } +} + +fn expect_accounts( + pre_states: Vec, +) -> [AccountWithMetadata; N] { + let actual = pre_states.len(); + pre_states.try_into().unwrap_or_else(|_| { + panic!("Account count mismatch: expected {N}, got {actual}"); + }) +} + +// Kept for `idl-gen`, which parses `#[lez_program]` source annotations directly. +#[cfg(test)] +#[allow(dead_code)] #[lez_program(instruction = "token_core::Instruction")] mod token { #[allow(unused_imports)] @@ -76,6 +185,7 @@ mod token { token_program::initialize::initialize_account( definition_account, account_to_initialize, + nssa_core::program::DEFAULT_PROGRAM_ID, ), )) } diff --git a/token/src/initialize.rs b/token/src/initialize.rs index d8350d4..7f57e38 100644 --- a/token/src/initialize.rs +++ b/token/src/initialize.rs @@ -1,12 +1,13 @@ use nssa_core::{ account::{Account, AccountWithMetadata, Data}, - program::{AccountPostState, Claim}, + program::{AccountPostState, Claim, ProgramId}, }; use token_core::{TokenDefinition, TokenHolding}; pub fn initialize_account( definition_account: AccountWithMetadata, account_to_initialize: AccountWithMetadata, + token_program_id: ProgramId, ) -> Vec { assert_eq!( account_to_initialize.account, @@ -17,11 +18,11 @@ pub fn initialize_account( account_to_initialize.is_authorized, "Account to initialize must be authorized" ); + assert_eq!( + definition_account.account.program_owner, token_program_id, + "Token definition must be owned by token program" + ); - // TODO: #212 We should check that this is an account owned by the token program. - // This check can't be done here since the ID of the program is known only after compiling it - // - // Check definition account is valid let definition = TokenDefinition::try_from(&definition_account.account.data) .expect("Definition account must be valid"); let holding = diff --git a/token/src/tests.rs b/token/src/tests.rs index 532787e..49adab5 100644 --- a/token/src/tests.rs +++ b/token/src/tests.rs @@ -2,7 +2,7 @@ use nssa_core::{ account::{Account, AccountId, AccountWithMetadata, Data, Nonce}, - program::Claim, + program::{Claim, ProgramId}, }; use token_core::{ MetadataStandard, NewTokenDefinition, NewTokenMetadata, TokenDefinition, TokenHolding, @@ -25,11 +25,31 @@ struct IdForTests; struct AccountForTests; +const TOKEN_PROGRAM_ID: ProgramId = [5u32; 8]; +const FOREIGN_TOKEN_PROGRAM_ID: ProgramId = [6u32; 8]; + impl AccountForTests { fn definition_account_auth() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5u32; 8], + program_owner: TOKEN_PROGRAM_ID, + balance: 0u128, + data: Data::from(&TokenDefinition::Fungible { + name: String::from("test"), + total_supply: BalanceForTests::init_supply(), + metadata_id: None, + }), + nonce: Nonce(0), + }, + is_authorized: true, + account_id: IdForTests::pool_definition_id(), + } + } + + fn definition_account_foreign_owner() -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: FOREIGN_TOKEN_PROGRAM_ID, balance: 0u128, data: Data::from(&TokenDefinition::Fungible { name: String::from("test"), @@ -46,7 +66,7 @@ impl AccountForTests { fn definition_account_without_auth() -> AccountWithMetadata { AccountWithMetadata { account: Account { - program_owner: [5u32; 8], + program_owner: TOKEN_PROGRAM_ID, balance: 0u128, data: Data::from(&TokenDefinition::Fungible { name: String::from("test"), @@ -757,7 +777,7 @@ fn test_transfer_with_default_recipient_claims_recipient() { fn test_token_initialize_account_succeeds() { let definition_account = AccountForTests::definition_account_auth(); let holding_account = AccountForTests::holding_account_uninit_auth(); - let post_states = initialize_account(definition_account, holding_account); + let post_states = initialize_account(definition_account, holding_account, TOKEN_PROGRAM_ID); let [definition_post, holding_post] = post_states.try_into().unwrap(); assert_eq!( @@ -785,7 +805,15 @@ fn test_token_initialize_account_succeeds() { fn test_token_initialize_account_requires_authorization() { let definition_account = AccountForTests::definition_account_auth(); let holding_account = AccountForTests::holding_account_uninit(); - let _post_states = initialize_account(definition_account, holding_account); + let _post_states = initialize_account(definition_account, holding_account, TOKEN_PROGRAM_ID); +} + +#[test] +#[should_panic(expected = "Token definition must be owned by token program")] +fn test_token_initialize_account_rejects_foreign_owned_definition() { + let definition_account = AccountForTests::definition_account_foreign_owner(); + let holding_account = AccountForTests::holding_account_uninit_auth(); + let _post_states = initialize_account(definition_account, holding_account, TOKEN_PROGRAM_ID); } #[test] From beda1d174731195ea84d4924018bba4d6f906e53 Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Mon, 4 May 2026 13:04:07 -0300 Subject: [PATCH 2/4] fix(token): reject foreign-owned definitions on mint --- integration_tests/tests/token.rs | 38 ++++++++++++++++++++++++++++ token/methods/guest/src/bin/token.rs | 2 ++ token/src/mint.rs | 7 ++++- token/src/tests.rs | 22 ++++++++++++++++ 4 files changed, 68 insertions(+), 1 deletion(-) diff --git a/integration_tests/tests/token.rs b/integration_tests/tests/token.rs index 93b1eae..0407c9e 100644 --- a/integration_tests/tests/token.rs +++ b/integration_tests/tests/token.rs @@ -446,6 +446,44 @@ fn token_mint() { ); } +#[test] +fn token_mint_rejects_foreign_owned_definition() { + let mut state = state_for_token_tests_without_recipient(); + state.force_insert_account( + Ids::token_definition(), + Accounts::token_definition_foreign_owner(), + ); + + let instruction = token_core::Instruction::Mint { + amount_to_mint: 500_000_u128, + }; + + let message = public_transaction::Message::try_new( + Ids::token_program(), + vec![Ids::token_definition(), Ids::recipient()], + vec![Nonce(0), Nonce(0)], + instruction, + ) + .unwrap(); + + let witness_set = public_transaction::WitnessSet::for_message( + &message, + &[&Keys::def_key(), &Keys::recipient_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::token_definition()), + Accounts::token_definition_foreign_owner() + ); + assert_eq!( + state.get_account_by_id(Ids::recipient()), + Account::default() + ); +} + #[test] fn token_mint_fresh_public_recipient_requires_authorization() { let mut state = state_for_token_tests_without_recipient(); diff --git a/token/methods/guest/src/bin/token.rs b/token/methods/guest/src/bin/token.rs index 308d9d3..26a089d 100644 --- a/token/methods/guest/src/bin/token.rs +++ b/token/methods/guest/src/bin/token.rs @@ -93,6 +93,7 @@ fn dispatch( definition_account, user_holding_account, amount_to_mint, + self_program_id, ) } Instruction::PrintNft => { @@ -216,6 +217,7 @@ mod token { definition_account, user_holding_account, amount_to_mint, + nssa_core::program::DEFAULT_PROGRAM_ID, ))) } diff --git a/token/src/mint.rs b/token/src/mint.rs index ff744d6..0c638d1 100644 --- a/token/src/mint.rs +++ b/token/src/mint.rs @@ -1,6 +1,6 @@ use nssa_core::{ account::{Account, AccountWithMetadata, Data}, - program::{AccountPostState, Claim}, + program::{AccountPostState, Claim, ProgramId}, }; use token_core::{TokenDefinition, TokenHolding}; @@ -8,11 +8,16 @@ pub fn mint( definition_account: AccountWithMetadata, user_holding_account: AccountWithMetadata, amount_to_mint: u128, + token_program_id: ProgramId, ) -> Vec { assert!( definition_account.is_authorized, "Definition authorization is missing" ); + assert_eq!( + definition_account.account.program_owner, token_program_id, + "Token definition must be owned by token program" + ); let mut definition = TokenDefinition::try_from(&definition_account.account.data) .expect("Token Definition account must be valid"); diff --git a/token/src/tests.rs b/token/src/tests.rs index 49adab5..3c5494c 100644 --- a/token/src/tests.rs +++ b/token/src/tests.rs @@ -896,6 +896,7 @@ fn test_mint_not_valid_holding_account() { definition_account, holding_account, BalanceForTests::mint_success(), + TOKEN_PROGRAM_ID, ); } @@ -908,6 +909,7 @@ fn test_mint_not_valid_definition_account() { definition_account, holding_account, BalanceForTests::mint_success(), + TOKEN_PROGRAM_ID, ); } @@ -920,6 +922,20 @@ fn test_mint_missing_authorization() { definition_account, holding_account, BalanceForTests::mint_success(), + TOKEN_PROGRAM_ID, + ); +} + +#[test] +#[should_panic(expected = "Token definition must be owned by token program")] +fn test_mint_rejects_foreign_owned_definition() { + let definition_account = AccountForTests::definition_account_foreign_owner(); + let holding_account = AccountForTests::holding_account_uninit(); + let _post_states = mint( + definition_account, + holding_account, + BalanceForTests::mint_success(), + TOKEN_PROGRAM_ID, ); } @@ -932,6 +948,7 @@ fn test_mint_mismatched_token_definition() { definition_account, holding_account, BalanceForTests::mint_success(), + TOKEN_PROGRAM_ID, ); } @@ -943,6 +960,7 @@ fn test_mint_success() { definition_account, holding_account, BalanceForTests::mint_success(), + TOKEN_PROGRAM_ID, ); let [def_post, holding_post] = post_states.try_into().unwrap(); @@ -967,6 +985,7 @@ fn test_mint_uninit_holding_success() { definition_account, holding_account, BalanceForTests::mint_success(), + TOKEN_PROGRAM_ID, ); let [def_post, holding_post] = post_states.try_into().unwrap(); @@ -992,6 +1011,7 @@ fn test_mint_total_supply_overflow() { definition_account, holding_account, BalanceForTests::mint_overflow(), + TOKEN_PROGRAM_ID, ); } @@ -1004,6 +1024,7 @@ fn test_mint_holding_account_overflow() { definition_account, holding_account, BalanceForTests::mint_overflow(), + TOKEN_PROGRAM_ID, ); } @@ -1016,6 +1037,7 @@ fn test_mint_cannot_mint_unmintable_tokens() { definition_account, holding_account, BalanceForTests::mint_success(), + TOKEN_PROGRAM_ID, ); } From 9a186de0b49f88a59d6171e884c25afd4805f9fd Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Mon, 4 May 2026 13:43:24 -0300 Subject: [PATCH 3/4] fix(token): gate guest entrypoint in tests Keep the manual dispatcher for self_program_id checks while allowing the SPEL IDL wrapper to compile during tests without defining a second entrypoint. See https://github.com/logos-co/spel/issues/172 --- token/methods/guest/src/bin/token.rs | 41 ++++++++++++++++++---------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/token/methods/guest/src/bin/token.rs b/token/methods/guest/src/bin/token.rs index 26a089d..7a0cf0d 100644 --- a/token/methods/guest/src/bin/token.rs +++ b/token/methods/guest/src/bin/token.rs @@ -1,15 +1,19 @@ -#![no_main] +#![cfg_attr(not(test), no_main)] use nssa_core::{ account::AccountWithMetadata, - program::{AccountPostState, ProgramInput, ProgramOutput}, + program::{AccountPostState, ProgramId}, }; +#[cfg(not(test))] +use nssa_core::program::{ProgramInput, ProgramOutput}; #[cfg(test)] use spel_framework::prelude::*; -use token_core::Instruction; +use token_core::Instruction as TokenInstruction; +#[cfg(not(test))] risc0_zkvm::guest::entry!(main); +#[cfg(not(test))] fn main() { let ( ProgramInput { @@ -19,7 +23,7 @@ fn main() { instruction, }, instruction_words, - ) = nssa_core::program::read_nssa_inputs::(); + ) = nssa_core::program::read_nssa_inputs::(); let pre_states_clone = pre_states.clone(); let post_states = dispatch(instruction, pre_states, self_program_id); @@ -34,17 +38,18 @@ fn main() { .write(); } +#[cfg_attr(test, allow(dead_code))] fn dispatch( - instruction: Instruction, + instruction: TokenInstruction, pre_states: Vec, - self_program_id: nssa_core::program::ProgramId, + self_program_id: ProgramId, ) -> Vec { match instruction { - Instruction::Transfer { amount_to_transfer } => { + TokenInstruction::Transfer { amount_to_transfer } => { let [sender, recipient] = expect_accounts(pre_states); token_program::transfer::transfer(sender, recipient, amount_to_transfer) } - Instruction::NewFungibleDefinition { name, total_supply } => { + TokenInstruction::NewFungibleDefinition { name, total_supply } => { let [definition_target_account, holding_target_account] = expect_accounts(pre_states); token_program::new_definition::new_fungible_definition( @@ -54,7 +59,7 @@ fn dispatch( total_supply, ) } - Instruction::NewDefinitionWithMetadata { + TokenInstruction::NewDefinitionWithMetadata { new_definition, metadata, } => { @@ -71,7 +76,7 @@ fn dispatch( *metadata, ) } - Instruction::InitializeAccount => { + TokenInstruction::InitializeAccount => { let [definition_account, account_to_initialize] = expect_accounts(pre_states); token_program::initialize::initialize_account( definition_account, @@ -79,7 +84,7 @@ fn dispatch( self_program_id, ) } - Instruction::Burn { amount_to_burn } => { + TokenInstruction::Burn { amount_to_burn } => { let [definition_account, user_holding_account] = expect_accounts(pre_states); token_program::burn::burn( definition_account, @@ -87,7 +92,7 @@ fn dispatch( amount_to_burn, ) } - Instruction::Mint { amount_to_mint } => { + TokenInstruction::Mint { amount_to_mint } => { let [definition_account, user_holding_account] = expect_accounts(pre_states); token_program::mint::mint( definition_account, @@ -96,13 +101,14 @@ fn dispatch( self_program_id, ) } - Instruction::PrintNft => { + TokenInstruction::PrintNft => { let [master_account, printed_account] = expect_accounts(pre_states); token_program::print_nft::print_nft(master_account, printed_account) } } } +#[cfg_attr(test, allow(dead_code))] fn expect_accounts( pre_states: Vec, ) -> [AccountWithMetadata; N] { @@ -112,7 +118,7 @@ fn expect_accounts( }) } -// Kept for `idl-gen`, which parses `#[lez_program]` source annotations directly. +// Kept for `idl-gen`, which parses source annotations directly. #[cfg(test)] #[allow(dead_code)] #[lez_program(instruction = "token_core::Instruction")] @@ -122,6 +128,7 @@ mod token { /// Transfer tokens from sender to recipient. /// Fresh public recipients must be explicitly authorized in the same transaction. + #[allow(dead_code, clippy::boxed_local)] #[instruction] pub fn transfer( sender: AccountWithMetadata, @@ -137,6 +144,7 @@ mod token { /// Create a new fungible token definition without metadata. /// Definition and holding targets must be uninitialized and authorized. + #[allow(dead_code, clippy::boxed_local)] #[instruction] pub fn new_fungible_definition( definition_target_account: AccountWithMetadata, @@ -156,6 +164,7 @@ mod token { /// Create a new fungible or non-fungible token definition with metadata. /// Definition, holding, and metadata targets must be uninitialized and authorized. + #[allow(dead_code, clippy::boxed_local)] #[instruction] pub fn new_definition_with_metadata( definition_target_account: AccountWithMetadata, @@ -177,6 +186,7 @@ mod token { /// Initialize a token holding account for a given token definition. /// The holding target must be uninitialized and authorized. + #[allow(dead_code, clippy::boxed_local)] #[instruction] pub fn initialize_account( definition_account: AccountWithMetadata, @@ -192,6 +202,7 @@ mod token { } /// Burn tokens from the holder's account. + #[allow(dead_code, clippy::boxed_local)] #[instruction] pub fn burn( definition_account: AccountWithMetadata, @@ -207,6 +218,7 @@ mod token { /// Mint new tokens to the holder's account. /// Fresh public holders must be explicitly authorized in the same transaction. + #[allow(dead_code, clippy::boxed_local)] #[instruction] pub fn mint( definition_account: AccountWithMetadata, @@ -223,6 +235,7 @@ mod token { /// Print a new NFT from the master copy. /// The printed copy target must be uninitialized and authorized. + #[allow(dead_code, clippy::boxed_local)] #[instruction] pub fn print_nft( master_account: AccountWithMetadata, From d9337d560340df77d717b9a463961bace529bd5e Mon Sep 17 00:00:00 2001 From: Ricardo Guilherme Schmidt <3esmit@gmail.com> Date: Mon, 4 May 2026 13:48:46 -0300 Subject: [PATCH 4/4] test(token): cover initialize account happy path --- integration_tests/tests/token.rs | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/integration_tests/tests/token.rs b/integration_tests/tests/token.rs index 0407c9e..9cc5404 100644 --- a/integration_tests/tests/token.rs +++ b/integration_tests/tests/token.rs @@ -184,6 +184,44 @@ fn token_new_fungible_definition() { ); } +#[test] +fn token_initialize_account_succeeds_for_canonical_definition() { + let mut state = state_for_token_tests_without_recipient(); + + let instruction = token_core::Instruction::InitializeAccount; + + let message = public_transaction::Message::try_new( + Ids::token_program(), + vec![Ids::token_definition(), Ids::recipient()], + vec![Nonce(0)], + instruction, + ) + .unwrap(); + + let witness_set = + public_transaction::WitnessSet::for_message(&message, &[&Keys::recipient_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()), + Accounts::token_definition_init() + ); + assert_eq!( + state.get_account_by_id(Ids::recipient()), + Account { + program_owner: Ids::token_program(), + balance: 0_u128, + data: Data::from(&TokenHolding::Fungible { + definition_id: Ids::token_definition(), + balance: 0_u128, + }), + nonce: Nonce(1), + } + ); +} + #[test] fn token_initialize_account_rejects_foreign_owned_definition() { let mut state = state_for_token_tests_without_recipient();