From ce87d3907f1a31e2581597c41ae3e5825960c29f Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:00:17 +0900 Subject: [PATCH 1/2] fix errors in compilation --- client/src/instructions.rs | 4 +- client/src/parse.rs | 2 +- interface/src/lib.rs | 4 + programs/settlement/idl/cow_settlement.json | 10 ++ programs/settlement/src/token.rs | 130 +++++++++++++++--- programs/settlement/tests/common/buffer.rs | 18 +-- programs/settlement/tests/common/mod.rs | 18 ++- programs/settlement/tests/common/token.rs | 46 ++++--- .../settlement/tests/common/token_2022.rs | 15 +- programs/settlement/tests/reclaim_buffer.rs | 2 +- .../settlement/tests/settle_limit_prices.rs | 15 +- .../settlement/tests/settle_solver_auth.rs | 5 +- .../settlement/tests/settle_token_programs.rs | 75 +++++----- 13 files changed, 226 insertions(+), 118 deletions(-) diff --git a/client/src/instructions.rs b/client/src/instructions.rs index c21339d5..8291435e 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -318,12 +318,12 @@ mod tests { instruction::{ fixtures::fake_account_from_array, settle::{ - BeginSettleInput, FinalizeSettleInput, INSTRUCTIONS_SYSVAR_ID, - SPL_TOKEN_PROGRAM_ID, SYSTEM_PROGRAM_ID, + BeginSettleInput, FinalizeSettleInput, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, }, InstructionInputParsing, }, pda::order::find_order_pda, + token_program::SYSTEM_PROGRAM_ID, }; proptest! { diff --git a/client/src/parse.rs b/client/src/parse.rs index 6f7d7e48..d0526877 100644 --- a/client/src/parse.rs +++ b/client/src/parse.rs @@ -79,7 +79,7 @@ mod tests { use super::*; use crate::instructions::{ AddSolver, BeginSettle, CreateBuffers, CreateOrder, FinalizeSettle, Initialize, - InitializedIntent, RemoveSolver, + InitializedIntent, RemoveSolver, TokenPrograms, }; use cow_settlement_interface::{ data::intent::fixtures::sample_intent, diff --git a/interface/src/lib.rs b/interface/src/lib.rs index a6a6ad0d..b5382c31 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -265,6 +265,10 @@ pub enum SettlementError { /// settlement has to carry every token program its accounts live under; see /// [`token_program::TokenPrograms`]. TokenProgramNotProvided = 41, + /// `FinalizeSettle`: a push's destination isn't owned by a supported token + /// program, so it is no token account at all and there is nothing to issue + /// its transfer against. + PushDestinationInvalid = 42, } impl From for u32 { diff --git a/programs/settlement/idl/cow_settlement.json b/programs/settlement/idl/cow_settlement.json index 48c19b6c..8bd47cf9 100644 --- a/programs/settlement/idl/cow_settlement.json +++ b/programs/settlement/idl/cow_settlement.json @@ -1052,6 +1052,16 @@ "code": 40, "name": "BufferSizeUnavailable", "msg": "CreateBuffer asked the token program how long a token account for a mint has to be and couldn't read the answer, so it can't size the buffer." + }, + { + "code": 41, + "name": "TokenProgramNotProvided", + "msg": "BeginSettle/FinalizeSettle: a token account it has to move is owned by a supported token program whose slot carries the system-program placeholder, so there is no program to issue that transfer against. The settlement has to carry every token program its accounts live under; see token_program::TokenPrograms." + }, + { + "code": 42, + "name": "PushDestinationInvalid", + "msg": "FinalizeSettle: a push's destination isn't owned by a supported token program, so it is no token account at all and there is nothing to issue its transfer against." } ] } diff --git a/programs/settlement/src/token.rs b/programs/settlement/src/token.rs index 408dbcf8..c6379adb 100644 --- a/programs/settlement/src/token.rs +++ b/programs/settlement/src/token.rs @@ -1,6 +1,9 @@ //! Token-program validation and token-account reads -use cow_settlement_interface::{token_program::TokenProgram, SettlementError}; +use cow_settlement_interface::{ + token_program::{TokenProgram, SYSTEM_PROGRAM_ID}, + SettlementError, +}; use pinocchio::{cpi::get_return_data, error::ProgramError, AccountView, Address}; use pinocchio_token::instructions::GetAccountDataSize; @@ -17,6 +20,87 @@ pub fn validate_token_program( TokenProgram::try_from(token_program_account.address()) } +/// The token programs a `BeginSettle`/`FinalizeSettle` was handed: one slot per +/// entry of [`TokenProgram::ALL`], holding either that program or the system +/// program standing in for "this settlement moves no token under it". +/// +/// Built by [`TokenPrograms::validate`] and asked, per account, +/// [`which program owns it`](TokenPrograms::program_for). The instruction issues +/// that account's transfers against the answer, which is what lets a single +/// settlement mix tokens from both programs. +pub struct TokenPrograms { + /// Whether the legacy SPL Token program's slot held the program rather than + /// the placeholder. + spl_token: bool, + /// Token-2022's slot, filled the same way. + token_2022: bool, +} + +impl TokenPrograms { + /// Validate a settlement's two token-program slots, in the order the + /// instruction lays them out. + /// + /// Each slot has to hold either the program it stands for or + /// [`SYSTEM_PROGRAM_ID`]; anything else is a caller mistake rather than an + /// opt-out. A slot holding its program is also what puts that program in the + /// transaction, which is what makes the transfers' CPIs dispatchable at all. + #[must_use = "the returned slots decide which program each transfer targets"] + pub fn validate( + spl_token_account: &AccountView, + token_2022_account: &AccountView, + ) -> Result { + Ok(Self { + spl_token: validate_slot(spl_token_account, TokenProgram::SplToken)?, + token_2022: validate_slot(token_2022_account, TokenProgram::Token2022)?, + }) + } + + /// The token program `account`'s transfers must be issued against: the one + /// that owns it. + /// + /// `None` when `account` isn't owned by a supported token program, which + /// means it is no token account at all and the caller reports it as whatever + /// it failed to be. An account owned by a supported program whose slot held + /// the placeholder is a different matter: the settlement can't reach that + /// program, so it says so with [`SettlementError::TokenProgramNotProvided`] + /// rather than pretending the account is malformed. + pub fn program_for( + &self, + account: &AccountView, + ) -> Result, SettlementError> { + let Ok(owner) = TokenProgram::try_from(account.owner()) else { + return Ok(None); + }; + if self.carries(owner) { + Ok(Some(owner)) + } else { + Err(SettlementError::TokenProgramNotProvided) + } + } + + /// Whether `program`'s slot held it rather than the placeholder. The one + /// place a new [`TokenProgram`] variant has to be given a slot. + fn carries(&self, program: TokenProgram) -> bool { + match program { + TokenProgram::SplToken => self.spl_token, + TokenProgram::Token2022 => self.token_2022, + } + } +} + +/// Whether `program`'s slot holds it rather than the placeholder, rejecting an +/// address that is neither. +fn validate_slot(account: &AccountView, program: TokenProgram) -> Result { + let address = account.address(); + if address == &program.address() { + Ok(true) + } else if address == &SYSTEM_PROGRAM_ID { + Ok(false) + } else { + Err(ProgramError::IncorrectProgramId) + } +} + /// The data length a token account holding `mint` has to be allocated at. pub fn token_account_len( token_program: TokenProgram, @@ -204,16 +288,17 @@ mod tests { /// The settlement's own two slots, each holding the program it stands for. fn both_slots() -> [AccountView; 2] { - [ - fake_account(SPL_TOKEN_PROGRAM_ID), - fake_account(TOKEN_2022_PROGRAM_ID), - ] + TokenProgram::ALL.map(|program| fake_account(program.address())) } /// A token account of `program`, well-formed but empty of interest: only /// its owner decides which program its transfers go to. fn token_account_of(program: Address) -> AccountView { - fake_account_owned_by(UNRELATED, program, &base_layout(UNRELATED, UNRELATED, 0)) + fake_account_owned_by( + pubkey_from_seed("token account"), + program, + &base_account_layout(pubkey_from_seed("mint"), pubkey_from_seed("owner"), 0), + ) } /// A settlement carrying both programs settles accounts under either, each @@ -225,11 +310,11 @@ mod tests { let programs = TokenPrograms::validate(&spl_token, &token_2022).expect("both slots hold a program"); - for program in SUPPORTED_TOKEN_PROGRAMS { + for program in TokenProgram::ALL { assert_eq!( - programs.program_for(&token_account_of(program)), - Ok(Some(&program)), - "an account owned by {program} should be settled against it", + programs.program_for(&token_account_of(program.address())), + Ok(Some(program)), + "an account owned by {program:?} should be settled against it", ); } } @@ -242,7 +327,8 @@ mod tests { let programs = TokenPrograms::validate(&spl_token, &token_2022).expect("both slots hold a program"); - assert_eq!(programs.program_for(&token_account_of(UNRELATED)), Ok(None)); + let unrelated = pubkey_from_seed("not a token program"); + assert_eq!(programs.program_for(&token_account_of(unrelated)), Ok(None)); } /// A settlement that left a program out can't reach it, so an account under @@ -251,11 +337,11 @@ mod tests { fn program_for_rejects_an_account_under_a_left_out_program() { let placeholder = fake_account(SYSTEM_PROGRAM_ID); for [carried, left_out] in [ - [SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID], - [TOKEN_2022_PROGRAM_ID, SPL_TOKEN_PROGRAM_ID], + [TokenProgram::SplToken, TokenProgram::Token2022], + [TokenProgram::Token2022, TokenProgram::SplToken], ] { - let carried_account = fake_account(carried); - let (spl_token, token_2022) = if carried == SPL_TOKEN_PROGRAM_ID { + let carried_account = fake_account(carried.address()); + let (spl_token, token_2022) = if carried == TokenProgram::SplToken { (&carried_account, &placeholder) } else { (&placeholder, &carried_account) @@ -264,14 +350,14 @@ mod tests { TokenPrograms::validate(spl_token, token_2022).expect("the placeholder is allowed"); assert_eq!( - programs.program_for(&token_account_of(left_out)), + programs.program_for(&token_account_of(left_out.address())), Err(SettlementError::TokenProgramNotProvided), - "{left_out} was left out, so its accounts have nothing to settle against", + "{left_out:?} was left out, so its accounts have nothing to settle against", ); // The program that *is* carried still settles its own accounts. assert_eq!( - programs.program_for(&token_account_of(carried)), - Ok(Some(&carried)), + programs.program_for(&token_account_of(carried.address())), + Ok(Some(carried)), ); } } @@ -284,9 +370,9 @@ mod tests { let programs = TokenPrograms::validate(&placeholder, &placeholder) .expect("two placeholders are allowed"); - for program in SUPPORTED_TOKEN_PROGRAMS { + for program in TokenProgram::ALL { assert_eq!( - programs.program_for(&token_account_of(program)), + programs.program_for(&token_account_of(program.address())), Err(SettlementError::TokenProgramNotProvided), ); } @@ -307,7 +393,7 @@ mod tests { /// caller mistake, not an opt-out. #[test] fn validate_rejects_an_unrelated_account_in_a_slot() { - let unrelated = fake_account(UNRELATED); + let unrelated = fake_account(pubkey_from_seed("not a token program")); let [spl_token, token_2022] = both_slots(); assert_eq!( TokenPrograms::validate(&unrelated, &token_2022).err(), diff --git a/programs/settlement/tests/common/buffer.rs b/programs/settlement/tests/common/buffer.rs index 76767c19..54b6f480 100644 --- a/programs/settlement/tests/common/buffer.rs +++ b/programs/settlement/tests/common/buffer.rs @@ -1,7 +1,6 @@ //! Buffer-account helpers for the settlement integration tests. use cow_settlement_client::cow_settlement_interface::pda::buffer::find_buffer_pda; -use cow_settlement_client::cow_settlement_interface::token_program::SPL_TOKEN_PROGRAM_ID; use cow_settlement_client::cow_settlement_interface::Instruction; use cow_settlement_client::instructions::CreateBuffers; use cow_settlement_interface::token_program::TokenProgram; @@ -12,7 +11,7 @@ use solana_sdk::{ transaction::Transaction, }; -use super::{replace_first_matching_account, token}; +use super::token; /// The canonical buffer PDA for `mint`. pub fn buffer_pda(program_id: &Pubkey, mint: &Pubkey) -> Pubkey { @@ -22,13 +21,19 @@ pub fn buffer_pda(program_id: &Pubkey, mint: &Pubkey) -> Pubkey { /// Create the canonical buffer for `mint`, paid for by `payer`, unless it /// already exists, and return its address. Idempotent so several orders can /// share one buy mint. +/// +/// A buffer is a token account of its mint, so it is created under whichever +/// program owns the mint; [`ensure_buffer_exists_for`] is for the tests that +/// name a program of their own instead. pub fn ensure_buffer_exists( svm: &mut LiteSVM, program_id: &Pubkey, payer: &Keypair, mint: &Pubkey, ) -> Pubkey { - ensure_buffer_exists_for(svm, program_id, payer, mint, TokenProgram::SplToken) + let token_program = TokenProgram::try_from(&token::program_of(svm, mint)) + .expect("a mint lives under a supported token program"); + ensure_buffer_exists_for(svm, program_id, payer, mint, token_program) } /// [`ensure_buffer_exists`] under a token program of the caller's choosing, for @@ -44,17 +49,12 @@ pub fn ensure_buffer_exists_for( if svm.get_account(&pda).is_some() { return pda; } - let mut ix = Instruction::from(CreateBuffers { + let ix = Instruction::from(CreateBuffers { program_id: *program_id, payer: payer.pubkey(), token_program, mints: &[*mint], }); - // A buffer is a token account of its mint, so it has to be created under the - // mint's own program. The builder can only name the legacy one, so point the - // instruction at whichever program the mint actually lives under — a no-op - // for a legacy mint. - replace_first_matching_account(&mut ix, &SPL_TOKEN_PROGRAM_ID, token::program_of(svm, mint)); let tx = Transaction::new_signed_with_payer( &[ix], Some(&payer.pubkey()), diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index 995cc7ba..b694a27c 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -17,7 +17,6 @@ pub mod token_2022; use cow_settlement_client::instructions::{AddSolver, Initialize}; use cow_settlement_interface::pda::state::find_state_pda; -use cow_settlement_interface::token_program::TokenProgram; use cow_settlement_interface::Instruction; use cow_settlement_interface::SettlementError; use litesvm::{types::TransactionMetadata, LiteSVM}; @@ -36,10 +35,6 @@ pub const PROGRAM_SO: &str = concat!( "/../../target/deploy/cow_settlement.so" ); -/// The legacy SPL Token program, which the tests create their buffers and -/// token accounts under unless they exercise Token-2022 specifically. -pub const SPL_TOKEN_PROGRAM_ID: Pubkey = TokenProgram::SplToken.address(); - pub const CPI_CALLER_SO: &str = concat!( env!("CARGO_MANIFEST_DIR"), "/../../target/deploy/test_cpi_caller.so" @@ -207,6 +202,19 @@ pub fn assert_instruction_error_at( ); } +/// Convenience wrapper around [`assert_instruction_error_at`] for asserting a +/// specific [`SettlementError`] at the instruction that produced it: settlements +/// run as a `[BeginSettle, FinalizeSettle]` pair, so the failing instruction +/// isn't always the first. +#[track_caller] +pub fn assert_settlement_error( + ix_idx: u8, + result: Result, + expected: SettlementError, +) { + assert_instruction_error_at(ix_idx, result, to_instruction_error(expected)); +} + pub fn create_account_at(svm: &mut LiteSVM, address: Pubkey, owner: &Pubkey, data: &[u8]) { let lamports = svm.minimum_balance_for_rent_exemption(data.len()); svm.set_account( diff --git a/programs/settlement/tests/common/token.rs b/programs/settlement/tests/common/token.rs index 97818c2e..46aca1ce 100644 --- a/programs/settlement/tests/common/token.rs +++ b/programs/settlement/tests/common/token.rs @@ -74,23 +74,34 @@ pub fn create_mint(svm: &mut LiteSVM, payer: &Keypair) -> Pubkey { create_mint_under(svm, payer, &TOKEN_ID) } -/// Create a fresh mint under `token_program`, whose mint authority is `payer`, -/// and return its address. Every later helper reads the program back off the -/// mint, so this is the only place a test names it. +/// [`create_mint`] at `mint`'s address rather than a fresh one. Lets a test +/// reclaim an address a Token-2022 mint was just closed at, which is the only +/// way a legacy mint can end up where a Token-2022 one used to be. +pub fn create_mint_at(svm: &mut LiteSVM, payer: &Keypair, mint: &Keypair) -> Pubkey { + create_mint_at_under(svm, payer, mint, &TOKEN_ID) +} + +/// [`create_mint`] under `token_program` rather than the legacy program, for +/// the tests that build mints under both at once. +pub fn create_mint_under(svm: &mut LiteSVM, payer: &Keypair, token_program: &Pubkey) -> Pubkey { + create_mint_at_under(svm, payer, &unique_keypair(), token_program) +} + +/// Create a mint at `mint`'s address under `token_program`, whose mint authority +/// is `payer`, and return its address. Every later helper reads the program back +/// off the mint, so the wrappers above are the only place a test names it. /// /// This open-codes what [`litesvm_token::CreateMint`] does rather than calling /// it, because that builder generates the mint keypair with `Keypair::new()` /// internally and offers no way to supply one. A mint address is a seed of its /// buffer PDA, so a random one makes buffer bumps — and the compute cost of /// deriving them — vary between runs. See [`super::unique_pubkey`]. -pub fn create_mint(svm: &mut LiteSVM, payer: &Keypair) -> Pubkey { - create_mint_at(svm, payer, &unique_keypair()) -} - -/// [`create_mint`] at `mint`'s address rather than a fresh one. Lets a test -/// reclaim an address a Token-2022 mint was just closed at, which is the only -/// way a legacy mint can end up where a Token-2022 one used to be. -pub fn create_mint_at(svm: &mut LiteSVM, payer: &Keypair, mint: &Keypair) -> Pubkey { +fn create_mint_at_under( + svm: &mut LiteSVM, + payer: &Keypair, + mint: &Keypair, + token_program: &Pubkey, +) -> Pubkey { /// `litesvm_token::CreateMint`'s default, kept so the two agree. const DECIMALS: u8 = 8; @@ -101,15 +112,12 @@ pub fn create_mint_at(svm: &mut LiteSVM, payer: &Keypair, mint: &Keypair) -> Pub Mint::LEN as u64, token_program, ); - let initialize = initialize_mint2(&TOKEN_ID, &mint.pubkey(), &payer.pubkey(), None, DECIMALS) - .expect("initialize_mint2 should build"); - let tx = Transaction::new_signed_with_payer( - &[create, initialize], - Some(&payer.pubkey()), - &[payer, mint], - svm.latest_blockhash(), + let initialize = under( + initialize_mint2(&TOKEN_ID, &mint.pubkey(), &payer.pubkey(), None, DECIMALS) + .expect("initialize_mint2 should build"), + token_program, ); - send_token_tx(svm, payer, &[&mint], &[create, initialize], "mint creation"); + send_token_tx(svm, payer, &[mint], &[create, initialize], "mint creation"); mint.pubkey() } diff --git a/programs/settlement/tests/common/token_2022.rs b/programs/settlement/tests/common/token_2022.rs index 284b5e06..92eb744a 100644 --- a/programs/settlement/tests/common/token_2022.rs +++ b/programs/settlement/tests/common/token_2022.rs @@ -24,9 +24,6 @@ use spl_token_2022_interface::{ state::{Account, Mint}, }; -/// The Token-2022 program, the counterpart of [`super::SPL_TOKEN_PROGRAM_ID`]. -const TOKEN_2022_PROGRAM_ID: Pubkey = TokenProgram::Token2022.address(); - /// Decimals every test mint carries, matching [`super::token::create_mint`] so /// a legacy and a Token-2022 mint differ only in their program. const DECIMALS: u8 = 8; @@ -122,15 +119,15 @@ impl Extensions { .map(|extension| { match extension { ExtensionType::MintCloseAuthority => initialize_mint_close_authority( - &TOKEN_2022_PROGRAM_ID, + &TokenProgram::Token2022.address(), mint, Some(authority), ), ExtensionType::NonTransferable => { - initialize_non_transferable_mint(&TOKEN_2022_PROGRAM_ID, mint) + initialize_non_transferable_mint(&TokenProgram::Token2022.address(), mint) } ExtensionType::TransferFeeConfig => initialize_transfer_fee_config( - &TOKEN_2022_PROGRAM_ID, + &TokenProgram::Token2022.address(), mint, Some(authority), Some(authority), @@ -162,12 +159,12 @@ pub fn create_mint( &mint.pubkey(), svm.minimum_balance_for_rent_exemption(space), space as u64, - &TOKEN_2022_PROGRAM_ID, + &TokenProgram::Token2022.address(), )]; instructions.extend(extensions.initializers(&mint.pubkey(), &payer.pubkey())); instructions.push( initialize_mint2( - &TOKEN_2022_PROGRAM_ID, + &TokenProgram::Token2022.address(), &mint.pubkey(), &payer.pubkey(), None, @@ -193,7 +190,7 @@ pub fn create_mint( /// to claim again. pub fn close_mint(svm: &mut LiteSVM, payer: &Keypair, mint: &Pubkey) { let ix = close_account( - &TOKEN_2022_PROGRAM_ID, + &TokenProgram::Token2022.address(), mint, &payer.pubkey(), &payer.pubkey(), diff --git a/programs/settlement/tests/reclaim_buffer.rs b/programs/settlement/tests/reclaim_buffer.rs index fd4a51b5..5050f602 100644 --- a/programs/settlement/tests/reclaim_buffer.rs +++ b/programs/settlement/tests/reclaim_buffer.rs @@ -459,7 +459,7 @@ fn reclaims_a_buffer_whose_mint_was_reopened_as_a_legacy_mint() { svm.get_account(&mint) .expect("the reopened mint should exist") .owner, - common::SPL_TOKEN_PROGRAM_ID, + TokenProgram::SplToken.address(), "sanity: the mint must now belong to the legacy program" ); diff --git a/programs/settlement/tests/settle_limit_prices.rs b/programs/settlement/tests/settle_limit_prices.rs index fbd39d29..a54ce96e 100644 --- a/programs/settlement/tests/settle_limit_prices.rs +++ b/programs/settlement/tests/settle_limit_prices.rs @@ -7,7 +7,7 @@ //! succeeds or is rejected with the expected error. use crate::common::{ - assert_instruction_error_at, + assert_settlement_error, order::OrderBuilder, send, settlement::{build_staged_settlement, stage_order, StagedOrder, BEGIN_INDEX}, @@ -28,19 +28,6 @@ use solana_sdk::{ mod common; -/// Convenience wrapper around [`assert_instruction_error_at`] for asserting a -/// specific [`SettlementError`] at the instruction that produced it: settlements -/// run as a `[BeginSettle, FinalizeSettle]` pair, so the failing instruction -/// isn't always the first. -#[track_caller] -fn assert_settlement_error( - ix_idx: u8, - result: Result, - expected: SettlementError, -) { - assert_instruction_error_at(ix_idx, result, to_instruction_error(expected)); -} - /// Read `intent`'s order PDA and return its persisted `(amount_withdrawn, /// amount_received)` cumulative fill totals. fn order_fill(svm: &LiteSVM, program_id: &Pubkey, intent: &OrderIntent) -> (u64, u64) { diff --git a/programs/settlement/tests/settle_solver_auth.rs b/programs/settlement/tests/settle_solver_auth.rs index 82e87428..20396ec8 100644 --- a/programs/settlement/tests/settle_solver_auth.rs +++ b/programs/settlement/tests/settle_solver_auth.rs @@ -4,7 +4,7 @@ //! unauthorized caller is rejected before any settlement work happens. use cow_settlement_client::cow_settlement_interface::{Instruction, SettlementError}; -use cow_settlement_client::instructions::{BeginSettle, FinalizeSettle}; +use cow_settlement_client::instructions::{BeginSettle, FinalizeSettle, TokenPrograms}; use solana_sdk::{pubkey::Pubkey, signature::Signer, transaction::Transaction}; use crate::common::{ @@ -24,11 +24,13 @@ fn noop_settlement(program_id: &Pubkey, solver: &Pubkey) -> Vec { solver: *solver, finalize_ix_index: FINALIZE_INDEX.into(), auction_id: 0, + token_programs: TokenPrograms::NONE, orders: &[], }; let finalize = FinalizeSettle { program_id: *program_id, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::NONE, orders: &[], }; vec![begin.into(), finalize.into()] @@ -99,6 +101,7 @@ fn non_signing_solver_may_not_settle() { solver: solver.pubkey(), finalize_ix_index: 0, auction_id: 0, + token_programs: TokenPrograms::NONE, orders: &[], } .into(); diff --git a/programs/settlement/tests/settle_token_programs.rs b/programs/settlement/tests/settle_token_programs.rs index 6306d920..33202eaf 100644 --- a/programs/settlement/tests/settle_token_programs.rs +++ b/programs/settlement/tests/settle_token_programs.rs @@ -12,12 +12,10 @@ use crate::common::{ assert_settlement_error, buffer, order::OrderBuilder, settlement::{BEGIN_INDEX, FINALIZE_INDEX}, - setup, token, unique_pubkey, + setup_settle_ready, token, unique_pubkey, }; use cow_settlement_client::cow_settlement_interface::{ - data::intent::OrderIntent, - token_program::{SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID}, - Instruction, SettlementError, + data::intent::OrderIntent, token_program::TokenProgram, Instruction, SettlementError, }; use cow_settlement_client::instructions::{ BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, TokenPrograms, @@ -49,6 +47,7 @@ fn settle_with( svm: &mut LiteSVM, program_id: &Pubkey, payer: &Keypair, + solver: &Keypair, orders: &[Settled], begin_programs: TokenPrograms, finalize_programs: TokenPrograms, @@ -79,13 +78,13 @@ fn settle_with( buffer::ensure_funded(svm, program_id, payer, &buy_mint, order.amount_out); finalized.push(FinalizedIntent { intent, - mint: buy_mint, amount: order.amount_out, }); } let begin = BeginSettle { program_id: *program_id, + solver: solver.pubkey(), finalize_ix_index: FINALIZE_INDEX.into(), auction_id: 0, token_programs: begin_programs, @@ -100,7 +99,7 @@ fn settle_with( let tx = Transaction::new_signed_with_payer( &[begin.into(), finalize.into()], Some(&payer.pubkey()), - &[payer], + &[payer, solver], svm.latest_blockhash(), ); svm.send_transaction(tx) @@ -144,29 +143,30 @@ fn order_across( /// programs, each transfer issued against the program that owns the account. #[test] fn settles_orders_under_both_token_programs() { - let (mut svm, program_id, payer) = setup(); + let (mut svm, program_id, payer, solver) = setup_settle_ready(); let legacy = order_across( &mut svm, &program_id, &payer, 0, - &SPL_TOKEN_PROGRAM_ID, - &SPL_TOKEN_PROGRAM_ID, + &TokenProgram::SplToken.address(), + &TokenProgram::SplToken.address(), ); let token_2022 = order_across( &mut svm, &program_id, &payer, 1, - &TOKEN_2022_PROGRAM_ID, - &TOKEN_2022_PROGRAM_ID, + &TokenProgram::Token2022.address(), + &TokenProgram::Token2022.address(), ); settle_with( &mut svm, &program_id, &payer, + &solver, &[ Settled { intent: &legacy, @@ -195,21 +195,22 @@ fn settles_orders_under_both_token_programs() { /// sell account's owner and the push the buy account's, independently. #[test] fn settles_an_order_that_crosses_token_programs() { - let (mut svm, program_id, payer) = setup(); + let (mut svm, program_id, payer, solver) = setup_settle_ready(); let intent = order_across( &mut svm, &program_id, &payer, 0, - &SPL_TOKEN_PROGRAM_ID, - &TOKEN_2022_PROGRAM_ID, + &TokenProgram::SplToken.address(), + &TokenProgram::Token2022.address(), ); settle_with( &mut svm, &program_id, &payer, + &solver, &[Settled { intent: &intent, amount_in: 250, @@ -228,21 +229,22 @@ fn settles_an_order_that_crosses_token_programs() { /// the legacy slot holding the placeholder costs it nothing it needs. #[test] fn settles_token_2022_orders_without_carrying_the_legacy_program() { - let (mut svm, program_id, payer) = setup(); + let (mut svm, program_id, payer, solver) = setup_settle_ready(); let intent = order_across( &mut svm, &program_id, &payer, 0, - &TOKEN_2022_PROGRAM_ID, - &TOKEN_2022_PROGRAM_ID, + &TokenProgram::Token2022.address(), + &TokenProgram::Token2022.address(), ); settle_with( &mut svm, &program_id, &payer, + &solver, &[Settled { intent: &intent, amount_in: 300, @@ -260,15 +262,15 @@ fn settles_token_2022_orders_without_carrying_the_legacy_program() { /// out is what it refuses — by name, rather than as a malformed account. #[test] fn rejects_a_sell_account_under_a_left_out_program() { - let (mut svm, program_id, payer) = setup(); + let (mut svm, program_id, payer, solver) = setup_settle_ready(); let intent = order_across( &mut svm, &program_id, &payer, 0, - &TOKEN_2022_PROGRAM_ID, - &SPL_TOKEN_PROGRAM_ID, + &TokenProgram::Token2022.address(), + &TokenProgram::SplToken.address(), ); assert_settlement_error( @@ -277,6 +279,7 @@ fn rejects_a_sell_account_under_a_left_out_program() { &mut svm, &program_id, &payer, + &solver, &[Settled { intent: &intent, amount_in: 100, @@ -294,15 +297,15 @@ fn rejects_a_sell_account_under_a_left_out_program() { /// runs first and passes: it only pulls, and this order's sell side is legacy. #[test] fn rejects_a_buy_account_under_a_left_out_program() { - let (mut svm, program_id, payer) = setup(); + let (mut svm, program_id, payer, solver) = setup_settle_ready(); let intent = order_across( &mut svm, &program_id, &payer, 0, - &SPL_TOKEN_PROGRAM_ID, - &TOKEN_2022_PROGRAM_ID, + &TokenProgram::SplToken.address(), + &TokenProgram::Token2022.address(), ); assert_settlement_error( @@ -311,6 +314,7 @@ fn rejects_a_buy_account_under_a_left_out_program() { &mut svm, &program_id, &payer, + &solver, &[Settled { intent: &intent, amount_in: 100, @@ -328,15 +332,15 @@ fn rejects_a_buy_account_under_a_left_out_program() { /// else. #[test] fn rejects_swapped_token_program_slots() { - let (mut svm, program_id, payer) = setup(); + let (mut svm, program_id, payer, solver) = setup_settle_ready(); let intent = order_across( &mut svm, &program_id, &payer, 0, - &SPL_TOKEN_PROGRAM_ID, - &SPL_TOKEN_PROGRAM_ID, + &TokenProgram::SplToken.address(), + &TokenProgram::SplToken.address(), ); token::fund_and_delegate( &mut svm, @@ -356,6 +360,7 @@ fn rejects_swapped_token_program_slots() { }]; let mut begin = Instruction::from(BeginSettle { program_id, + solver: solver.pubkey(), finalize_ix_index: FINALIZE_INDEX.into(), auction_id: 0, token_programs: TokenPrograms::BOTH, @@ -364,17 +369,16 @@ fn rejects_swapped_token_program_slots() { pulls: &pulls, }], }); - // `BeginSettle`'s accounts are `[sysvar, state, spl_token, token_2022, ...]`, - // so exchanging the two slots leaves both programs present but each in the - // other's position. - begin.accounts.swap(2, 3); + // `BeginSettle`'s accounts are `[solver, sysvar, state, spl_token, + // token_2022, ...]`, so exchanging the two slots leaves both programs + // present but each in the other's position. + begin.accounts.swap(3, 4); let finalize = FinalizeSettle { program_id, begin_ix_index: BEGIN_INDEX.into(), token_programs: TokenPrograms::BOTH, orders: &[FinalizedIntent { intent: &intent, - mint: buy_mint, amount: 100, }], }; @@ -382,7 +386,7 @@ fn rejects_swapped_token_program_slots() { let tx = Transaction::new_signed_with_payer( &[begin, finalize.into()], Some(&payer.pubkey()), - &[&payer], + &[&payer, &solver], svm.latest_blockhash(), ); let error = svm @@ -400,21 +404,22 @@ fn rejects_swapped_token_program_slots() { /// only the accounts under the left-out program that become unsettleable. #[test] fn accepts_the_placeholder_for_a_legacy_only_settlement() { - let (mut svm, program_id, payer) = setup(); + let (mut svm, program_id, payer, solver) = setup_settle_ready(); let intent = order_across( &mut svm, &program_id, &payer, 0, - &SPL_TOKEN_PROGRAM_ID, - &SPL_TOKEN_PROGRAM_ID, + &TokenProgram::SplToken.address(), + &TokenProgram::SplToken.address(), ); settle_with( &mut svm, &program_id, &payer, + &solver, &[Settled { intent: &intent, amount_in: 500, From 06d41a131f743d9a91fe7a656184416a628bf34d Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:30:32 +0900 Subject: [PATCH 2/2] Dispatch on the token account's owner instead of parsing the program MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every token-moving instruction still names its token program — a CPI can only dispatch to a program its own instruction names — but nothing reads those accounts any more. The program a transfer targets is the one owning the account it moves, which the account itself says, so the slots are skipped positionally and dropped from the parsed inputs. `BeginSettle` pulls against the sell account's owner and `FinalizeSettle` pushes against the destination's owner; `CreateBuffer` creates each buffer under its mint's owner and `ReclaimBuffer` closes each one through the program that owns it. That takes ~110-170 CU off every settlement, and costs ~35 CU per buffer in the two batch instructions, which resolve once per buffer rather than once per instruction. `TokenProgramNotProvided` went with the slot validation: a settlement that leaves out a program its accounts live under is now refused by the runtime as a missing account. `PushDestinationInvalid` takes its code, both being new on this stack. Co-Authored-By: Claude Opus 5 (1M context) --- client/src/instructions.rs | 16 +- interface/src/instruction/create_buffer.rs | 12 +- interface/src/instruction/reclaim_buffer.rs | 10 +- interface/src/instruction/settle/begin.rs | 27 +-- interface/src/instruction/settle/finalize.rs | 20 +- interface/src/lib.rs | 8 +- interface/src/token_program.rs | 23 +- programs/settlement/idl/cow_settlement.json | 13 +- programs/settlement/src/create_buffer.rs | 20 +- programs/settlement/src/reclaim_buffer.rs | 25 +- programs/settlement/src/settle/begin.rs | 20 +- programs/settlement/src/settle/finalize.rs | 25 +- programs/settlement/src/token.rs | 226 +++--------------- .../settlement/tests/begin_settle_orders.rs | 35 +-- programs/settlement/tests/common/token.rs | 18 ++ programs/settlement/tests/create_buffer.rs | 26 +- .../tests/finalize_settle_pushes.rs | 7 +- programs/settlement/tests/reclaim_buffer.rs | 5 +- .../settlement/tests/settle_token_programs.rs | 64 ++--- 19 files changed, 194 insertions(+), 406 deletions(-) diff --git a/client/src/instructions.rs b/client/src/instructions.rs index 8291435e..1d8116ec 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -457,16 +457,12 @@ mod tests { ); let (state_pda, _bump) = find_state_pda(&program_id); prop_assert_eq!(parsed.state_pda_account.address(), &state_pda); - prop_assert_eq!( - parsed.spl_token_program_account.address(), - &SPL_TOKEN_PROGRAM_ID, - ); - // These settlements are legacy-only, so Token-2022's slot stands - // empty. - prop_assert_eq!( - parsed.token_2022_program_account.address(), - &SYSTEM_PROGRAM_ID, - ); + // The token-program slots aren't parsed, so the instruction's own + // account list is where they are checked: the legacy program in its + // own slot, and — these settlements being legacy-only — the + // placeholder in Token-2022's. + prop_assert_eq!(ix.accounts[2].pubkey, SPL_TOKEN_PROGRAM_ID); + prop_assert_eq!(ix.accounts[3].pubkey, SYSTEM_PROGRAM_ID); let parsed_pushes: Vec<_> = parsed.pushes.iter().collect(); prop_assert_eq!(parsed_pushes.len(), expected.len()); diff --git a/interface/src/instruction/create_buffer.rs b/interface/src/instruction/create_buffer.rs index 976d94d8..e017196c 100644 --- a/interface/src/instruction/create_buffer.rs +++ b/interface/src/instruction/create_buffer.rs @@ -76,7 +76,6 @@ pub struct BufferAccounts<'a, A> { /// Parsed inputs of a `CreateBuffer` instruction. pub struct CreateBufferInput<'a, A> { pub payer: &'a A, - pub token_program: &'a A, buffer_pairs: &'a [[A; 2]], } @@ -98,10 +97,11 @@ impl<'a, A> InstructionInputParsing<'a, A> for CreateBufferInput<'a, A> { } // Accounts: [payer (W,S), system_program (R), token_program (R), // (buffer_pda (W), mint (R))...]. The three shared accounts come first; - // the per-buffer pairs follow, one pair per buffer. The system program - // needs to be present for the `CreateAccount` CPI but isn't dereferenced - // here. - let [payer, _system, token_program, rest @ ..] = accounts else { + // the per-buffer pairs follow, one pair per buffer. Neither program is + // dereferenced here: they need to be present for the `CreateAccount` + // and `InitializeAccount3` CPIs to dispatch, and each buffer's program + // is the one that owns its mint. + let [payer, _system, _token_program, rest @ ..] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); }; // Group the trailing accounts into `[buffer_pda, mint]` pairs. Each @@ -116,7 +116,6 @@ impl<'a, A> InstructionInputParsing<'a, A> for CreateBufferInput<'a, A> { Ok(Self { payer, - token_program, buffer_pairs: buffers, }) } @@ -186,7 +185,6 @@ mod tests { let input = CreateBufferInput::parse(&data, &accounts).expect("parse should succeed"); assert_eq!(*input.payer.address(), payer); - assert_eq!(*input.token_program.address(), token_program); let buffers: Vec<_> = input.buffers().collect(); assert_eq!(buffers.len(), 1, "one buffer is one (pda, mint) pair"); assert_eq!(*buffers[0].buffer_pda.address(), buffer_pda); diff --git a/interface/src/instruction/reclaim_buffer.rs b/interface/src/instruction/reclaim_buffer.rs index ad400f43..e62c472f 100644 --- a/interface/src/instruction/reclaim_buffer.rs +++ b/interface/src/instruction/reclaim_buffer.rs @@ -72,7 +72,6 @@ pub struct ReclaimBufferInput<'a, A> { pub state_pda: &'a A, pub reclaim_authority: &'a A, pub reclaim_recipient: &'a A, - pub token_program: &'a A, /// One `[buffer_pda, mint]` pair per buffer to close. pub buffers: &'a [[A; 2]], } @@ -87,8 +86,10 @@ impl<'a, A> InstructionInputParsing<'a, A> for ReclaimBufferInput<'a, A> { // Accounts: [state_pda (R), reclaim_authority (R,S), reclaim_recipient // (W), token_program (R), (buffer_pda (W), mint (R))...]. The four // shared accounts come first; the per-buffer pairs follow, one pair per - // buffer. - let [state_pda, reclaim_authority, reclaim_recipient, token_program, rest @ ..] = accounts + // buffer. The token program is skipped rather than read: each buffer is + // closed by the program that owns it, so the account is only there to + // put that program in the transaction. + let [state_pda, reclaim_authority, reclaim_recipient, _token_program, rest @ ..] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); }; @@ -105,7 +106,6 @@ impl<'a, A> InstructionInputParsing<'a, A> for ReclaimBufferInput<'a, A> { state_pda, reclaim_authority, reclaim_recipient, - token_program, buffers, }) } @@ -183,14 +183,12 @@ mod tests { state_pda: parsed_state_pda, reclaim_authority: parsed_reclaim_authority, reclaim_recipient: parsed_reclaim_recipient, - token_program: parsed_token_program, buffers, } = ReclaimBufferInput::parse(&data, &accounts).expect("parse should succeed"); assert_eq!(*parsed_state_pda.address(), state_pda); assert_eq!(*parsed_reclaim_authority.address(), reclaim_authority); assert_eq!(*parsed_reclaim_recipient.address(), reclaim_recipient); - assert_eq!(*parsed_token_program.address(), token_program); assert_eq!(buffers.len(), 1, "one buffer is one pair"); assert_eq!(*buffers[0][0].address(), buffer_pda); assert_eq!(*buffers[0][1].address(), mint); diff --git a/interface/src/instruction/settle/begin.rs b/interface/src/instruction/settle/begin.rs index 76ce8382..22200e82 100644 --- a/interface/src/instruction/settle/begin.rs +++ b/interface/src/instruction/settle/begin.rs @@ -37,8 +37,10 @@ pub struct Pull { /// spl_token_program (R), token_2022_program (R)]` followed, per order, by /// `[order_pda (W), sell_token_account (W), destination (W)...]`. The two token /// programs are the slots [`TokenPrograms`] describes: each transfer is issued -/// against the program that owns the account it moves, and a program this -/// settlement doesn't touch is left out with the system program. +/// against the program that owns the account it moves, so the slots are there +/// to name those programs — a CPI can only dispatch to a program the +/// instruction names. A program this settlement doesn't touch is left out with +/// the system program. /// /// `solver` must sign, and the solver must be registered in the state pda. /// @@ -214,11 +216,6 @@ pub struct BeginSettleInput<'a, A> { pub solver_account: &'a A, pub instructions_sysvar_account: &'a A, pub state_pda_account: &'a A, - /// The legacy SPL Token program's slot: the program itself, or the - /// placeholder where this settlement moves no token under it. - pub spl_token_program_account: &'a A, - /// Token-2022's slot, filled the same way. - pub token_2022_program_account: &'a A, pub orders: SettledOrders<'a, A>, } @@ -231,7 +228,11 @@ impl<'a, A> InstructionInputParsing<'a, A> for BeginSettleInput<'a, A> { fn parse_body(instruction_data: &'a [u8], accounts: &'a [A]) -> Result { let (finalize_ix_index, body) = recover_counterpart(instruction_data)?; - let [solver_account, instructions_sysvar_account, state_pda_account, spl_token_program_account, token_2022_program_account, order_accounts @ ..] = + // The two token-program slots are skipped rather than read: every + // transfer is issued against the program that owns the account it + // moves, so naming the programs is all the slots do. They still take up + // their positions, which is what the order accounts are counted from. + let [solver_account, instructions_sysvar_account, state_pda_account, _spl_token_program_account, _token_2022_program_account, order_accounts @ ..] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); @@ -285,8 +286,6 @@ impl<'a, A> InstructionInputParsing<'a, A> for BeginSettleInput<'a, A> { auction_id, instructions_sysvar_account, state_pda_account, - spl_token_program_account, - token_2022_program_account, solver_account, orders: SettledOrders { order_accounts, @@ -577,16 +576,12 @@ mod tests { solver_account, instructions_sysvar_account, orders, - spl_token_program_account, - token_2022_program_account, state_pda_account, } = BeginSettleInput::parse(&data, &accounts).expect("parse should succeed"); assert_eq!(finalize_ix_index, 0x1337); assert_eq!(auction_id, 0x0102_0304_0506_0708); assert_eq!(instructions_sysvar_account.address(), &sysvar); assert_eq!(orders.iter().count(), 0); - assert_eq!(spl_token_program_account.address(), &spl_token_program); - assert_eq!(token_2022_program_account.address(), &token_2022_program); assert_eq!(state_pda_account.address(), &state); assert_eq!(solver_account.address(), &solver); } @@ -665,14 +660,10 @@ mod tests { instructions_sysvar_account, orders, state_pda_account, - spl_token_program_account, - token_2022_program_account, } = BeginSettleInput::parse(&data, &accounts).expect("parse should succeed"); assert_eq!(finalize_ix_index, 0x1337); assert_eq!(auction_id, AUCTION_ID); assert_eq!(instructions_sysvar_account.address(), &sysvar); - assert_eq!(spl_token_program_account.address(), &spl_token_program); - assert_eq!(token_2022_program_account.address(), &token_2022_program); assert_eq!(state_pda_account.address(), &state); assert_eq!(solver_account.address(), &solver); diff --git a/interface/src/instruction/settle/finalize.rs b/interface/src/instruction/settle/finalize.rs index 58d4ae89..d7e3d29c 100644 --- a/interface/src/instruction/settle/finalize.rs +++ b/interface/src/instruction/settle/finalize.rs @@ -83,7 +83,8 @@ pub fn finalize_push_data( /// `[instructions_sysvar (R), state_pda (R), spl_token_program (R), /// token_2022_program (R)]` followed, per push, by `[source_buffer (W), /// destination (W)]`. The two token programs are the slots [`TokenPrograms`] -/// describes; the matching `BeginSettle` carries the same ones. +/// describes, there to name the programs this instruction's pushes are issued +/// against; the matching `BeginSettle` carries the ones its pulls need. /// /// `FinalizeSettle` only executes the transfers. Every push is validated by /// `BeginSettle`, which reads this instruction through introspection. @@ -208,11 +209,6 @@ pub struct FinalizeSettleInput<'a, A> { pub begin_ix_index: u16, pub instructions_sysvar_account: &'a A, pub state_pda_account: &'a A, - /// The legacy SPL Token program's slot: the program itself, or the - /// placeholder where this settlement moves no token under it. - pub spl_token_program_account: &'a A, - /// Token-2022's slot, filled the same way. - pub token_2022_program_account: &'a A, pub pushes: Pushes<'a, A>, } @@ -225,7 +221,11 @@ impl<'a, A> InstructionInputParsing<'a, A> for FinalizeSettleInput<'a, A> { fn parse_body(instruction_data: &'a [u8], accounts: &'a [A]) -> Result { let (begin_ix_index, body) = recover_counterpart(instruction_data)?; - let [instructions_sysvar_account, state_pda_account, spl_token_program_account, token_2022_program_account, push_accounts @ ..] = + // The two token-program slots are skipped rather than read: every push + // is issued against the program that owns its destination, so naming + // the programs is all the slots do. They still take up their positions, + // which is what the push accounts are counted from. + let [instructions_sysvar_account, state_pda_account, _spl_token_program_account, _token_2022_program_account, push_accounts @ ..] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); @@ -247,8 +247,6 @@ impl<'a, A> InstructionInputParsing<'a, A> for FinalizeSettleInput<'a, A> { begin_ix_index, instructions_sysvar_account, state_pda_account, - spl_token_program_account, - token_2022_program_account, pushes: Pushes { push_accounts, bumps, @@ -437,15 +435,11 @@ mod tests { begin_ix_index, instructions_sysvar_account, state_pda_account, - spl_token_program_account, - token_2022_program_account, pushes, } = FinalizeSettleInput::parse(&data, &accounts).expect("parse should succeed"); assert_eq!(begin_ix_index, 0x1337); assert_eq!(instructions_sysvar_account.address(), &sysvar); assert_eq!(state_pda_account.address(), &state); - assert_eq!(spl_token_program_account.address(), &spl_token_program); - assert_eq!(token_2022_program_account.address(), &token_2022_program); assert_eq!(pushes.iter().count(), 0); } diff --git a/interface/src/lib.rs b/interface/src/lib.rs index b5382c31..1b9b2aed 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -259,16 +259,10 @@ pub enum SettlementError { /// mint has to be and couldn't read the answer, so it can't size the /// buffer. BufferSizeUnavailable = 40, - /// `BeginSettle`/`FinalizeSettle`: a token account it has to move is owned - /// by a supported token program whose slot carries the system-program - /// placeholder, so there is no program to issue that transfer against. The - /// settlement has to carry every token program its accounts live under; see - /// [`token_program::TokenPrograms`]. - TokenProgramNotProvided = 41, /// `FinalizeSettle`: a push's destination isn't owned by a supported token /// program, so it is no token account at all and there is nothing to issue /// its transfer against. - PushDestinationInvalid = 42, + PushDestinationInvalid = 41, } impl From for u32 { diff --git a/interface/src/token_program.rs b/interface/src/token_program.rs index aadcf942..9672a33a 100644 --- a/interface/src/token_program.rs +++ b/interface/src/token_program.rs @@ -1,13 +1,16 @@ //! The token programs settlement transfers may be issued against. //! //! An instruction that moves tokens has to name the program to issue its -//! transfers against, and that program has to be one of [`TokenProgram::ALL`], -//! which is what [`TokenProgram::try_from`] resolves an address against. How it -//! names them differs by instruction: +//! transfers against — a CPI can only dispatch to a program its instruction +//! names — and the program it targets is the one owning the account it moves, +//! which [`TokenProgram::try_from`] resolves from that account's owner. Naming +//! is all the accounts below do; none of them is read on-chain. How an +//! instruction names them differs: //! //! - `CreateBuffer` and `ReclaimBuffer` take a single `token_program` account. -//! Each works on one program's accounts at a time, so a mint under the other -//! needs its own instruction. +//! Each buffer is created under, and closed by, the program owning its mint, +//! so a mint under the program the instruction didn't name needs its own +//! instruction. //! - `BeginSettle` and `FinalizeSettle` take one account per supported program, //! described by [`TokenPrograms`], and issue each transfer against the //! program that owns the account it moves. One settlement can therefore mix @@ -65,11 +68,11 @@ impl TryFrom<&Pubkey> for TokenProgram { /// Both instructions take one account per supported program, at fixed positions /// and in [`TokenProgram::ALL`] order, and issue each transfer against the /// program that owns the account it moves — so a single settlement may mix -/// tokens from both. A program the settlement doesn't touch is left out by -/// putting [`SYSTEM_PROGRAM_ID`] in its slot: the transfers still need their -/// program to be named by the transaction, and the placeholder says this one -/// isn't. A token account under a left-out program has nothing to be settled -/// against and is rejected. +/// tokens from both. The slots are what name those programs; they are not read +/// on-chain, and a program the settlement doesn't touch is left out by putting +/// [`SYSTEM_PROGRAM_ID`] in its slot. A transfer of a token account under a +/// left-out program then has no program to dispatch to, and the runtime refuses +/// the instruction. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct TokenPrograms { /// Whether the legacy SPL Token program's slot carries the program rather diff --git a/programs/settlement/idl/cow_settlement.json b/programs/settlement/idl/cow_settlement.json index 8bd47cf9..954eee85 100644 --- a/programs/settlement/idl/cow_settlement.json +++ b/programs/settlement/idl/cow_settlement.json @@ -86,7 +86,7 @@ "name": "create_buffer", "docs": [ "Creates one or more per-token buffer PDAs (token accounts) in a single instruction.", - "Every buffer created by one instruction is owned by the single token_program the instruction is handed, so mints spread across both supported token programs need one instruction each.", + "Every buffer is created under the token program that owns its mint, and the instruction names a single token_program for those CPIs to dispatch to, so mints spread across both supported token programs need one instruction each.", "IDL LIMITATION: the real instruction accepts an unbounded number of (buffer_pda, mint) pairs as remaining accounts, one pair per buffer, with at least one pair required (CreateBuffer rejects zero buffers). IDL grammar has no 'repeated group' construct, so this file only declares the guaranteed index-0 template (buffer_pda_0/mint_0).", "Each buffer_pda_i must be the canonical PDA for seeds [SETTLEMENT_SEED, mint_i, \"buffer\"]." ], @@ -106,7 +106,7 @@ { "name": "token_program", "docs": [ - "The token program that will own the created buffer PDAs. Must be one of the supported token accounts." + "The token program the created buffer PDAs are initialized through. Must be one of the supported token programs, and the one owning every mint this instruction is handed." ] }, { @@ -333,7 +333,7 @@ "name": "reclaim_buffer", "docs": [ "Closes one or more buffer PDAs and sends each closed buffer's rent lamports to a reclaim_recipient of the caller's choosing. Only the current holder of the ReclaimAuthority role recorded in the state PDA may authorize this. A buffer that still holds tokens is skipped, not closed, and the instruction still succeeds.", - "Every buffer closed by one instruction must be owned by the single token_program the instruction is handed, so buffers spread across both supported token programs need one instruction each.", + "Every buffer is closed by the token program that owns it, and the instruction names a single token_program for those CPIs to dispatch to, so buffers spread across both supported token programs need one instruction each.", "IDL LIMITATION: the real instruction accepts an unbounded number of (buffer_pda, mint) pairs as remaining accounts, one pair per buffer, with at least one pair required (ReclaimBuffer rejects zero buffers). IDL grammar has no 'repeated group' construct, so this file only declares the guaranteed index-0 template (buffer_pda_0/mint_0).", "Each buffer_pda_i must be the canonical PDA for seeds [SETTLEMENT_SEED, mint_i, \"buffer\"]; mint_i is passed only so that derivation can be checked on-chain." ], @@ -392,7 +392,7 @@ { "name": "token_program", "docs": [ - "The token program that owns the created buffer PDAs. Must be one of the supported token accounts." + "The token program the buffer PDAs are closed through. Must be one of the supported token programs, and the one owning every buffer this instruction is handed." ] }, { @@ -1055,11 +1055,6 @@ }, { "code": 41, - "name": "TokenProgramNotProvided", - "msg": "BeginSettle/FinalizeSettle: a token account it has to move is owned by a supported token program whose slot carries the system-program placeholder, so there is no program to issue that transfer against. The settlement has to carry every token program its accounts live under; see token_program::TokenPrograms." - }, - { - "code": 42, "name": "PushDestinationInvalid", "msg": "FinalizeSettle: a push's destination isn't owned by a supported token program, so it is no token account at all and there is nothing to issue its transfer against." } diff --git a/programs/settlement/src/create_buffer.rs b/programs/settlement/src/create_buffer.rs index fd9318ff..d0e86372 100644 --- a/programs/settlement/src/create_buffer.rs +++ b/programs/settlement/src/create_buffer.rs @@ -12,7 +12,7 @@ use pinocchio_token::instructions::InitializeAccount3; use crate::{ processor::CanonicalPda, - token::{token_account_len, validate_token_program}, + token::{owning_token_program, token_account_len}, }; pub fn process_create_buffer( @@ -22,12 +22,6 @@ pub fn process_create_buffer( ) -> ProgramResult { let input = CreateBufferInput::parse(instruction_data, accounts)?; - // Every buffer this instruction creates belongs to the one token program - // it was handed, so reject an unsupported one up front rather than at the - // first CPI. - let token_program = validate_token_program(input.token_program)?; - let token_program_id = token_program.address(); - // The buffers' token authority is the settlement state PDA, the single // authority over every buffer. Derive it once for all buffers. let (state_pda, _) = Address::find_program_address(&state_pda_seeds(), program_id); @@ -39,9 +33,15 @@ pub fn process_create_buffer( // is a token account, so it's assigned to the token program rather than // to the settlement program. // - // We don't validate `mint` here. `InitializeAccount3` requires a real, - // token-program-owned mint (and special-cases the native mint), so a - // check of our own would be redundant. + // A buffer belongs to the same program as the mint it holds, so the + // mint's owner is what says which program to allocate it to and + // initialize it with. That is also the only check `mint` needs here: + // `InitializeAccount3` requires a real mint of that program (and + // special-cases the native mint), so a check of our own would be + // redundant. + let token_program = owning_token_program(mint)?; + let token_program_id = token_program.address(); + let mint_key = mint.address().as_array(); let (created, _) = CanonicalPda { program_id, diff --git a/programs/settlement/src/reclaim_buffer.rs b/programs/settlement/src/reclaim_buffer.rs index 151e6d53..33c1659d 100644 --- a/programs/settlement/src/reclaim_buffer.rs +++ b/programs/settlement/src/reclaim_buffer.rs @@ -16,7 +16,7 @@ use pinocchio_token::instructions::CloseAccount; use crate::{ processor::with_state_pda_signer, - token::{read_token_account, validate_token_program}, + token::{owning_token_program, read_token_account}, }; pub fn process_reclaim_buffer( @@ -28,13 +28,9 @@ pub fn process_reclaim_buffer( state_pda, reclaim_authority, reclaim_recipient, - token_program, buffers, } = ReclaimBufferInput::parse(instruction_data, accounts)?; - let token_program = validate_token_program(token_program)?; - let token_program_id = token_program.address(); - with_state_pda_signer(program_id, state_pda, |state_signer| { let reclaim_authority_pubkey: Pubkey = StateAccount::from_account(state_pda)?.authority(Role::ReclaimAuthority); @@ -51,6 +47,9 @@ pub fn process_reclaim_buffer( return Err(SettlementError::ReclaimBufferNotCanonical.into()); } + // A buffer is closed by the program that owns it, which is the one + // that created it in the first place. + let token_program = owning_token_program(buffer_pda)?; let amount = read_token_account(token_program, buffer_pda)?.amount; // A token account can't be closed while it still holds a balance, and this @@ -63,7 +62,7 @@ pub fn process_reclaim_buffer( CloseAccount::new(buffer_pda, reclaim_recipient, state_pda) .invoke_signed_with_unverified_program( core::slice::from_ref(state_signer), - &token_program_id, + &token_program.address(), )?; } @@ -101,7 +100,6 @@ mod tests { // Positions within [`base_accounts`], for the tests that swap one entry. const STATE_PDA: usize = 0; const RECLAIM_AUTHORITY: usize = 1; - const TOKEN_PROGRAM: usize = 3; const BUFFER_PDA: usize = 4; /// State account bytes for planting a well-formed state PDA in tests. @@ -179,10 +177,14 @@ mod tests { .unwrap_or_else(|err| panic!("reclaim buffer happy path should succeed: {err}")); } + /// The buffer's own owner is what says which program closes it, so one + /// owned by neither token program is refused: there is nothing to close it + /// with. #[test] - fn process_reclaim_buffer_rejects_wrong_token_program() { + fn process_reclaim_buffer_rejects_a_buffer_under_an_unrelated_program() { let mut accounts = base_accounts(); - accounts[TOKEN_PROGRAM] = fake_account(UNRELATED); + let buffer_pda = *accounts[BUFFER_PDA].address(); + accounts[BUFFER_PDA] = fake_account_owned_by(buffer_pda, UNRELATED, &[]); assert_rejects(accounts, ProgramError::IncorrectProgramId); } @@ -243,6 +245,9 @@ mod tests { assert_rejects(accounts, SettlementError::ReclaimBufferNotCanonical.into()); } + /// A buffer that was never created is owned by the system program, so it + /// is refused as an account no token program can close rather than read as + /// a malformed token account. #[test] fn process_reclaim_buffer_rejects_uninitialized_buffer_pda() { let mut accounts = base_accounts(); @@ -250,6 +255,6 @@ mod tests { let buffer_pda = *accounts[BUFFER_PDA].address(); accounts[BUFFER_PDA] = fake_account(buffer_pda); - assert_rejects(accounts, ProgramError::InvalidAccountData); + assert_rejects(accounts, ProgramError::IncorrectProgramId); } } diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index 3a4f5d83..a52aabe7 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -31,7 +31,7 @@ use pinocchio_token::instructions::Transfer; use crate::{ processor::{check_state_pda, is_cpi_call, require_solver, with_state_pda_signer_from_bump}, - token::{read_token_account, TokenPrograms}, + token::{owning_token_program, read_token_account}, }; use super::validate_counterpart; @@ -76,11 +76,6 @@ pub fn process_begin_settle( let finalize_ix = instructions.load_instruction_at(usize::from(input.finalize_ix_index))?; - let token_programs = TokenPrograms::validate( - input.spl_token_program_account, - input.token_2022_program_account, - )?; - with_state_pda_signer_from_bump(state_bump, |signer| { settle_orders( program_id, @@ -88,7 +83,6 @@ pub fn process_begin_settle( signer, &input.orders, &finalize_ix, - &token_programs, ) }) } @@ -209,7 +203,6 @@ fn settle_orders( state_pda_signer: &Signer, orders: &SettledOrders<'_, AccountView>, finalize_ix: &IntrospectedInstruction, - token_programs: &TokenPrograms, ) -> ProgramResult { // Orders must be passed strictly increasing by address; this rejects // duplicates (settling the same order twice) without a separate scan. @@ -240,7 +233,6 @@ fn settle_orders( now, state_pda_account, state_pda_signer, - token_programs, )?; } @@ -264,7 +256,6 @@ fn process_order( now: i64, state_account: &AccountView, state_pda_signer: &Signer, - token_programs: &TokenPrograms, ) -> ProgramResult { let SettledOrder { order_pda, @@ -303,11 +294,10 @@ fn process_order( return Err(SettlementError::SellTokenAccountMismatch.into()); } // The pulls below move this account's tokens, so they are issued against - // the token program that owns it — the one this settlement has to be - // carrying. An account under neither program isn't a token account at all. - let token_program = token_programs - .program_for(sell_token_account)? - .ok_or(SettlementError::SellTokenAccountInvalid)?; + // the token program that owns it. An account under neither program isn't a + // token account at all. + let token_program = owning_token_program(sell_token_account) + .map_err(|_| SettlementError::SellTokenAccountInvalid)?; // Assert the order intent owner and sell mint match those of the sell token // account. // `read_token_account` confirms this is a real token account of that token diff --git a/programs/settlement/src/settle/finalize.rs b/programs/settlement/src/settle/finalize.rs index b52d8f70..7005b512 100644 --- a/programs/settlement/src/settle/finalize.rs +++ b/programs/settlement/src/settle/finalize.rs @@ -14,7 +14,7 @@ use pinocchio_token::instructions::Transfer; use crate::{ processor::{is_cpi_call, with_state_pda_signer}, - token::TokenPrograms, + token::owning_token_program, }; use super::validate_counterpart; @@ -47,18 +47,8 @@ pub fn process_finalize_settle( // the canonical buffer for the order's buy mint. Nothing is left to check // here, so `push_funds` only executes the transfers. - let token_programs = TokenPrograms::validate( - input.spl_token_program_account, - input.token_2022_program_account, - )?; - with_state_pda_signer(program_id, input.state_pda_account, |state_pda_signer| { - push_funds( - input.state_pda_account, - state_pda_signer, - input.pushes, - &token_programs, - ) + push_funds(input.state_pda_account, state_pda_signer, input.pushes) }) } @@ -77,16 +67,13 @@ fn push_funds<'a>( state_pda_account: &AccountView, state_pda_signer: &Signer, pushes: Pushes<'a, AccountView>, - token_programs: &TokenPrograms, ) -> ProgramResult { for push in pushes.iter() { // The push moves this destination's tokens, so it is issued against the - // token program that owns it — the one this settlement has to be - // carrying. An account under neither program isn't a token account at - // all. - let token_program = token_programs - .program_for(push.destination)? - .ok_or(SettlementError::PushDestinationInvalid)?; + // token program that owns it. An account under neither program isn't a + // token account at all. + let token_program = owning_token_program(push.destination) + .map_err(|_| SettlementError::PushDestinationInvalid)?; Transfer::new( push.source_buffer, push.destination, diff --git a/programs/settlement/src/token.rs b/programs/settlement/src/token.rs index c6379adb..b6355651 100644 --- a/programs/settlement/src/token.rs +++ b/programs/settlement/src/token.rs @@ -1,9 +1,6 @@ -//! Token-program validation and token-account reads +//! Token-program dispatch and token-account reads -use cow_settlement_interface::{ - token_program::{TokenProgram, SYSTEM_PROGRAM_ID}, - SettlementError, -}; +use cow_settlement_interface::{token_program::TokenProgram, SettlementError}; use pinocchio::{cpi::get_return_data, error::ProgramError, AccountView, Address}; use pinocchio_token::instructions::GetAccountDataSize; @@ -11,94 +8,21 @@ use pinocchio_token::instructions::GetAccountDataSize; /// the actual token account longer than this. const BASE_TOKEN_ACCOUNT_LEN: u64 = pinocchio_token::state::Account::LEN as u64; -/// Validate that `token_program_account` is a token program this program may -/// issue CPIs against, returning the program for the instruction to target. -#[must_use = "not consuming skips validation"] -pub fn validate_token_program( - token_program_account: &AccountView, -) -> Result { - TokenProgram::try_from(token_program_account.address()) -} - -/// The token programs a `BeginSettle`/`FinalizeSettle` was handed: one slot per -/// entry of [`TokenProgram::ALL`], holding either that program or the system -/// program standing in for "this settlement moves no token under it". +/// The token program that owns `account`, and so the one every transfer of its +/// tokens has to be issued against. /// -/// Built by [`TokenPrograms::validate`] and asked, per account, -/// [`which program owns it`](TokenPrograms::program_for). The instruction issues -/// that account's transfers against the answer, which is what lets a single -/// settlement mix tokens from both programs. -pub struct TokenPrograms { - /// Whether the legacy SPL Token program's slot held the program rather than - /// the placeholder. - spl_token: bool, - /// Token-2022's slot, filled the same way. - token_2022: bool, -} - -impl TokenPrograms { - /// Validate a settlement's two token-program slots, in the order the - /// instruction lays them out. - /// - /// Each slot has to hold either the program it stands for or - /// [`SYSTEM_PROGRAM_ID`]; anything else is a caller mistake rather than an - /// opt-out. A slot holding its program is also what puts that program in the - /// transaction, which is what makes the transfers' CPIs dispatchable at all. - #[must_use = "the returned slots decide which program each transfer targets"] - pub fn validate( - spl_token_account: &AccountView, - token_2022_account: &AccountView, - ) -> Result { - Ok(Self { - spl_token: validate_slot(spl_token_account, TokenProgram::SplToken)?, - token_2022: validate_slot(token_2022_account, TokenProgram::Token2022)?, - }) - } - - /// The token program `account`'s transfers must be issued against: the one - /// that owns it. - /// - /// `None` when `account` isn't owned by a supported token program, which - /// means it is no token account at all and the caller reports it as whatever - /// it failed to be. An account owned by a supported program whose slot held - /// the placeholder is a different matter: the settlement can't reach that - /// program, so it says so with [`SettlementError::TokenProgramNotProvided`] - /// rather than pretending the account is malformed. - pub fn program_for( - &self, - account: &AccountView, - ) -> Result, SettlementError> { - let Ok(owner) = TokenProgram::try_from(account.owner()) else { - return Ok(None); - }; - if self.carries(owner) { - Ok(Some(owner)) - } else { - Err(SettlementError::TokenProgramNotProvided) - } - } - - /// Whether `program`'s slot held it rather than the placeholder. The one - /// place a new [`TokenProgram`] variant has to be given a slot. - fn carries(&self, program: TokenProgram) -> bool { - match program { - TokenProgram::SplToken => self.spl_token, - TokenProgram::Token2022 => self.token_2022, - } - } -} - -/// Whether `program`'s slot holds it rather than the placeholder, rejecting an -/// address that is neither. -fn validate_slot(account: &AccountView, program: TokenProgram) -> Result { - let address = account.address(); - if address == &program.address() { - Ok(true) - } else if address == &SYSTEM_PROGRAM_ID { - Ok(false) - } else { - Err(ProgramError::IncorrectProgramId) - } +/// Reading the owner is what lets one instruction move tokens under either +/// program without being told which: the account itself says. An account under +/// anything else is no token account at all, and there is nothing to issue a +/// transfer against. +/// +/// The program the answer names still has to be one of the calling +/// instruction's own accounts, or the CPI issued against it has nothing to +/// dispatch to. Naming it is the caller's job, and the runtime is what enforces +/// it. +#[must_use = "not consuming skips the owner check"] +pub fn owning_token_program(account: &AccountView) -> Result { + TokenProgram::try_from(account.owner()) } /// The data length a token account holding `mint` has to be allocated at. @@ -286,11 +210,6 @@ mod tests { ); } - /// The settlement's own two slots, each holding the program it stands for. - fn both_slots() -> [AccountView; 2] { - TokenProgram::ALL.map(|program| fake_account(program.address())) - } - /// A token account of `program`, well-formed but empty of interest: only /// its owner decides which program its transfers go to. fn token_account_of(program: Address) -> AccountView { @@ -301,19 +220,15 @@ mod tests { ) } - /// A settlement carrying both programs settles accounts under either, each - /// against the program that owns it. This is what one instruction pair - /// mixing the two token programs rests on. + /// Every token account dispatches to the program that owns it. This is what + /// one instruction moving tokens under both programs rests on: nothing has + /// to tell it which, each account already says. #[test] - fn program_for_dispatches_on_the_accounts_owner() { - let [spl_token, token_2022] = both_slots(); - let programs = - TokenPrograms::validate(&spl_token, &token_2022).expect("both slots hold a program"); - + fn owning_token_program_dispatches_on_the_accounts_owner() { for program in TokenProgram::ALL { assert_eq!( - programs.program_for(&token_account_of(program.address())), - Ok(Some(program)), + owning_token_program(&token_account_of(program.address())), + Ok(program), "an account owned by {program:?} should be settled against it", ); } @@ -322,102 +237,21 @@ mod tests { /// An account under neither program is no token account at all, which the /// caller reports as whatever the account failed to be. #[test] - fn program_for_returns_nothing_for_an_unowned_account() { - let [spl_token, token_2022] = both_slots(); - let programs = - TokenPrograms::validate(&spl_token, &token_2022).expect("both slots hold a program"); - + fn owning_token_program_rejects_an_account_under_an_unrelated_program() { let unrelated = pubkey_from_seed("not a token program"); - assert_eq!(programs.program_for(&token_account_of(unrelated)), Ok(None)); - } - - /// A settlement that left a program out can't reach it, so an account under - /// it is refused by name rather than mistaken for a malformed one. - #[test] - fn program_for_rejects_an_account_under_a_left_out_program() { - let placeholder = fake_account(SYSTEM_PROGRAM_ID); - for [carried, left_out] in [ - [TokenProgram::SplToken, TokenProgram::Token2022], - [TokenProgram::Token2022, TokenProgram::SplToken], - ] { - let carried_account = fake_account(carried.address()); - let (spl_token, token_2022) = if carried == TokenProgram::SplToken { - (&carried_account, &placeholder) - } else { - (&placeholder, &carried_account) - }; - let programs = - TokenPrograms::validate(spl_token, token_2022).expect("the placeholder is allowed"); - - assert_eq!( - programs.program_for(&token_account_of(left_out.address())), - Err(SettlementError::TokenProgramNotProvided), - "{left_out:?} was left out, so its accounts have nothing to settle against", - ); - // The program that *is* carried still settles its own accounts. - assert_eq!( - programs.program_for(&token_account_of(carried.address())), - Ok(Some(carried)), - ); - } - } - - /// Leaving both programs out is allowed — it only makes every token account - /// unsettleable, which is exactly what a settlement moving no tokens wants. - #[test] - fn validate_accepts_two_placeholders() { - let placeholder = fake_account(SYSTEM_PROGRAM_ID); - let programs = TokenPrograms::validate(&placeholder, &placeholder) - .expect("two placeholders are allowed"); - - for program in TokenProgram::ALL { - assert_eq!( - programs.program_for(&token_account_of(program.address())), - Err(SettlementError::TokenProgramNotProvided), - ); - } - } - - /// The slots are positional: each one holds its own program or the - /// placeholder, so the two programs can't be swapped between them. - #[test] - fn validate_rejects_swapped_slots() { - let [spl_token, token_2022] = both_slots(); - assert_eq!( - TokenPrograms::validate(&token_2022, &spl_token).err(), - Some(ProgramError::IncorrectProgramId), - ); - } - - /// Anything that is neither the slot's program nor the placeholder is a - /// caller mistake, not an opt-out. - #[test] - fn validate_rejects_an_unrelated_account_in_a_slot() { - let unrelated = fake_account(pubkey_from_seed("not a token program")); - let [spl_token, token_2022] = both_slots(); - assert_eq!( - TokenPrograms::validate(&unrelated, &token_2022).err(), - Some(ProgramError::IncorrectProgramId), - ); assert_eq!( - TokenPrograms::validate(&spl_token, &unrelated).err(), - Some(ProgramError::IncorrectProgramId), + owning_token_program(&token_account_of(unrelated)), + Err(ProgramError::IncorrectProgramId), ); } + /// An account that was never allocated is owned by the system program, so + /// it is refused like any other non-token account rather than read as one. #[test] - fn validate_token_program_accepts_every_supported_program() { - for program in TokenProgram::ALL { - let account = fake_account(program.address()); - assert_eq!(validate_token_program(&account), Ok(program)); - } - } - - #[test] - fn validate_token_program_rejects_unrelated_program() { - let account = fake_account(pubkey_from_seed("not a token program")); + fn owning_token_program_rejects_an_unallocated_account() { + let account = fake_account(pubkey_from_seed("never allocated")); assert_eq!( - validate_token_program(&account), + owning_token_program(&account), Err(ProgramError::IncorrectProgramId), ); } diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index bf7ffaa7..e526ef02 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -12,8 +12,7 @@ //! fully-working settlement, so every test here builds one with it and either //! sends it unmodified (when the rejection is already baked into the orders or //! accounts passed in) or mutates its `BeginSettle` instruction in place -//! afterwards (a wrong account, a wrong token program, a wrong state PDA, an -//! extra account). A few tests are the exception and build the raw instruction +//! afterwards (a wrong account, a wrong state PDA, an extra account). A few tests are the exception and build the raw instruction //! directly, because what they exercise can't come out of the client builder, //! whose output is a properly built instruction. @@ -30,7 +29,7 @@ use cow_settlement_client::cow_settlement_interface::{ data::order::{EncodedOrderAccount, OrderAccount}, instruction::settle::{ BeginSettle as BeginSettleRaw, FinalizeSettle as FinalizeSettleRaw, - FINALIZE_FIXED_ACCOUNTS, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, + FINALIZE_FIXED_ACCOUNTS, INSTRUCTIONS_SYSVAR_ID, }, pda::{buffer::find_buffer_pda, order::find_order_pda, state::find_state_pda}, Instruction, SettlementError, SettlementInstruction, @@ -937,36 +936,6 @@ fn rejects_wrong_state_pda() { ); } -#[test] -fn rejects_wrong_token_program() { - let (mut svm, program_id, payer, solver) = setup_settle_ready(); - - let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); - let mut instructions = settle_and_pay( - &mut svm, - &program_id, - &payer, - &solver, - &[InitializedIntent { - intent: &intent, - pulls: &[], - }], - ); - - // Swap the SPL Token program account `BeginSettle` references for a bogus - // one. - replace_first_matching_account( - &mut instructions[usize::from(BEGIN_INDEX)], - &SPL_TOKEN_PROGRAM_ID, - unique_pubkey(), - ); - - assert_instruction_error( - send(&mut svm, &solver, instructions), - InstructionError::IncorrectProgramId, - ); -} - #[test] fn rejects_pull_delegated_to_incorrect_address() { let (mut svm, program_id, payer, solver) = setup_settle_ready(); diff --git a/programs/settlement/tests/common/token.rs b/programs/settlement/tests/common/token.rs index 46aca1ce..0d3b3a6d 100644 --- a/programs/settlement/tests/common/token.rs +++ b/programs/settlement/tests/common/token.rs @@ -11,6 +11,7 @@ use litesvm::{types::TransactionMetadata, LiteSVM}; use litesvm_token::{ spl_token::{ instruction::{approve, initialize_account3, initialize_mint2, mint_to as mint_to_ix}, + native_mint, state::{Account, Mint}, }, CreateAssociatedTokenAccount, Transfer, TOKEN_ID, @@ -68,6 +69,23 @@ fn send_token_tx( .unwrap_or_else(|error| panic!("{what} should succeed: {error:?}")); } +/// Plant the native mint (wrapped SOL) at its well-known address, owned by the +/// legacy SPL Token program. +/// +/// Every cluster carries this mint already; LiteSVM starts without it, so a +/// test that works with wrapped SOL has to put it there. Its body is what a +/// real one holds: no authorities, no supply, and the native decimals. +pub fn create_native_mint(svm: &mut LiteSVM) { + let mut data = vec![0u8; Mint::LEN]; + Mint { + decimals: native_mint::DECIMALS, + is_initialized: true, + ..Default::default() + } + .pack_into_slice(&mut data); + super::create_account_at(svm, native_mint::ID, &TOKEN_ID, &data); +} + /// Create a fresh mint under the legacy SPL Token program, owned by `payer`, /// and return its address. pub fn create_mint(svm: &mut LiteSVM, payer: &Keypair) -> Pubkey { diff --git a/programs/settlement/tests/create_buffer.rs b/programs/settlement/tests/create_buffer.rs index 919a6d8c..6a0a72da 100644 --- a/programs/settlement/tests/create_buffer.rs +++ b/programs/settlement/tests/create_buffer.rs @@ -147,6 +147,9 @@ fn happy_path_creates_native_token_buffer() { // and the buffer is initialized as a wrapped-SOL account. Since we fund // exactly the rent-exempt minimum, the wrapped balance starts at zero. let (mut svm, program_id, payer) = common::setup(); + // The buffer is created under the program that owns its mint, so the native + // mint has to be on-chain here the way it is on a real cluster. + common::token::create_native_mint(&mut svm); let (buffer_pda, _bump) = find_buffer_pda(&program_id, &native_mint::ID); let ix = CreateBuffers { @@ -291,8 +294,12 @@ fn rejects_non_canonical_bump_pda() { common::pda::assert_rejected_as_noncanonical(&mut svm, tx, &non_canonical_pda); } +/// The token-program account isn't read: each buffer is created under the +/// program that owns its mint. What the account is for is naming that program, +/// and a CPI can only dispatch to a program its instruction names — so swapping +/// it out leaves `InitializeAccount3` with nowhere to go. #[test] -fn rejects_non_spl_token_program() { +fn rejects_a_token_program_the_instruction_doesnt_name() { let (mut svm, program_id, payer) = common::setup(); let mint = common::token::create_mint(&mut svm, &payer); let (buffer_pda, _bump) = find_buffer_pda(&program_id, &mint); @@ -316,13 +323,13 @@ fn rejects_non_spl_token_program() { let err = svm .send_transaction(tx) - .expect_err("a non-SPL-Token program must be rejected"); + .expect_err("a buffer whose token program isn't named must be rejected"); assert!( matches!( err.err, - TransactionError::InstructionError(0, InstructionError::IncorrectProgramId) + TransactionError::InstructionError(0, InstructionError::MissingAccount) ), - "expected instruction 0 to fail with IncorrectProgramId, got {:?}", + "expected instruction 0 to fail with MissingAccount, got {:?}", err.err, ); assert!( @@ -335,11 +342,10 @@ fn rejects_non_spl_token_program() { fn rejects_invalid_mint() { let (mut svm, program_id, payer) = common::setup(); - // An account that isn't an initialized SPL mint. The handler derives the - // buffer PDA from it and delegates mint validation to InitializeAccount3, - // which rejects it: a non-mint account isn't owned by the token program, so - // the CPI fails with IncorrectProgramId after the buffer was allocated, - // reverting the whole instruction. + // An account that isn't an initialized SPL mint. The handler reads the + // mint's owner to decide which program the buffer belongs to, and an + // account under no token program has no answer: it is rejected with + // IncorrectProgramId before anything is allocated. let not_a_mint = unique_pubkey(); let (buffer_pda, _bump) = find_buffer_pda(&program_id, ¬_a_mint); @@ -354,8 +360,6 @@ fn rejects_invalid_mint() { let err = svm .send_transaction(tx) .expect_err("a non-mint account must be rejected"); - // Expected failing line: - // https://github.com/solana-program/token/blob/7ed1aa8d9eb6d54c0084a9e8475c56a0a868b5bd/program/src/processor.rs#L115 assert!( matches!( err.err, diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs index 6393b9ef..e23acaed 100644 --- a/programs/settlement/tests/finalize_settle_pushes.rs +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -196,8 +196,11 @@ fn rejects_buy_token_account_recreated_for_another_mint() { ); } +/// The token-program account isn't read: every push is issued against the +/// program that owns its destination. The account is what names that program to +/// the runtime, and a CPI can only dispatch to a program its instruction names. #[test] -fn rejects_wrong_token_program() { +fn rejects_a_token_program_the_instruction_doesnt_name() { let (mut svm, program_id, payer, solver) = setup_settle_ready(); let intent = OrderBuilder::new(&mut svm, &program_id, &payer).build(); let orders = [FinalizedIntent { @@ -214,7 +217,7 @@ fn rejects_wrong_token_program() { assert_finalize_error( send(&mut svm, &solver, instructions), - InstructionError::IncorrectProgramId, + InstructionError::MissingAccount, ); } diff --git a/programs/settlement/tests/reclaim_buffer.rs b/programs/settlement/tests/reclaim_buffer.rs index 5050f602..94fc164e 100644 --- a/programs/settlement/tests/reclaim_buffer.rs +++ b/programs/settlement/tests/reclaim_buffer.rs @@ -248,6 +248,9 @@ fn reclaims_multiple_buffers_skipping_funded() { ); } +/// The first pass closes the buffer, which hands it back to the system program. +/// The second pass then finds an account no token program owns and refuses to +/// close it. #[test] fn rejects_the_same_buffer_twice_in_one_instruction() { let ( @@ -274,7 +277,7 @@ fn rejects_the_same_buffer_twice_in_one_instruction() { let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); assert_instruction_error( svm.send_transaction(tx).map_err(|e| e.err), - InstructionError::InvalidAccountData, + InstructionError::IncorrectProgramId, ); } diff --git a/programs/settlement/tests/settle_token_programs.rs b/programs/settlement/tests/settle_token_programs.rs index 33202eaf..df2f74bc 100644 --- a/programs/settlement/tests/settle_token_programs.rs +++ b/programs/settlement/tests/settle_token_programs.rs @@ -3,19 +3,21 @@ //! //! Both instructions take one account per supported token program and issue //! each transfer against the program that owns the account it moves, so a -//! single pair can settle legacy SPL Token and Token-2022 orders together. A -//! program the settlement doesn't need is left out by putting the system -//! program in its slot; a token account under a left-out program then has -//! nothing to be settled against. +//! single pair can settle legacy SPL Token and Token-2022 orders together. The +//! slots are never read: all they do is name those programs, and a CPI can only +//! dispatch to a program its instruction names. A program the settlement +//! doesn't need is left out by putting the system program in its slot; a +//! transfer of a token account under a left-out program then has nothing to +//! dispatch to, and the runtime refuses it. use crate::common::{ - assert_settlement_error, buffer, + buffer, order::OrderBuilder, settlement::{BEGIN_INDEX, FINALIZE_INDEX}, setup_settle_ready, token, unique_pubkey, }; use cow_settlement_client::cow_settlement_interface::{ - data::intent::OrderIntent, token_program::TokenProgram, Instruction, SettlementError, + data::intent::OrderIntent, token_program::TokenProgram, Instruction, }; use cow_settlement_client::instructions::{ BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, TokenPrograms, @@ -258,8 +260,11 @@ fn settles_token_2022_orders_without_carrying_the_legacy_program() { assert_eq!(token::balance(&svm, &intent.buy_token_account), 300); } -/// `BeginSettle` pulls from the sell account, so leaving that account's program -/// out is what it refuses — by name, rather than as a malformed account. +/// `BeginSettle` pulls from the sell account against the program that owns it, +/// so leaving that program out of the settlement leaves the pull's CPI with +/// nothing to dispatch to. The runtime is what refuses it: the program was +/// never told which programs the settlement carries, only which one owns the +/// account in front of it. #[test] fn rejects_a_sell_account_under_a_left_out_program() { let (mut svm, program_id, payer, solver) = setup_settle_ready(); @@ -273,8 +278,7 @@ fn rejects_a_sell_account_under_a_left_out_program() { &TokenProgram::SplToken.address(), ); - assert_settlement_error( - BEGIN_INDEX, + assert_eq!( settle_with( &mut svm, &program_id, @@ -288,13 +292,17 @@ fn rejects_a_sell_account_under_a_left_out_program() { TokenPrograms::SPL_TOKEN, TokenPrograms::SPL_TOKEN, ), - SettlementError::TokenProgramNotProvided, + Err(TransactionError::InstructionError( + BEGIN_INDEX, + InstructionError::MissingAccount, + )), ); } -/// `FinalizeSettle` pushes into the buy account, so it is the one that refuses -/// a settlement whose slots leave that account's program out. `BeginSettle` -/// runs first and passes: it only pulls, and this order's sell side is legacy. +/// `FinalizeSettle` pushes into the buy account, so it is the instruction whose +/// CPI has nothing to dispatch to when that account's program is left out. +/// `BeginSettle` runs first and passes: it only pulls, and this order's sell +/// side is legacy. #[test] fn rejects_a_buy_account_under_a_left_out_program() { let (mut svm, program_id, payer, solver) = setup_settle_ready(); @@ -308,8 +316,7 @@ fn rejects_a_buy_account_under_a_left_out_program() { &TokenProgram::Token2022.address(), ); - assert_settlement_error( - FINALIZE_INDEX, + assert_eq!( settle_with( &mut svm, &program_id, @@ -323,15 +330,18 @@ fn rejects_a_buy_account_under_a_left_out_program() { TokenPrograms::BOTH, TokenPrograms::SPL_TOKEN, ), - SettlementError::TokenProgramNotProvided, + Err(TransactionError::InstructionError( + FINALIZE_INDEX, + InstructionError::MissingAccount, + )), ); } -/// The slots are positional. Handing each one the other's program isn't a way -/// to carry both: each slot takes its own program or the placeholder, nothing -/// else. +/// The slots aren't positional: nothing reads them, so a settlement naming both +/// programs settles either way round. All the slots decide is which programs +/// the instruction names. #[test] -fn rejects_swapped_token_program_slots() { +fn settles_with_the_token_program_slots_swapped() { let (mut svm, program_id, payer, solver) = setup_settle_ready(); let intent = order_across( @@ -389,14 +399,10 @@ fn rejects_swapped_token_program_slots() { &[&payer, &solver], svm.latest_blockhash(), ); - let error = svm - .send_transaction(tx) - .expect_err("swapped slots should be rejected") - .err; - assert_eq!( - error, - TransactionError::InstructionError(BEGIN_INDEX, InstructionError::IncorrectProgramId), - ); + svm.send_transaction(tx) + .expect("the slots only name the programs, in either order"); + + assert_eq!(token::balance(&svm, &intent.buy_token_account), 100); } /// Every settlement in the rest of the suite leaves Token-2022's slot empty, so