From d4c19acd16f3f5ac97794bb951d78aec64ecc009 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:10:57 +0900 Subject: [PATCH 1/2] Support Token-2022 in `BeginSettle` and `FinalizeSettle` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BeginSettle` and `FinalizeSettle` each took a `token_program` account and rejected anything that wasn't the legacy SPL Token program, so the buffers `CreateBuffer` can now open under Token-2022 had no way to be settled. They accept it too, and issue every transfer against whichever of the two they were handed, through the same `token::validate_token_program` gate the buffer instructions use. Token-2022 encodes `Transfer` exactly as the legacy program does, so only the CPI target changes. What differs is the account data: a Token-2022 account carrying extensions is longer than the base layout, and the legacy reader insists on an exact length. Both sides now read through `token::read_token_account`, which dispatches on the validated program — the sell account's owner in `BeginSettle`, the destination's mint in `FinalizeSettle`. That reader grows the `mint` and `owner` fields the settlement needs and the buffer instructions didn't, which costs `ReclaimBuffer` a little: its `max_buffers_in_one_instruction` goes 137,046 -> 138,392 CU for the wider read. The settle benchmarks rise 0.3-1.6% from the added dispatch. The `token_program` account is shared by the whole instruction, so every token one settlement touches must live under the same program; a mixed settlement still needs two instruction pairs. Co-Authored-By: Claude Opus 5 (1M context) --- programs/settlement/src/settle/begin.rs | 47 +++++++++++++--------- programs/settlement/src/settle/finalize.rs | 16 ++++++-- programs/settlement/src/token.rs | 25 +++++++++--- 3 files changed, 59 insertions(+), 29 deletions(-) diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index 19d6927..416e9b4 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -15,7 +15,9 @@ use cow_settlement_interface::{ InstructionInputParsing, }, pda::buffer::validate_buffer_pda, - recover_discriminator, SettlementError, SettlementInstruction, + recover_discriminator, + token_program::TokenProgram, + SettlementError, SettlementInstruction, }; use pinocchio::{ cpi::Signer, @@ -27,11 +29,11 @@ use pinocchio::{ }, AccountView, Address, ProgramResult, }; -use pinocchio_token::{instructions::Transfer, state::Account as TokenAccount}; +use pinocchio_token::instructions::Transfer; use crate::{ processor::{check_state_pda, is_cpi_call, require_solver, with_state_pda_signer_from_bump}, - token::validate_token_program, + token::{read_token_account, validate_token_program}, }; use super::validate_counterpart; @@ -76,7 +78,7 @@ pub fn process_begin_settle( let finalize_ix = instructions.load_instruction_at(usize::from(input.finalize_ix_index))?; - validate_token_program(input.token_program_account)?; + let token_program = validate_token_program(input.token_program_account)?; with_state_pda_signer_from_bump(state_bump, |signer| { settle_orders( @@ -85,6 +87,7 @@ pub fn process_begin_settle( signer, &input.orders, &finalize_ix, + token_program, ) }) } @@ -205,6 +208,7 @@ fn settle_orders( state_pda_signer: &Signer, orders: &SettledOrders<'_, AccountView>, finalize_ix: &IntrospectedInstruction, + token_program: TokenProgram, ) -> ProgramResult { // Orders must be passed strictly increasing by address; this rejects // duplicates (settling the same order twice) without a separate scan. @@ -235,6 +239,7 @@ fn settle_orders( now, state_pda_account, state_pda_signer, + token_program, )?; } @@ -258,6 +263,7 @@ fn process_order( now: i64, state_account: &AccountView, state_pda_signer: &Signer, + token_program: TokenProgram, ) -> ProgramResult { let SettledOrder { order_pda, @@ -297,21 +303,19 @@ fn process_order( } // Assert the order intent owner and sell mint match those of the sell token // account. - { - // `from_account_view` confirms this is a real SPL token account - // (right length, owned by the token program) before we read its - // owner and mint. The borrow it holds is released at the end of this - // block, before the transfers below touch the same account. - let token_account = TokenAccount::from_account_view(sell_token_account) - .map_err(|_| SettlementError::SellTokenAccountInvalid)?; - if token_account.owner() != &intent.owner { - return Err(SettlementError::SellTokenOwnerMismatch.into()); - } - // Like the buy side, the account could have been recreated for another - // mint after the order was created. - if token_account.mint() != &intent.sell_mint { - return Err(SettlementError::SellMintMismatch.into()); - } + // `read_token_account` confirms this is a real token account of the + // instruction's token program before we read its owner and mint, and reads + // by value, so nothing is left borrowing the account when the transfers + // below touch it. + let sell_token = read_token_account(token_program, sell_token_account) + .map_err(|_| SettlementError::SellTokenAccountInvalid)?; + if sell_token.owner != intent.owner { + return Err(SettlementError::SellTokenOwnerMismatch.into()); + } + // Like the buy side, the account could have been recreated for another + // mint after the order was created. + if sell_token.mint != intent.sell_mint { + return Err(SettlementError::SellMintMismatch.into()); } // Pull the configured amounts out of the sell token account, summing them @@ -324,7 +328,10 @@ fn process_order( .checked_add(amount) .ok_or(SettlementError::PullAmountOverflow)?; Transfer::new(sell_token_account, destination, state_account, amount) - .invoke_signed(core::slice::from_ref(state_pda_signer))?; + .invoke_signed_with_unverified_program( + core::slice::from_ref(state_pda_signer), + &token_program.address(), + )?; } validate_limit_price(intent, amount_in, push.amount)?; diff --git a/programs/settlement/src/settle/finalize.rs b/programs/settlement/src/settle/finalize.rs index ea20b01..3bd2564 100644 --- a/programs/settlement/src/settle/finalize.rs +++ b/programs/settlement/src/settle/finalize.rs @@ -5,6 +5,7 @@ use cow_settlement_interface::{ settle::{FinalizeSettleInput, Pushes}, InstructionInputParsing, }, + token_program::TokenProgram, SettlementError, SettlementInstruction, }; use pinocchio::{ @@ -47,10 +48,15 @@ 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. - validate_token_program(input.token_program_account)?; + let token_program = validate_token_program(input.token_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) + push_funds( + input.state_pda_account, + state_pda_signer, + input.pushes, + token_program, + ) }) } @@ -69,6 +75,7 @@ fn push_funds<'a>( state_pda_account: &AccountView, state_pda_signer: &Signer, pushes: Pushes<'a, AccountView>, + token_program: TokenProgram, ) -> ProgramResult { for push in pushes.iter() { Transfer::new( @@ -77,7 +84,10 @@ fn push_funds<'a>( state_pda_account, push.amount, ) - .invoke_signed(core::slice::from_ref(state_pda_signer))?; + .invoke_signed_with_unverified_program( + core::slice::from_ref(state_pda_signer), + &token_program.address(), + )?; } Ok(()) diff --git a/programs/settlement/src/token.rs b/programs/settlement/src/token.rs index efe82e0..25148d2 100644 --- a/programs/settlement/src/token.rs +++ b/programs/settlement/src/token.rs @@ -1,7 +1,7 @@ //! Token-program validation and token-account reads use cow_settlement_interface::{token_program::TokenProgram, SettlementError}; -use pinocchio::{cpi::get_return_data, error::ProgramError, AccountView}; +use pinocchio::{cpi::get_return_data, error::ProgramError, AccountView, Address}; use pinocchio_token::instructions::GetAccountDataSize; /// The length of a SPL token program account. Token2022 extensions may make @@ -51,6 +51,8 @@ pub fn token_account_len( /// [`read_token_account`]. /// For our purposes, we only need the `amount`. pub struct TokenAccount { + pub mint: Address, + pub owner: Address, pub amount: u64, } @@ -60,15 +62,24 @@ pub fn read_token_account( token_program: TokenProgram, account: &AccountView, ) -> Result { - let amount = match token_program { + Ok(match token_program { TokenProgram::SplToken => { - pinocchio_token::state::Account::from_account_view(account)?.amount() + let decoded = pinocchio_token::state::Account::from_account_view(account)?; + TokenAccount { + amount: decoded.amount(), + mint: *decoded.mint(), + owner: *decoded.owner(), + } } TokenProgram::Token2022 => { - pinocchio_token_2022::state::Account::from_account_view(account)?.amount() + let decoded = pinocchio_token_2022::state::Account::from_account_view(account)?; + TokenAccount { + amount: decoded.amount(), + mint: *decoded.mint(), + owner: *decoded.owner(), + } } - }; - Ok(TokenAccount { amount }) + }) } #[cfg(test)] @@ -235,6 +246,8 @@ mod tests { ); let read = read_token_account(TokenProgram::Token2022, &account) .expect("an extended Token-2022 account should read"); + assert_eq!(read.mint, mint); + assert_eq!(read.owner, owner); assert_eq!(read.amount, 7); } From ac413194fc4b7f9f774c3d748c0eb6451bb2d4bc Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:03:33 +0900 Subject: [PATCH 2/2] Carry both token programs in a settlement pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BeginSettle` and `FinalizeSettle` took one `token_program` account, so every token an instruction touched had to live under the same program: settling a legacy SPL mint and a Token-2022 mint meant two instruction pairs. They now take one account per supported program, at fixed positions after the state PDA, and issue each transfer against the program that owns the account it moves. One pair can settle both, and the two sides of a single order need not agree — the pull follows the sell account's owner, the push the buy account's. A program the settlement doesn't touch is left out by putting the system program in its slot. The transfers still need their program named by the transaction, so the placeholder is how an instruction says this one isn't; in a transaction that already references the system program it costs an account index instead of another 32-byte address. Nothing new goes into the instruction data: the owner is the authority on which program an account belongs to, and the slots only decide whether the settlement can reach it. Resolving an account gives one of three answers: - owned by a carried program: its transfers CPI into that program; - owned by a supported program whose slot holds the placeholder: the new `SettlementError::TokenProgramNotProvided` (36), so a forgotten slot reads as itself rather than as a malformed account; - owned by neither: the existing `SellTokenAccountInvalid` / `InvalidBuyTokenAccount`, unchanged. The slots are positional. Each holds its own program or the placeholder; anything else, swapping the two included, is `IncorrectProgramId`. `FINALIZE_FIXED_ACCOUNTS` becomes 4, which `push_destinations` follows on its own. `CreateBuffer` and `ReclaimBuffer` keep their single `token_program` account: each works on one mint at a time, so there is nothing to mix. The settle benchmarks each gain one account, 34 transaction bytes, and 50-170 CU. The one- and two-unit drift on the unrelated create/reclaim lines is codegen, not behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- client/src/instructions.rs | 24 +- client/src/parse.rs | 2 + interface/src/instruction/settle/begin.rs | 120 +++-- interface/src/instruction/settle/finalize.rs | 109 ++++- interface/src/instruction/settle/mod.rs | 2 + interface/src/lib.rs | 6 + interface/src/token_program.rs | 149 +++++- programs/settlement/src/settle/begin.rs | 37 +- programs/settlement/src/settle/finalize.rs | 19 +- programs/settlement/src/token.rs | 117 +++++ .../settlement/tests/begin_settle_orders.rs | 36 +- programs/settlement/tests/common/buffer.rs | 10 +- .../settlement/tests/common/settlement.rs | 5 +- programs/settlement/tests/common/token.rs | 136 +++++- .../tests/finalize_settle_pushes.rs | 22 +- .../tests/matching_begin_finalize.rs | 12 +- .../settlement/tests/program_deployment.rs | 4 +- .../settlement/tests/settle_token_programs.rs | 429 ++++++++++++++++++ test-cli/src/cmd/settle.rs | 5 + 19 files changed, 1124 insertions(+), 120 deletions(-) create mode 100644 programs/settlement/tests/settle_token_programs.rs diff --git a/client/src/instructions.rs b/client/src/instructions.rs index eaec239..c21339d 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -14,7 +14,7 @@ use cow_settlement_interface::{ // Reexport the instruction builders that don't change from the interface. // We want the client to provide all instruction builders. -pub use cow_settlement_interface::instruction::settle::Pull; +pub use cow_settlement_interface::instruction::settle::{Pull, TokenPrograms}; /// An order ready to be settled, together with the funds to pull from it: /// `intent` identifies the order and `pulls` lists the [`Pull`]s to make from @@ -32,6 +32,10 @@ pub struct BeginSettle<'a> { /// The off-chain auction this settlement executes, carried so it can be tied /// back to its auction off-chain. pub auction_id: i64, + /// The token programs owning the accounts this settlement pulls from and + /// pays into. Leaving one out makes its accounts unsettleable here, so this + /// has to cover every one of them. + pub token_programs: TokenPrograms, pub orders: &'a [InitializedIntent<'a>], } @@ -53,6 +57,7 @@ impl From> for Instruction { solver: builder.solver, finalize_ix_index: builder.finalize_ix_index, auction_id: builder.auction_id, + token_programs: builder.token_programs, order_pdas: &order_pdas, sell_token_accounts: &sell_token_accounts, pulls: &pull_lists, @@ -81,6 +86,9 @@ pub struct FinalizedIntent<'a> { pub struct FinalizeSettle<'a> { pub program_id: Pubkey, pub begin_ix_index: u16, + /// The token programs owning the buffers and buy token accounts this + /// settlement pushes between, filled the same way as [`BeginSettle`]'s. + pub token_programs: TokenPrograms, pub orders: &'a [FinalizedIntent<'a>], } @@ -114,6 +122,7 @@ impl From> for Instruction { program_id: builder.program_id, state_pda, begin_ix_index: builder.begin_ix_index, + token_programs: builder.token_programs, source_buffers: &source_buffers, destinations: &destinations, bumps: &bumps, @@ -309,7 +318,8 @@ mod tests { instruction::{ fixtures::fake_account_from_array, settle::{ - BeginSettleInput, FinalizeSettleInput, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, + BeginSettleInput, FinalizeSettleInput, INSTRUCTIONS_SYSVAR_ID, + SPL_TOKEN_PROGRAM_ID, SYSTEM_PROGRAM_ID, }, InstructionInputParsing, }, @@ -337,6 +347,7 @@ mod tests { solver: pubkey_from_seed("solver"), finalize_ix_index, auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &orders, }); @@ -401,6 +412,7 @@ mod tests { let ix = Instruction::from(FinalizeSettle { program_id, begin_ix_index, + token_programs: TokenPrograms::SPL_TOKEN, orders: &orders, }); @@ -446,9 +458,15 @@ 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.token_program_account.address(), + 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, + ); let parsed_pushes: Vec<_> = parsed.pushes.iter().collect(); prop_assert_eq!(parsed_pushes.len(), expected.len()); diff --git a/client/src/parse.rs b/client/src/parse.rs index 6d5eeaa..6f7d7e4 100644 --- a/client/src/parse.rs +++ b/client/src/parse.rs @@ -125,6 +125,7 @@ mod tests { solver: payer, finalize_ix_index: 1, auction_id: 42, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[InitializedIntent { intent: &intent, pulls: &[], @@ -134,6 +135,7 @@ mod tests { SettlementInstruction::FinalizeSettle => FinalizeSettle { program_id, begin_ix_index: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], } .into(), diff --git a/interface/src/instruction/settle/begin.rs b/interface/src/instruction/settle/begin.rs index a4284f8..76ce838 100644 --- a/interface/src/instruction/settle/begin.rs +++ b/interface/src/instruction/settle/begin.rs @@ -9,7 +9,7 @@ use solana_pubkey::Pubkey; use crate::instruction::InstructionInputParsing; use crate::{SettlementError, SettlementInstruction}; -use super::{recover_counterpart, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID}; +use super::{recover_counterpart, TokenPrograms, INSTRUCTIONS_SYSVAR_ID}; /// A single transfer made when settling an order: `amount` tokens sent from the /// order's sell token account to `destination`. @@ -34,8 +34,11 @@ pub struct Pull { /// `[discriminator=0][finalize_ix_index: u16 LE][auction_id: i64 LE][n: u8] /// [transfer_count×n][amount: u64 LE ×T]`. /// Required accounts: `[solver (S,R), instructions_sysvar (R), state_pda (R), -/// token_program (R)]` followed, per order, by `[order_pda (W), -/// sell_token_account (W), destination (W)...]`. +/// 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. /// /// `solver` must sign, and the solver must be registered in the state pda. /// @@ -52,6 +55,9 @@ pub struct BeginSettle<'a> { /// instruction data so the settlement can be tied back to its auction /// off-chain, unused on-chain. pub auction_id: i64, + /// The token programs this settlement carries, one slot each; see + /// [`TokenPrograms`]. + pub token_programs: TokenPrograms, pub order_pdas: &'a [Pubkey], pub sell_token_accounts: &'a [Pubkey], pub pulls: &'a [&'a [Pull]], @@ -65,6 +71,7 @@ impl From> for Instruction { solver, finalize_ix_index, auction_id, + token_programs, order_pdas, sell_token_accounts, pulls, @@ -93,13 +100,18 @@ impl From> for Instruction { .concat(); // The signing solver, followed by read-only accounts for instruction - // introspection, settlement state, and the SPL token program. + // introspection, settlement state, and one slot per supported token + // program. let mut accounts = vec![ AccountMeta::new_readonly(solver, true), AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false), AccountMeta::new_readonly(state_pda, false), - AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), ]; + accounts.extend( + token_programs + .addresses() + .map(|address| AccountMeta::new_readonly(address, false)), + ); for &i in &order { // Writable account for the order: `BeginSettle` updates its filled // amounts (`amount_withdrawn`/`amount_received`). @@ -202,7 +214,11 @@ pub struct BeginSettleInput<'a, A> { pub solver_account: &'a A, pub instructions_sysvar_account: &'a A, pub state_pda_account: &'a A, - pub token_program_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>, } @@ -215,7 +231,7 @@ 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, token_program_account, order_accounts @ ..] = + 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); @@ -269,7 +285,8 @@ impl<'a, A> InstructionInputParsing<'a, A> for BeginSettleInput<'a, A> { auction_id, instructions_sysvar_account, state_pda_account, - token_program_account, + spl_token_program_account, + token_2022_program_account, solver_account, orders: SettledOrders { order_accounts, @@ -288,15 +305,17 @@ mod tests { fake_account, fake_account_from_array, fake_sequential_accounts, }; use crate::instruction::settle::tests::ix_data; + use crate::instruction::settle::SPL_TOKEN_PROGRAM_ID; use crate::instruction::tests::{assert_readonly_nonsigner, assert_readonly_signer}; + use crate::token_program::{TokenProgram, SYSTEM_PROGRAM_ID}; use hex_literal::hex; use solana_account_view::AccountView; use solana_address::Address; /// The fixed accounts every `BeginSettle` carries before its order accounts: /// the signing solver, the instructions sysvar, the settlement state PDA, and - /// the token program. - const FIXED_ACCOUNTS: usize = 4; + /// one slot per supported token program. + const FIXED_ACCOUNTS: usize = 5; /// A placeholder auction id for the tests where its specific value is /// incidental. The wire-layout tests spell out the literal bytes instead. @@ -317,6 +336,7 @@ mod tests { solver, finalize_ix_index: 0x1337, auction_id: 0x0102_0304_0506_0708, + token_programs: TokenPrograms::SPL_TOKEN, order_pdas: &[], sell_token_accounts: &[], pulls: &[], @@ -332,14 +352,47 @@ mod tests { [0], // order count ], ); - // No orders: the four fixed accounts (solver, sysvar, state PDA, token - // program). Only the solver signs; the rest don't play an active role in - // the base instruction (the state PDA CPI signature isn't relevant here). - assert_eq!(accounts.len(), 4); + // No orders: the fixed accounts (solver, sysvar, state PDA, and a slot + // per token program). Only the solver signs; the rest don't play an + // active role in the base instruction (the state PDA CPI signature isn't + // relevant here). This settlement carries only the legacy program, so + // Token-2022's slot holds the placeholder. + assert_eq!(accounts.len(), FIXED_ACCOUNTS); assert_readonly_signer(&accounts[0], solver); assert_readonly_nonsigner(&accounts[1], INSTRUCTIONS_SYSVAR_ID); assert_readonly_nonsigner(&accounts[2], state_pda); assert_readonly_nonsigner(&accounts[3], SPL_TOKEN_PROGRAM_ID); + assert_readonly_nonsigner(&accounts[4], SYSTEM_PROGRAM_ID); + } + + /// The token-program slots are whatever [`TokenPrograms`] says, in its own + /// order, so a settlement can carry both programs — or leave either one out. + #[test] + fn begin_settle_carries_the_token_program_slots_it_is_given() { + for token_programs in [ + TokenPrograms::SPL_TOKEN, + TokenPrograms::TOKEN_2022, + TokenPrograms::BOTH, + TokenPrograms::NONE, + ] { + let Instruction { accounts, .. } = Instruction::from(BeginSettle { + program_id: Pubkey::new_unique(), + state_pda: Pubkey::new_unique(), + solver: Pubkey::new_unique(), + finalize_ix_index: 0, + auction_id: 0, + token_programs, + order_pdas: &[], + sell_token_accounts: &[], + pulls: &[], + }); + let slots: Vec = accounts[3..].iter().map(|meta| meta.pubkey).collect(); + assert_eq!( + slots, + token_programs.addresses(), + "{token_programs:?} should be laid out as its own addresses", + ); + } } #[test] @@ -359,6 +412,7 @@ mod tests { solver, finalize_ix_index: 0x1337, auction_id: AUCTION_ID, + token_programs: TokenPrograms::SPL_TOKEN, order_pdas: &[high_order_pda, low_order_pda], sell_token_accounts: &[high_sell_token_account, low_sell_token_account], pulls: &[&[], &[]], @@ -381,6 +435,7 @@ mod tests { INSTRUCTIONS_SYSVAR_ID, state_pda, SPL_TOKEN_PROGRAM_ID, + SYSTEM_PROGRAM_ID, low_order_pda, low_sell_token_account, high_order_pda, @@ -428,6 +483,7 @@ mod tests { solver, finalize_ix_index: 0x1337, auction_id: AUCTION_ID, + token_programs: TokenPrograms::BOTH, order_pdas: &[order_a, order_b], sell_token_accounts: &[sell_a, sell_b], pulls: &[ @@ -469,6 +525,7 @@ mod tests { INSTRUCTIONS_SYSVAR_ID, state_pda, SPL_TOKEN_PROGRAM_ID, + TokenProgram::Token2022.address(), order_a, sell_a, dest_a0, @@ -498,13 +555,15 @@ mod tests { fn begin_settle_input_parses_valid_input() { let sysvar = pubkey_from_seed("sysvar"); let state = pubkey_from_seed("state pda"); - let token_program = pubkey_from_seed("token program"); + let spl_token_program = pubkey_from_seed("spl token program"); + let token_2022_program = pubkey_from_seed("token 2022 program"); let solver = pubkey_from_seed("solver"); let accounts = [ fake_account(solver), fake_account(sysvar), fake_account(state), - fake_account(token_program), + fake_account(spl_token_program), + fake_account(token_2022_program), ]; let data = ix_data![ [SettlementInstruction::BeginSettle.discriminator()], @@ -518,14 +577,16 @@ mod tests { solver_account, instructions_sysvar_account, orders, - token_program_account, + 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!(token_program_account.address(), &token_program); + 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); } @@ -576,7 +637,8 @@ mod tests { fn begin_settle_input_pairs_orders_with_their_accounts() { let sysvar = pubkey_from_seed("sysvar"); let state = pubkey_from_seed("state pda"); - let token_program = pubkey_from_seed("token program"); + let spl_token_program = pubkey_from_seed("spl token program"); + let token_2022_program = pubkey_from_seed("token 2022 program"); let solver = pubkey_from_seed("solver"); let order_pda = pubkey_from_seed("order pda"); let sell_token = pubkey_from_seed("sell token"); @@ -584,7 +646,8 @@ mod tests { fake_account(solver), fake_account(sysvar), fake_account(state), - fake_account(token_program), + fake_account(spl_token_program), + fake_account(token_2022_program), fake_account(order_pda), fake_account(sell_token), ]; @@ -602,12 +665,14 @@ mod tests { instructions_sysvar_account, orders, state_pda_account, - token_program_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!(token_program_account.address(), &token_program); + 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); @@ -623,7 +688,8 @@ mod tests { fn begin_settle_input_parses_transfers() { let sysvar = pubkey_from_seed("sysvar"); let state = pubkey_from_seed("state pda"); - let token_program = pubkey_from_seed("token program"); + let spl_token_program = pubkey_from_seed("spl token program"); + let token_2022_program = pubkey_from_seed("token 2022 program"); let solver = pubkey_from_seed("solver"); let order_pda = pubkey_from_seed("order pda"); let sell_token = pubkey_from_seed("sell token"); @@ -633,7 +699,8 @@ mod tests { fake_account(solver), fake_account(sysvar), fake_account(state), - fake_account(token_program), + fake_account(spl_token_program), + fake_account(token_2022_program), fake_account(order_pda), fake_account(sell_token), fake_account(dest0), @@ -677,13 +744,14 @@ mod tests { expected.push((order_pda, sell_token)); } - // The four fixed accounts (`[0xff..]`, `[0xfe..]`, `[0xfd..]`, `[0xfc..]`) - // differ from every order/token address above. + // The fixed accounts (`[0xff..]` down to `[0xfb..]`) differ from every + // order/token address above. let mut accounts = vec![ fake_account_from_array([0xff; 32]), fake_account_from_array([0xfe; 32]), fake_account_from_array([0xfd; 32]), fake_account_from_array([0xfc; 32]), + fake_account_from_array([0xfb; 32]), ]; for &(order_pda, sell_token) in &expected { accounts.push(fake_account(order_pda)); diff --git a/interface/src/instruction/settle/finalize.rs b/interface/src/instruction/settle/finalize.rs index b6ed462..58d4ae8 100644 --- a/interface/src/instruction/settle/finalize.rs +++ b/interface/src/instruction/settle/finalize.rs @@ -9,12 +9,12 @@ use solana_pubkey::Pubkey; use crate::instruction::InstructionInputParsing; use crate::{recover_discriminator, SettlementError, SettlementInstruction}; -use super::{recover_counterpart, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID}; +use super::{recover_counterpart, TokenPrograms, INSTRUCTIONS_SYSVAR_ID}; /// The number of fixed accounts every `FinalizeSettle` carries before its push -/// accounts: the instructions sysvar, the settlement state PDA, and the token -/// program. -pub const FINALIZE_FIXED_ACCOUNTS: usize = 3; +/// accounts: the instructions sysvar, the settlement state PDA, and one slot per +/// supported token program. +pub const FINALIZE_FIXED_ACCOUNTS: usize = 4; /// Split the instruction bytes from `FinalizeSettle` that remain after all /// constant-size data has been extracted into the per-push bump bytes and the @@ -80,8 +80,10 @@ pub fn finalize_push_data( /// Wire format (with `n` total pushes): /// `[discriminator=1][begin_ix_index: u16 LE][bump: u8 ×n][amount: u64 LE ×n]`. /// Required accounts: -/// `[instructions_sysvar (R), state_pda (R), token_program (R)]` followed, per -/// push, by `[source_buffer (W), destination (W)]`. +/// `[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. /// /// `FinalizeSettle` only executes the transfers. Every push is validated by /// `BeginSettle`, which reads this instruction through introspection. @@ -89,6 +91,9 @@ pub struct FinalizeSettle<'a> { pub program_id: Pubkey, pub state_pda: Pubkey, pub begin_ix_index: u16, + /// The token programs this settlement carries, one slot each; see + /// [`TokenPrograms`]. + pub token_programs: TokenPrograms, pub source_buffers: &'a [Pubkey], pub destinations: &'a [Pubkey], pub bumps: &'a [u8], @@ -101,6 +106,7 @@ impl From> for Instruction { program_id, state_pda, begin_ix_index, + token_programs, source_buffers, destinations, bumps, @@ -116,8 +122,12 @@ impl From> for Instruction { let mut accounts = vec![ AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false), AccountMeta::new_readonly(state_pda, false), - AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), ]; + accounts.extend( + token_programs + .addresses() + .map(|address| AccountMeta::new_readonly(address, false)), + ); for (source, destination) in source_buffers.iter().zip(destinations) { accounts.push(AccountMeta::new(*source, false)); accounts.push(AccountMeta::new(*destination, false)); @@ -198,7 +208,11 @@ pub struct FinalizeSettleInput<'a, A> { pub begin_ix_index: u16, pub instructions_sysvar_account: &'a A, pub state_pda_account: &'a A, - pub token_program_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>, } @@ -211,7 +225,7 @@ 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, token_program_account, push_accounts @ ..] = + let [instructions_sysvar_account, state_pda_account, spl_token_program_account, token_2022_program_account, push_accounts @ ..] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); @@ -233,7 +247,8 @@ impl<'a, A> InstructionInputParsing<'a, A> for FinalizeSettleInput<'a, A> { begin_ix_index, instructions_sysvar_account, state_pda_account, - token_program_account, + spl_token_program_account, + token_2022_program_account, pushes: Pushes { push_accounts, bumps, @@ -251,7 +266,9 @@ mod tests { fake_account, fake_account_from_array, fake_sequential_accounts, }; use crate::instruction::settle::tests::ix_data; + use crate::instruction::settle::SPL_TOKEN_PROGRAM_ID; use crate::instruction::tests::assert_readonly_nonsigner; + use crate::token_program::{TokenProgram, SYSTEM_PROGRAM_ID}; use hex_literal::hex; use proptest::prelude::*; use solana_account_view::AccountView; @@ -265,6 +282,7 @@ mod tests { program_id: pubkey_from_seed("program id"), state_pda: pubkey_from_seed("state pda"), begin_ix_index: 0, + token_programs: TokenPrograms::SPL_TOKEN, source_buffers: &[], destinations: &[], bumps: &[], @@ -285,6 +303,7 @@ mod tests { program_id, state_pda, begin_ix_index: 0x1337, + token_programs: TokenPrograms::SPL_TOKEN, source_buffers: &[], destinations: &[], bumps: &[], @@ -299,14 +318,45 @@ mod tests { hex!("3713"), // counterpart index (little-endian) ], ); - // No orders: the three fixed accounts (sysvar, state PDA, token - // program). They are all generic accounts that don't play an active - // role in the base instruction (the state PDA CPI signature isn't - // relevant here). - assert_eq!(accounts.len(), 3); + // No orders: the fixed accounts (sysvar, state PDA, and a slot per token + // program). They are all generic accounts that don't play an active role + // in the base instruction (the state PDA CPI signature isn't relevant + // here). This settlement carries only the legacy program, so Token-2022's + // slot holds the placeholder. + assert_eq!(accounts.len(), FINALIZE_FIXED_ACCOUNTS); assert_readonly_nonsigner(&accounts[0], INSTRUCTIONS_SYSVAR_ID); assert_readonly_nonsigner(&accounts[1], state_pda); assert_readonly_nonsigner(&accounts[2], SPL_TOKEN_PROGRAM_ID); + assert_readonly_nonsigner(&accounts[3], SYSTEM_PROGRAM_ID); + } + + /// The token-program slots are whatever [`TokenPrograms`] says, in its own + /// order, so a settlement can carry both programs — or leave either one out. + #[test] + fn finalize_settle_carries_the_token_program_slots_it_is_given() { + for token_programs in [ + TokenPrograms::SPL_TOKEN, + TokenPrograms::TOKEN_2022, + TokenPrograms::BOTH, + TokenPrograms::NONE, + ] { + let ix = Instruction::from(FinalizeSettle { + program_id: Pubkey::new_unique(), + state_pda: Pubkey::new_unique(), + begin_ix_index: 0, + token_programs, + source_buffers: &[], + destinations: &[], + bumps: &[], + amounts: &[], + }); + let slots: Vec = ix.accounts[2..].iter().map(|meta| meta.pubkey).collect(); + assert_eq!( + slots, + token_programs.addresses(), + "{token_programs:?} should be laid out as its own addresses", + ); + } } #[test] @@ -322,6 +372,7 @@ mod tests { program_id, state_pda, begin_ix_index: 0x1337, + token_programs: TokenPrograms::BOTH, source_buffers: &[source_a, source_b], destinations: &[dest_a, dest_b], bumps: &[0xa1, 0xb1], @@ -347,6 +398,7 @@ mod tests { INSTRUCTIONS_SYSVAR_ID, state_pda, SPL_TOKEN_PROGRAM_ID, + TokenProgram::Token2022.address(), source_a, dest_a, source_b, @@ -369,11 +421,13 @@ mod tests { fn finalize_settle_input_parses_no_pushes() { let sysvar = pubkey_from_seed("sysvar"); let state = pubkey_from_seed("state pda"); - let token_program = pubkey_from_seed("token program"); + let spl_token_program = pubkey_from_seed("spl token program"); + let token_2022_program = pubkey_from_seed("token 2022 program"); let accounts = [ fake_account(sysvar), fake_account(state), - fake_account(token_program), + fake_account(spl_token_program), + fake_account(token_2022_program), ]; let data = ix_data![ [SettlementInstruction::FinalizeSettle.discriminator()], @@ -383,13 +437,15 @@ mod tests { begin_ix_index, instructions_sysvar_account, state_pda_account, - token_program_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!(token_program_account.address(), &token_program); + 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); } @@ -397,7 +453,8 @@ mod tests { fn finalize_settle_input_parses_pushes() { let sysvar = pubkey_from_seed("sysvar"); let state = pubkey_from_seed("state pda"); - let token_program = pubkey_from_seed("token program"); + let spl_token_program = pubkey_from_seed("spl token program"); + let token_2022_program = pubkey_from_seed("token 2022 program"); // The same source buffer funds both pushes: parsing makes no uniqueness // assumption about source buffers. let source = pubkey_from_seed("source buffer"); @@ -406,7 +463,8 @@ mod tests { let accounts = [ fake_account(sysvar), fake_account(state), - fake_account(token_program), + fake_account(spl_token_program), + fake_account(token_2022_program), fake_account(source), fake_account(dest0), fake_account(source), @@ -467,12 +525,13 @@ mod tests { }); } - // The three fixed accounts (`[0xff..]`, `[0xfe..]`, `[0xfd..]`) differ - // from every source/destination address above. + // The fixed accounts (`[0xff..]` down to `[0xfc..]`) differ from every + // source/destination address above. let mut accounts = vec![ fake_account_from_array([0xff; 32]), fake_account_from_array([0xfe; 32]), fake_account_from_array([0xfd; 32]), + fake_account_from_array([0xfc; 32]), ]; let mut bump_bytes = Vec::new(); let mut amount_bytes = Vec::new(); @@ -578,6 +637,7 @@ mod tests { program_id: pubkey_from_seed("program id"), state_pda: pubkey_from_seed("state pda"), begin_ix_index: 0x1337, + token_programs: TokenPrograms::SPL_TOKEN, source_buffers: &[ pubkey_from_seed("source buffer 0"), pubkey_from_seed("source buffer 1"), @@ -601,6 +661,7 @@ mod tests { program_id: pubkey_from_seed("program id"), state_pda: pubkey_from_seed("state pda"), begin_ix_index: 0, + token_programs: TokenPrograms::SPL_TOKEN, source_buffers: &[], destinations: &[], bumps: &[], @@ -616,6 +677,7 @@ mod tests { program_id: pubkey_from_seed("program id"), state_pda: pubkey_from_seed("state pda"), begin_ix_index: 0, + token_programs: TokenPrograms::SPL_TOKEN, source_buffers: &[pubkey_from_seed("source buffer")], destinations: &[pubkey_from_seed("destination")], bumps: &[0xff], @@ -650,6 +712,7 @@ mod tests { program_id, state_pda, begin_ix_index, + token_programs: TokenPrograms::BOTH, source_buffers: &source_buffers, destinations: &destinations, bumps: &bumps, diff --git a/interface/src/instruction/settle/mod.rs b/interface/src/instruction/settle/mod.rs index 8ba0859..5301445 100644 --- a/interface/src/instruction/settle/mod.rs +++ b/interface/src/instruction/settle/mod.rs @@ -6,6 +6,8 @@ use solana_program_error::ProgramError; /// The legacy SPL Token program, which the builders below target by default. pub const SPL_TOKEN_PROGRAM_ID: Pubkey = TokenProgram::SplToken.address(); + +pub use crate::token_program::TokenPrograms; pub use solana_sdk_ids::sysvar::instructions::ID as INSTRUCTIONS_SYSVAR_ID; mod begin; diff --git a/interface/src/lib.rs b/interface/src/lib.rs index efd2cf0..a6a6ad0 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -259,6 +259,12 @@ 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, } impl From for u32 { diff --git a/interface/src/token_program.rs b/interface/src/token_program.rs index c84b298..aadcf94 100644 --- a/interface/src/token_program.rs +++ b/interface/src/token_program.rs @@ -1,8 +1,27 @@ -//! Utilities related to the token programs supported by the settlement program. +//! 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: +//! +//! - `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. +//! - `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 +//! tokens from both programs. use crate::Pubkey; use solana_program_error::ProgramError; +/// The program a [`TokenPrograms`] slot carries when the settlement moves no +/// token under that program. The system program is named by nearly every +/// settlement transaction already, so standing it in costs one more account +/// index rather than another 32-byte address. +pub use solana_system_interface::program::ID as SYSTEM_PROGRAM_ID; + /// A token program a token-moving instruction accepts. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TokenProgram { @@ -13,8 +32,9 @@ pub enum TokenProgram { } impl TokenProgram { - /// Every supported token program, in no particular order. The single list - /// [`TryFrom`] resolves addresses against. + /// Every supported token program. The single list [`TryFrom`] resolves + /// addresses against, and the order `BeginSettle` and `FinalizeSettle` lay + /// their token-program accounts out in; see [`TokenPrograms::addresses`]. pub const ALL: [Self; 2] = [Self::SplToken, Self::Token2022]; /// The address the program is deployed at. @@ -39,6 +59,80 @@ impl TryFrom<&Pubkey> for TokenProgram { } } +/// Which of [`TokenProgram::ALL`] a `BeginSettle`/`FinalizeSettle` pair +/// carries. +/// +/// 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. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct TokenPrograms { + /// Whether the legacy SPL Token program's slot carries the program rather + /// than the placeholder. + pub spl_token: bool, + /// Whether Token-2022's slot carries the program rather than the + /// placeholder. + pub token_2022: bool, +} + +impl TokenPrograms { + /// The legacy SPL Token program alone. + pub const SPL_TOKEN: Self = Self { + spl_token: true, + token_2022: false, + }; + + /// Token-2022 alone. + pub const TOKEN_2022: Self = Self { + spl_token: false, + token_2022: true, + }; + + /// Both programs, for a settlement mixing tokens from each. + pub const BOTH: Self = Self { + spl_token: true, + token_2022: true, + }; + + /// Neither program: every slot is the placeholder. Only a settlement that + /// moves no tokens at all can be built this way. + pub const NONE: Self = Self { + spl_token: false, + token_2022: false, + }; + + /// The addresses to pass, one per entry of [`TokenProgram::ALL`] and in + /// that order: the program itself where the settlement needs it, and + /// [`SYSTEM_PROGRAM_ID`] where it doesn't. + pub const fn addresses(self) -> [Pubkey; TokenProgram::ALL.len()] { + let [spl_token, token_2022] = TokenProgram::ALL; + [self.slot(spl_token), self.slot(token_2022)] + } + + /// The address `program`'s own slot holds. + const fn slot(self, program: TokenProgram) -> Pubkey { + if self.carries(program) { + program.address() + } else { + SYSTEM_PROGRAM_ID + } + } + + /// Whether `program`'s slot carries it rather than the placeholder. The one + /// place a new [`TokenProgram`] variant has to be given a slot. + const fn carries(self, program: TokenProgram) -> bool { + match program { + TokenProgram::SplToken => self.spl_token, + TokenProgram::Token2022 => self.token_2022, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -69,4 +163,53 @@ mod tests { Err(ProgramError::IncorrectProgramId), ); } + + /// The placeholder has to be something no token account can be owned by, + /// or a slot carrying it would still dispatch transfers somewhere. + #[test] + fn the_placeholder_is_not_a_token_program() { + assert_eq!( + TokenProgram::try_from(&SYSTEM_PROGRAM_ID), + Err(ProgramError::IncorrectProgramId), + ); + } + + /// Every combination puts each program in its own slot, and the placeholder + /// wherever the settlement said it isn't needed. + #[test] + fn addresses_fill_each_slot_with_its_program_or_the_placeholder() { + let spl_token = TokenProgram::SplToken.address(); + let token_2022 = TokenProgram::Token2022.address(); + assert_eq!(TokenPrograms::BOTH.addresses(), [spl_token, token_2022]); + assert_eq!( + TokenPrograms::SPL_TOKEN.addresses(), + [spl_token, SYSTEM_PROGRAM_ID], + ); + assert_eq!( + TokenPrograms::TOKEN_2022.addresses(), + [SYSTEM_PROGRAM_ID, token_2022], + ); + assert_eq!( + TokenPrograms::NONE.addresses(), + [SYSTEM_PROGRAM_ID, SYSTEM_PROGRAM_ID], + ); + } + + /// The slots are laid out in [`TokenProgram::ALL`] order, which is what + /// lets the on-chain side pair a slot with the program it stands for by + /// position alone. + #[test] + fn addresses_follow_the_supported_program_order() { + assert_eq!( + TokenPrograms::BOTH.addresses(), + TokenProgram::ALL.map(TokenProgram::address), + ); + } + + /// Carrying nothing is the default, so a builder that forgets its token + /// programs settles no tokens rather than silently picking one. + #[test] + fn no_program_is_carried_by_default() { + assert_eq!(TokenPrograms::default(), TokenPrograms::NONE); + } } diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index 416e9b4..3a4f5d8 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -15,9 +15,7 @@ use cow_settlement_interface::{ InstructionInputParsing, }, pda::buffer::validate_buffer_pda, - recover_discriminator, - token_program::TokenProgram, - SettlementError, SettlementInstruction, + recover_discriminator, SettlementError, SettlementInstruction, }; use pinocchio::{ cpi::Signer, @@ -33,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, validate_token_program}, + token::{read_token_account, TokenPrograms}, }; use super::validate_counterpart; @@ -78,7 +76,10 @@ pub fn process_begin_settle( let finalize_ix = instructions.load_instruction_at(usize::from(input.finalize_ix_index))?; - let token_program = validate_token_program(input.token_program_account)?; + 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( @@ -87,7 +88,7 @@ pub fn process_begin_settle( signer, &input.orders, &finalize_ix, - token_program, + &token_programs, ) }) } @@ -208,7 +209,7 @@ fn settle_orders( state_pda_signer: &Signer, orders: &SettledOrders<'_, AccountView>, finalize_ix: &IntrospectedInstruction, - token_program: TokenProgram, + token_programs: &TokenPrograms, ) -> ProgramResult { // Orders must be passed strictly increasing by address; this rejects // duplicates (settling the same order twice) without a separate scan. @@ -239,7 +240,7 @@ fn settle_orders( now, state_pda_account, state_pda_signer, - token_program, + token_programs, )?; } @@ -263,7 +264,7 @@ fn process_order( now: i64, state_account: &AccountView, state_pda_signer: &Signer, - token_program: TokenProgram, + token_programs: &TokenPrograms, ) -> ProgramResult { let SettledOrder { order_pda, @@ -301,12 +302,17 @@ fn process_order( if sell_token_account.address() != &intent.sell_token_account { 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)?; // 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 the - // instruction's token program before we read its owner and mint, and reads - // by value, so nothing is left borrowing the account when the transfers - // below touch it. + // `read_token_account` confirms this is a real token account of that token + // program before we read its owner and mint, and reads by value, so nothing + // is left borrowing the account when the transfers below touch it. let sell_token = read_token_account(token_program, sell_token_account) .map_err(|_| SettlementError::SellTokenAccountInvalid)?; if sell_token.owner != intent.owner { @@ -424,7 +430,9 @@ mod tests { use cow_settlement_interface::data::intent::Flags; use cow_settlement_interface::instruction::fixtures::fake_account; use cow_settlement_interface::instruction::settle::fixtures::arb_pushes; - use cow_settlement_interface::instruction::settle::{FinalizeSettle, FinalizeSettleInput}; + use cow_settlement_interface::instruction::settle::{ + FinalizeSettle, FinalizeSettleInput, TokenPrograms, + }; use cow_settlement_interface::instruction::InstructionInputParsing; use cow_settlement_interface::Pubkey; use proptest::prelude::*; @@ -1029,6 +1037,7 @@ mod tests { program_id: Pubkey::new_from_array(program_id), state_pda: Pubkey::new_from_array(state_pda), begin_ix_index, + token_programs: TokenPrograms::BOTH, source_buffers: &source_buffers, destinations: &destinations, bumps: &bumps, diff --git a/programs/settlement/src/settle/finalize.rs b/programs/settlement/src/settle/finalize.rs index 3bd2564..b52d8f7 100644 --- a/programs/settlement/src/settle/finalize.rs +++ b/programs/settlement/src/settle/finalize.rs @@ -5,7 +5,6 @@ use cow_settlement_interface::{ settle::{FinalizeSettleInput, Pushes}, InstructionInputParsing, }, - token_program::TokenProgram, SettlementError, SettlementInstruction, }; use pinocchio::{ @@ -15,7 +14,7 @@ use pinocchio_token::instructions::Transfer; use crate::{ processor::{is_cpi_call, with_state_pda_signer}, - token::validate_token_program, + token::TokenPrograms, }; use super::validate_counterpart; @@ -48,14 +47,17 @@ 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_program = validate_token_program(input.token_program_account)?; + 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_program, + &token_programs, ) }) } @@ -75,9 +77,16 @@ fn push_funds<'a>( state_pda_account: &AccountView, state_pda_signer: &Signer, pushes: Pushes<'a, AccountView>, - token_program: TokenProgram, + 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)?; Transfer::new( push.source_buffer, push.destination, diff --git a/programs/settlement/src/token.rs b/programs/settlement/src/token.rs index 25148d2..408dbcf 100644 --- a/programs/settlement/src/token.rs +++ b/programs/settlement/src/token.rs @@ -202,6 +202,123 @@ 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), + ] + } + + /// 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)) + } + + /// 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. + #[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"); + + for program in SUPPORTED_TOKEN_PROGRAMS { + assert_eq!( + programs.program_for(&token_account_of(program)), + Ok(Some(&program)), + "an account owned by {program} should be settled against it", + ); + } + } + + /// 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"); + + 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 [ + [SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID], + [TOKEN_2022_PROGRAM_ID, SPL_TOKEN_PROGRAM_ID], + ] { + let carried_account = fake_account(carried); + let (spl_token, token_2022) = if carried == SPL_TOKEN_PROGRAM_ID { + (&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)), + 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)), + 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 SUPPORTED_TOKEN_PROGRAMS { + assert_eq!( + programs.program_for(&token_account_of(program)), + 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(UNRELATED); + 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), + ); + } + #[test] fn validate_token_program_accepts_every_supported_program() { for program in TokenProgram::ALL { diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index 3a2965f..bf7ffaa 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -29,14 +29,14 @@ use crate::common::{ use cow_settlement_client::cow_settlement_interface::{ data::order::{EncodedOrderAccount, OrderAccount}, instruction::settle::{ - BeginSettle as BeginSettleRaw, FinalizeSettle as FinalizeSettleRaw, INSTRUCTIONS_SYSVAR_ID, - SPL_TOKEN_PROGRAM_ID, + BeginSettle as BeginSettleRaw, FinalizeSettle as FinalizeSettleRaw, + FINALIZE_FIXED_ACCOUNTS, INSTRUCTIONS_SYSVAR_ID, SPL_TOKEN_PROGRAM_ID, }, pda::{buffer::find_buffer_pda, order::find_order_pda, state::find_state_pda}, Instruction, SettlementError, SettlementInstruction, }; use cow_settlement_client::instructions::{ - BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, + BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, TokenPrograms, }; use cow_settlement_interface::data::intent::OrderIntent; use litesvm::LiteSVM; @@ -137,11 +137,13 @@ fn settle_and_pay_amounts( solver: solver.pubkey(), finalize_ix_index: FINALIZE_INDEX.into(), auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders, }; let finalize = FinalizeSettle { program_id: *program_id, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::SPL_TOKEN, orders: &settled, }; vec![begin.into(), finalize.into()] @@ -254,6 +256,7 @@ fn rejects_fabricated_program_owned_account() { solver: solver.pubkey(), finalize_ix_index: 1, auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, order_pdas: &[fake_order], sell_token_accounts: &[sell_token], pulls: &no_pulls(1), @@ -264,6 +267,7 @@ fn rejects_fabricated_program_owned_account() { program_id, state_pda: find_state_pda(&program_id).0, begin_ix_index: 0, + token_programs: TokenPrograms::SPL_TOKEN, source_buffers: &[unique_pubkey()], destinations: &[intent.buy_token_account], bumps: &[0], @@ -294,6 +298,7 @@ fn rejects_non_order_account_in_order_slot() { solver: solver.pubkey(), finalize_ix_index: 1, auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, order_pdas: &[sell_token], sell_token_accounts: &[sell_token], pulls: &no_pulls(1), @@ -303,6 +308,7 @@ fn rejects_non_order_account_in_order_slot() { program_id, state_pda: find_state_pda(&program_id).0, begin_ix_index: 0, + token_programs: TokenPrograms::SPL_TOKEN, source_buffers: &[unique_pubkey()], destinations: &[unique_pubkey()], bumps: &[0], @@ -479,10 +485,10 @@ fn rejects_orders_in_wrong_address_order() { // instructions by hand in the current wire format. Begin data is // `[discriminator, finalize_ix_index (LE), order_count, transfer_count×n]` // (no transfers here) and begin accounts are `[solver, instructions_sysvar, - // state_pda, token_program, (order_pda, sell_token_account)...]`. The - // finalize's push destinations are laid out in the same decreasing order, - // so the first order's destination check passes and the second order trips - // the ordering check. + // state_pda, spl_token_program, token_2022_program, (order_pda, + // sell_token_account)...]`. The finalize's push destinations are laid out in + // the same decreasing order, so the first order's destination check passes + // and the second order trips the ordering check. let mut orders = [(first_pda, &first), (second_pda, &second)]; orders.sort_by_key(|&(pda, ..)| std::cmp::Reverse(pda)); @@ -498,8 +504,12 @@ fn rejects_orders_in_wrong_address_order() { AccountMeta::new_readonly(solver.pubkey(), true), AccountMeta::new_readonly(INSTRUCTIONS_SYSVAR_ID, false), AccountMeta::new_readonly(find_state_pda(&program_id).0, false), - AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), ]; + accounts.extend( + TokenPrograms::SPL_TOKEN + .addresses() + .map(|program| AccountMeta::new_readonly(program, false)), + ); for (order_pda, intent) in orders { accounts.push(AccountMeta::new_readonly(order_pda, false)); accounts.push(AccountMeta::new(intent.sell_token_account, false)); @@ -528,6 +538,7 @@ fn rejects_orders_in_wrong_address_order() { program_id, state_pda: find_state_pda(&program_id).0, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::SPL_TOKEN, source_buffers: &source_buffers, destinations: &destinations, bumps: &bumps, @@ -1076,11 +1087,12 @@ fn rejects_push_to_wrong_destination() { let mut finalize = Instruction::from(FinalizeSettle { program_id, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::SPL_TOKEN, orders: &orders, }); // Redirect the push to an account that isn't the order's buy token account. - // Finalize accounts: `[sysvar, state, token_program, source, destination]`. - let destination_index = 4; + // Finalize accounts: `[FINALIZE_FIXED_ACCOUNTS..., source, destination]`. + let destination_index = FINALIZE_FIXED_ACCOUNTS + 1; finalize.accounts[destination_index].pubkey = unique_pubkey(); let instructions = build_settlement(&program_id, &solver.pubkey(), &orders, finalize); @@ -1109,6 +1121,7 @@ fn rejects_push_if_buffer_does_not_match_buy_mint() { program_id, state_pda: find_state_pda(&program_id).0, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::SPL_TOKEN, source_buffers: &[other_buffer], destinations: &[intent.buy_token_account], bumps: &[other_bump], @@ -1135,6 +1148,7 @@ fn rejects_fewer_pushes_than_orders() { let finalize = FinalizeSettle { program_id, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], }; @@ -1155,6 +1169,7 @@ fn rejects_more_pushes_than_orders() { let finalize = FinalizeSettle { program_id, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::SPL_TOKEN, orders: &[FinalizedIntent { intent: &intent, amount: 0, @@ -1180,6 +1195,7 @@ fn rejects_partial_push_amount_in_finalize_settle() { let mut finalize = Instruction::from(FinalizeSettle { program_id, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::SPL_TOKEN, orders: &orders, }); // Drop one byte from the finalize intstruction so the trailing amount is no diff --git a/programs/settlement/tests/common/buffer.rs b/programs/settlement/tests/common/buffer.rs index b8f9070..76767c1 100644 --- a/programs/settlement/tests/common/buffer.rs +++ b/programs/settlement/tests/common/buffer.rs @@ -1,6 +1,7 @@ //! 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; @@ -11,7 +12,7 @@ use solana_sdk::{ transaction::Transaction, }; -use super::token; +use super::{replace_first_matching_account, token}; /// The canonical buffer PDA for `mint`. pub fn buffer_pda(program_id: &Pubkey, mint: &Pubkey) -> Pubkey { @@ -43,12 +44,17 @@ pub fn ensure_buffer_exists_for( if svm.get_account(&pda).is_some() { return pda; } - let ix = Instruction::from(CreateBuffers { + let mut 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/settlement.rs b/programs/settlement/tests/common/settlement.rs index c3065bf..ad19f25 100644 --- a/programs/settlement/tests/common/settlement.rs +++ b/programs/settlement/tests/common/settlement.rs @@ -1,7 +1,7 @@ //! Scaffolding for building `[BeginSettle, FinalizeSettle]` settlement pairs. use cow_settlement_client::instructions::{ - BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, + BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, TokenPrograms, }; use cow_settlement_interface::{data::intent::OrderIntent, Instruction}; use litesvm::LiteSVM; @@ -40,6 +40,7 @@ pub fn build_settlement( solver: *solver, finalize_ix_index: FINALIZE_INDEX.into(), auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &begin_orders, }; vec![begin.into(), finalize.into()] @@ -131,11 +132,13 @@ pub fn build_staged_settlement( solver: *solver, finalize_ix_index: finalize_index(between.len()), auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &begin_orders, }; let finalize = FinalizeSettle { program_id: *program_id, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::SPL_TOKEN, orders: &finalize_orders, }; diff --git a/programs/settlement/tests/common/token.rs b/programs/settlement/tests/common/token.rs index a71dd3e..97818c2 100644 --- a/programs/settlement/tests/common/token.rs +++ b/programs/settlement/tests/common/token.rs @@ -1,10 +1,19 @@ //! SPL Token helpers for the settlement integration tests. +//! +//! Every helper that acts on an existing token works under whichever token +//! program owns it, read back with [`program_of`], so a test settling +//! Token-2022 accounts uses the same calls as one settling legacy ones. Only +//! [`create_mint_under`] has to be told, there being nothing yet to read it +//! from. -use cow_settlement_client::cow_settlement_interface::pda::state::find_state_pda; +use cow_settlement_client::cow_settlement_interface::{pda::state::find_state_pda, Instruction}; use litesvm::{types::TransactionMetadata, LiteSVM}; use litesvm_token::{ - spl_token::{instruction::initialize_mint2, state::Mint}, - Approve, CreateAccount, CreateAssociatedTokenAccount, MintTo, Transfer, TOKEN_ID, + spl_token::{ + instruction::{approve, initialize_account3, initialize_mint2, mint_to as mint_to_ix}, + state::{Account, Mint}, + }, + CreateAssociatedTokenAccount, Transfer, TOKEN_ID, }; use solana_program_pack::Pack; use solana_sdk::{ @@ -16,7 +25,58 @@ use solana_system_interface::instruction::create_account as system_create_accoun use super::unique_keypair; -/// Create a fresh mint owned by `payer` and return its address. +/// The token program that owns `account`. +/// +/// A token account always lives under its mint's program, so this answers for a +/// mint and for the accounts holding it alike — which is what lets the helpers +/// below take the program from the tokens a test already built. +pub fn program_of(svm: &LiteSVM, account: &Pubkey) -> Pubkey { + svm.get_account(account) + .unwrap_or_else(|| panic!("{account} should exist on-chain")) + .owner +} + +/// Re-target a token instruction at `token_program`. +/// +/// The SPL Token builders refuse to emit an instruction for any program but +/// their own, so the helpers below build against the legacy program and re-point +/// the result. Token-2022 encodes each of these instructions exactly as the +/// legacy program does — the same fact that lets the settlement program issue +/// one transfer against either — so only the program id needs replacing. +fn under(mut instruction: Instruction, token_program: &Pubkey) -> Instruction { + instruction.program_id = *token_program; + instruction +} + +/// Submit `instructions` as one transaction signed by `payer` and `extra`. +fn send_token_tx( + svm: &mut LiteSVM, + payer: &Keypair, + extra: &[&Keypair], + instructions: &[Instruction], + what: &str, +) { + let mut signers = vec![payer]; + signers.extend_from_slice(extra); + let tx = Transaction::new_signed_with_payer( + instructions, + Some(&payer.pubkey()), + &signers, + svm.latest_blockhash(), + ); + svm.send_transaction(tx) + .unwrap_or_else(|error| panic!("{what} should succeed: {error:?}")); +} + +/// 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 { + 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. /// /// This open-codes what [`litesvm_token::CreateMint`] does rather than calling /// it, because that builder generates the mint keypair with `Keypair::new()` @@ -39,7 +99,7 @@ pub fn create_mint_at(svm: &mut LiteSVM, payer: &Keypair, mint: &Keypair) -> Pub &mint.pubkey(), svm.minimum_balance_for_rent_exemption(Mint::LEN), Mint::LEN as u64, - &TOKEN_ID, + token_program, ); let initialize = initialize_mint2(&TOKEN_ID, &mint.pubkey(), &payer.pubkey(), None, DECIMALS) .expect("initialize_mint2 should build"); @@ -49,27 +109,46 @@ pub fn create_mint_at(svm: &mut LiteSVM, payer: &Keypair, mint: &Keypair) -> Pub &[payer, mint], svm.latest_blockhash(), ); - svm.send_transaction(tx) - .expect("mint creation should succeed"); + send_token_tx(svm, payer, &[&mint], &[create, initialize], "mint creation"); mint.pubkey() } -/// Create an initialized SPL token account for `mint` whose SPL owner is -/// `owner`, funded by `payer`, and return its address. Each call produces a -/// fresh account, so the same `owner` can hold several accounts for one `mint`. +/// Create an initialized token account for `mint` whose token owner is `owner`, +/// funded by `payer`, and return its address. The account is created under +/// `mint`'s own token program. Each call produces a fresh account, so the same +/// `owner` can hold several accounts for one `mint`. +/// +/// Open-coded for the same reason as [`create_mint_under`]: the builder picks +/// the account address itself, and it would build against the legacy program +/// whatever the mint lives under. pub fn create_token_account( svm: &mut LiteSVM, payer: &Keypair, mint: &Pubkey, owner: &Pubkey, ) -> Pubkey { - CreateAccount::new(svm, payer, mint) - .owner(owner) - // Without this the builder generates the address with `Keypair::new()`; - // see [`create_mint`]. - .account_kp(unique_keypair()) - .send() - .expect("token account creation should succeed") + let token_program = program_of(svm, mint); + let account = unique_keypair(); + let create = system_create_account( + &payer.pubkey(), + &account.pubkey(), + svm.minimum_balance_for_rent_exemption(Account::LEN), + Account::LEN as u64, + &token_program, + ); + let initialize = under( + initialize_account3(&TOKEN_ID, &account.pubkey(), mint, owner) + .expect("initialize_account3 should build"), + &token_program, + ); + send_token_tx( + svm, + payer, + &[&account], + &[create, initialize], + "token account creation", + ); + account.pubkey() } /// Create `owner`'s associated token account for `mint`, funded by `payer`, and @@ -96,9 +175,13 @@ pub fn mint_to( destination: &Pubkey, amount: u64, ) { - MintTo::new(svm, payer, mint, destination, amount) - .send() - .expect("mint_to should succeed"); + let token_program = program_of(svm, mint); + let instruction = under( + mint_to_ix(&TOKEN_ID, mint, destination, &payer.pubkey(), &[], amount) + .expect("mint_to should build"), + &token_program, + ); + send_token_tx(svm, payer, &[], &[instruction], "mint_to"); } /// Transfer `amount` of `mint` from `owner`'s associated token account into @@ -124,9 +207,13 @@ pub fn delegate( delegate: &Pubkey, amount: u64, ) { - Approve::new(svm, owner, delegate, source, amount) - .send() - .expect("approving a delegate should succeed"); + let token_program = program_of(svm, source); + let instruction = under( + approve(&TOKEN_ID, source, delegate, &owner.pubkey(), &[], amount) + .expect("approve should build"), + &token_program, + ); + send_token_tx(svm, owner, &[], &[instruction], "approving a delegate"); } /// Fund `sell_token` with `amount` of its mint and approve the settlement state @@ -149,7 +236,8 @@ pub fn fund_and_delegate( ); } -/// Read the SPL token balance of `account`. +/// Read the token balance of `account`. The two programs share the base layout +/// this reads, so it answers for an account under either. pub fn balance(svm: &LiteSVM, account: &Pubkey) -> u64 { litesvm_token::get_spl_account::(svm, account) .expect("account should exist and be a valid SPL token account") diff --git a/programs/settlement/tests/finalize_settle_pushes.rs b/programs/settlement/tests/finalize_settle_pushes.rs index daa291f..6393b9e 100644 --- a/programs/settlement/tests/finalize_settle_pushes.rs +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -21,7 +21,7 @@ use cow_settlement_client::cow_settlement_interface::{ data::intent::OrderIntent, instruction::settle::SPL_TOKEN_PROGRAM_ID, pda::state::find_state_pda, Instruction, SettlementError, }; -use cow_settlement_client::instructions::{FinalizeSettle, FinalizedIntent}; +use cow_settlement_client::instructions::{FinalizeSettle, FinalizedIntent, TokenPrograms}; use litesvm_token::spl_token::error::TokenError; use solana_sdk::{ instruction::InstructionError, program_error::ProgramError, pubkey::Pubkey, signer::Signer, @@ -45,6 +45,7 @@ fn finalize(program_id: &Pubkey, solver: &Pubkey, orders: &[FinalizedIntent]) -> let finalize = FinalizeSettle { program_id: *program_id, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::SPL_TOKEN, orders, }; build_settlement(program_id, solver, orders, finalize) @@ -253,6 +254,7 @@ fn rejects_push_account_count_mismatch() { let mut finalize = Instruction::from(FinalizeSettle { program_id, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::SPL_TOKEN, orders: &orders, }); // ...with another push's worth of data bytes appended but no matching @@ -278,9 +280,10 @@ fn rejects_too_few_accounts() { let mut finalize = Instruction::from(FinalizeSettle { program_id, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], }); - // ...with one of its three fixed accounts popped. `BeginSettle` runs first + // ...with one of its fixed accounts popped. `BeginSettle` runs first // but only reads push destinations off the accounts (finding none, matching // its zero orders) so it passes. The finalize then can't even destructure // its fixed accounts and raises `NotEnoughAccountKeys`. @@ -301,6 +304,9 @@ fn rejects_too_few_accounts() { ); } +/// An account that isn't a token account at all is owned by no token program, +/// so the push has nothing to be issued against and `FinalizeSettle` says so +/// itself rather than handing the transfer to a token program. #[test] fn rejects_invalid_buy_token_account() { let (mut svm, program_id, payer, solver) = setup_settle_ready(); @@ -319,7 +325,7 @@ fn rejects_invalid_buy_token_account() { let instructions = finalize(&program_id, &solver.pubkey(), &orders); assert_finalize_error( send(&mut svm, &solver, instructions), - InstructionError::InvalidAccountData, + to_instruction_error(SettlementError::PushDestinationInvalid), ); } @@ -334,9 +340,9 @@ fn rejects_buy_token_account_owned_by_wrong_program() { .data; let impostor = create_account(&mut svm, &unique_pubkey(), &token_shaped); - // As above, the impostor passes both instructions' checks (the push pays - // `intent.buy_token_account` from `intent.buy_mint`'s buffer) and is left - // for the SPL token program, which rejects a destination it doesn't own. + // As above, the impostor passes both instructions' push checks (the push + // pays `intent.buy_token_account` from `intent.buy_mint`'s buffer), but its + // owner is no token program, so there is nothing to issue the push against. let intent = OrderIntent { buy_token_account: impostor, ..settlable @@ -351,7 +357,7 @@ fn rejects_buy_token_account_owned_by_wrong_program() { let instructions = finalize(&program_id, &solver.pubkey(), &orders); assert_finalize_error( send(&mut svm, &solver, instructions), - InstructionError::IncorrectProgramId, + to_instruction_error(SettlementError::PushDestinationInvalid), ); } @@ -372,6 +378,7 @@ fn rejects_two_too_few_accounts() { let mut finalize = Instruction::from(FinalizeSettle { program_id, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::SPL_TOKEN, orders: &orders, }); // ...with that push's whole (source, destination) pair popped, so the data @@ -401,6 +408,7 @@ fn rejects_partial_push_amount() { let mut finalize = Instruction::from(FinalizeSettle { program_id, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::SPL_TOKEN, orders: &orders, }); // Drop one byte so the trailing amount is no longer a whole `u64`. diff --git a/programs/settlement/tests/matching_begin_finalize.rs b/programs/settlement/tests/matching_begin_finalize.rs index 958cea0..df4e3d6 100644 --- a/programs/settlement/tests/matching_begin_finalize.rs +++ b/programs/settlement/tests/matching_begin_finalize.rs @@ -1,5 +1,5 @@ use cow_settlement_client::cow_settlement_interface::{SettlementError, SettlementInstruction}; -use cow_settlement_client::instructions::{BeginSettle, FinalizeSettle}; +use cow_settlement_client::instructions::{BeginSettle, FinalizeSettle, TokenPrograms}; use litesvm::{types::FailedTransactionMetadata, LiteSVM}; use solana_sdk::{ instruction::{AccountMeta, Instruction, InstructionError}, @@ -40,12 +40,14 @@ fn run_sequence( solver: solver.pubkey(), finalize_ix_index: *idx, auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], } .into(), AbstractInstruction::Fin(idx) => FinalizeSettle { program_id: *program_id, begin_ix_index: *idx, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], } .into(), @@ -193,6 +195,7 @@ fn rejects_non_instructions_sysvar_account_at_position_one() { solver: solver.pubkey(), finalize_ix_index: 1, auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], } .into(); @@ -200,6 +203,7 @@ fn rejects_non_instructions_sysvar_account_at_position_one() { let finalize = FinalizeSettle { program_id, begin_ix_index: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], }; @@ -231,6 +235,7 @@ fn rejects_counterpart_instruction_in_different_program() { solver: solver.pubkey(), finalize_ix_index: 1, auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], }; // We build a transaction that looks like a valid finalize_settle but @@ -239,6 +244,7 @@ fn rejects_counterpart_instruction_in_different_program() { let stranger = FinalizeSettle { program_id: solana_system_interface::program::ID, begin_ix_index: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], }; @@ -293,6 +299,7 @@ fn rejects_cpi_call_to_begin_settle() { solver: solver.pubkey(), finalize_ix_index: 1, auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], }, ); @@ -325,6 +332,7 @@ fn rejects_cpi_call_to_finalize_settle() { FinalizeSettle { program_id: settlement_id, begin_ix_index: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], }, ); @@ -358,6 +366,7 @@ fn rejects_counterpart_with_unrecoverable_discriminator() { solver: solver.pubkey(), finalize_ix_index: 1, auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], }; // Uses the settlement program, but no data: `recover_discriminator` fails @@ -400,6 +409,7 @@ fn rejects_counterpart_with_unrecoverable_counterpart_index() { solver: solver.pubkey(), finalize_ix_index: 1, auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], }; // Same program as `begin`, with a valid discriminator but no trailing diff --git a/programs/settlement/tests/program_deployment.rs b/programs/settlement/tests/program_deployment.rs index bbd3a8d..e7779d6 100644 --- a/programs/settlement/tests/program_deployment.rs +++ b/programs/settlement/tests/program_deployment.rs @@ -1,4 +1,4 @@ -use cow_settlement_client::instructions::{BeginSettle, FinalizeSettle}; +use cow_settlement_client::instructions::{BeginSettle, FinalizeSettle, TokenPrograms}; use solana_sdk::{ instruction::{Instruction, InstructionError}, signature::Signer, @@ -34,12 +34,14 @@ fn program_can_be_invoked() { solver: solver.pubkey(), finalize_ix_index: 1, auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], } .into(), FinalizeSettle { program_id, begin_ix_index: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], } .into(), diff --git a/programs/settlement/tests/settle_token_programs.rs b/programs/settlement/tests/settle_token_programs.rs new file mode 100644 index 0000000..6306d92 --- /dev/null +++ b/programs/settlement/tests/settle_token_programs.rs @@ -0,0 +1,429 @@ +//! Integration tests for the token-program slots a `BeginSettle` / +//! `FinalizeSettle` pair carries. +//! +//! 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. + +use crate::common::{ + assert_settlement_error, buffer, + order::OrderBuilder, + settlement::{BEGIN_INDEX, FINALIZE_INDEX}, + setup, 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, +}; +use cow_settlement_client::instructions::{ + BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, TokenPrograms, +}; +use litesvm::LiteSVM; +use solana_sdk::{ + instruction::InstructionError, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::{Transaction, TransactionError}, +}; + +mod common; + +/// What each order in a settlement sells and buys: `amount_in` of its sell +/// token pulled out, `amount_out` of its buy token pushed in. +struct Settled<'a> { + intent: &'a OrderIntent, + amount_in: u64, + amount_out: u64, +} + +/// Fund and settle `orders` in one `[BeginSettle, FinalizeSettle]` pair, with +/// each instruction carrying the token-program slots it is given. +/// +/// Every account involved is set up under its own mint's program, so the only +/// thing a test varies is which programs the settlement says it carries. +fn settle_with( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + orders: &[Settled], + begin_programs: TokenPrograms, + finalize_programs: TokenPrograms, +) -> Result<(), TransactionError> { + let mut initialized: Vec = vec![]; + let mut finalized: Vec = vec![]; + for order in orders { + let intent = order.intent; + // Sell side: fund the account and delegate the pull to the state PDA, + // then pull into a throwaway account of the same mint. + token::fund_and_delegate( + svm, + program_id, + payer, + &intent.sell_token_account, + order.amount_in, + ); + let sell_mint = token::mint_of(svm, &intent.sell_token_account); + let destination = token::create_token_account(svm, payer, &sell_mint, &unique_pubkey()); + let pulls: &[Pull] = Box::leak(Box::new([Pull { + destination, + amount: order.amount_in, + }])); + initialized.push(InitializedIntent { intent, pulls }); + + // Buy side: fund the buffer so the push has something to draw from. + let buy_mint = token::mint_of(svm, &intent.buy_token_account); + 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, + finalize_ix_index: FINALIZE_INDEX.into(), + auction_id: 0, + token_programs: begin_programs, + orders: &initialized, + }; + let finalize = FinalizeSettle { + program_id: *program_id, + begin_ix_index: BEGIN_INDEX.into(), + token_programs: finalize_programs, + orders: &finalized, + }; + let tx = Transaction::new_signed_with_payer( + &[begin.into(), finalize.into()], + Some(&payer.pubkey()), + &[payer], + svm.latest_blockhash(), + ); + svm.send_transaction(tx) + .map(|_| ()) + .map_err(|error| error.err) +} + +/// An order selling a token under `sell_program` and buying one under +/// `buy_program`, priced 1:1 and partially fillable. +fn order_across( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + salt: u8, + sell_program: &Pubkey, + buy_program: &Pubkey, +) -> OrderIntent { + let sell_mint = token::create_mint_under(svm, payer, sell_program); + let buy_mint = token::create_mint_under(svm, payer, buy_program); + let intent = OrderBuilder::new(svm, program_id, payer) + .salt(salt) + .sell_mint(&sell_mint) + .buy_mint(&buy_mint) + .sell_amount(1_000) + .buy_amount(1_000) + .build(); + // The order's accounts have to have landed under the programs asked for, or + // a test meant to settle Token-2022 would quietly be settling legacy tokens. + assert_eq!( + token::program_of(svm, &intent.sell_token_account), + *sell_program, + ); + assert_eq!( + token::program_of(svm, &intent.buy_token_account), + *buy_program, + ); + intent +} + +/// The headline capability: one settlement pair moving tokens under both +/// 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 legacy = order_across( + &mut svm, + &program_id, + &payer, + 0, + &SPL_TOKEN_PROGRAM_ID, + &SPL_TOKEN_PROGRAM_ID, + ); + let token_2022 = order_across( + &mut svm, + &program_id, + &payer, + 1, + &TOKEN_2022_PROGRAM_ID, + &TOKEN_2022_PROGRAM_ID, + ); + + settle_with( + &mut svm, + &program_id, + &payer, + &[ + Settled { + intent: &legacy, + amount_in: 400, + amount_out: 400, + }, + Settled { + intent: &token_2022, + amount_in: 700, + amount_out: 700, + }, + ], + TokenPrograms::BOTH, + TokenPrograms::BOTH, + ) + .expect("a settlement carrying both programs should settle orders under either"); + + assert_eq!(token::balance(&svm, &legacy.buy_token_account), 400); + assert_eq!(token::balance(&svm, &token_2022.buy_token_account), 700); + // Both sell sides were drained by their own program's transfer. + assert_eq!(token::balance(&svm, &legacy.sell_token_account), 0); + assert_eq!(token::balance(&svm, &token_2022.sell_token_account), 0); +} + +/// The two sides of one order need not share a program: the pull follows the +/// 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 intent = order_across( + &mut svm, + &program_id, + &payer, + 0, + &SPL_TOKEN_PROGRAM_ID, + &TOKEN_2022_PROGRAM_ID, + ); + + settle_with( + &mut svm, + &program_id, + &payer, + &[Settled { + intent: &intent, + amount_in: 250, + amount_out: 250, + }], + TokenPrograms::BOTH, + TokenPrograms::BOTH, + ) + .expect("an order selling under one program and buying under the other should settle"); + + assert_eq!(token::balance(&svm, &intent.buy_token_account), 250); + assert_eq!(token::balance(&svm, &intent.sell_token_account), 0); +} + +/// A settlement that carries only Token-2022 still settles Token-2022 orders: +/// 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 intent = order_across( + &mut svm, + &program_id, + &payer, + 0, + &TOKEN_2022_PROGRAM_ID, + &TOKEN_2022_PROGRAM_ID, + ); + + settle_with( + &mut svm, + &program_id, + &payer, + &[Settled { + intent: &intent, + amount_in: 300, + amount_out: 300, + }], + TokenPrograms::TOKEN_2022, + TokenPrograms::TOKEN_2022, + ) + .expect("a Token-2022-only settlement should settle Token-2022 orders"); + + 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. +#[test] +fn rejects_a_sell_account_under_a_left_out_program() { + let (mut svm, program_id, payer) = setup(); + + let intent = order_across( + &mut svm, + &program_id, + &payer, + 0, + &TOKEN_2022_PROGRAM_ID, + &SPL_TOKEN_PROGRAM_ID, + ); + + assert_settlement_error( + BEGIN_INDEX, + settle_with( + &mut svm, + &program_id, + &payer, + &[Settled { + intent: &intent, + amount_in: 100, + amount_out: 100, + }], + TokenPrograms::SPL_TOKEN, + TokenPrograms::SPL_TOKEN, + ), + SettlementError::TokenProgramNotProvided, + ); +} + +/// `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. +#[test] +fn rejects_a_buy_account_under_a_left_out_program() { + let (mut svm, program_id, payer) = setup(); + + let intent = order_across( + &mut svm, + &program_id, + &payer, + 0, + &SPL_TOKEN_PROGRAM_ID, + &TOKEN_2022_PROGRAM_ID, + ); + + assert_settlement_error( + FINALIZE_INDEX, + settle_with( + &mut svm, + &program_id, + &payer, + &[Settled { + intent: &intent, + amount_in: 100, + amount_out: 100, + }], + TokenPrograms::BOTH, + TokenPrograms::SPL_TOKEN, + ), + SettlementError::TokenProgramNotProvided, + ); +} + +/// 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. +#[test] +fn rejects_swapped_token_program_slots() { + let (mut svm, program_id, payer) = setup(); + + let intent = order_across( + &mut svm, + &program_id, + &payer, + 0, + &SPL_TOKEN_PROGRAM_ID, + &SPL_TOKEN_PROGRAM_ID, + ); + token::fund_and_delegate( + &mut svm, + &program_id, + &payer, + &intent.sell_token_account, + 100, + ); + let sell_mint = token::mint_of(&svm, &intent.sell_token_account); + let buy_mint = token::mint_of(&svm, &intent.buy_token_account); + buffer::ensure_funded(&mut svm, &program_id, &payer, &buy_mint, 100); + let destination = token::create_token_account(&mut svm, &payer, &sell_mint, &unique_pubkey()); + + let pulls = [Pull { + destination, + amount: 100, + }]; + let mut begin = Instruction::from(BeginSettle { + program_id, + finalize_ix_index: FINALIZE_INDEX.into(), + auction_id: 0, + token_programs: TokenPrograms::BOTH, + orders: &[InitializedIntent { + intent: &intent, + 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); + let finalize = FinalizeSettle { + program_id, + begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::BOTH, + orders: &[FinalizedIntent { + intent: &intent, + mint: buy_mint, + amount: 100, + }], + }; + + let tx = Transaction::new_signed_with_payer( + &[begin, finalize.into()], + Some(&payer.pubkey()), + &[&payer], + 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), + ); +} + +/// Every settlement in the rest of the suite leaves Token-2022's slot empty, so +/// the placeholder has to be accepted for a legacy-only settlement — and it is +/// 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 intent = order_across( + &mut svm, + &program_id, + &payer, + 0, + &SPL_TOKEN_PROGRAM_ID, + &SPL_TOKEN_PROGRAM_ID, + ); + + settle_with( + &mut svm, + &program_id, + &payer, + &[Settled { + intent: &intent, + amount_in: 500, + amount_out: 500, + }], + TokenPrograms::SPL_TOKEN, + TokenPrograms::SPL_TOKEN, + ) + .expect("a legacy-only settlement should not have to carry Token-2022"); + + assert_eq!(token::balance(&svm, &intent.buy_token_account), 500); +} diff --git a/test-cli/src/cmd/settle.rs b/test-cli/src/cmd/settle.rs index 9efb0b8..ddd6ea9 100644 --- a/test-cli/src/cmd/settle.rs +++ b/test-cli/src/cmd/settle.rs @@ -9,6 +9,7 @@ use cow_settlement_client::{ }, instructions::{ BeginSettle, CreateBuffers, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, + TokenPrograms, }, }; use solana_hash::Hash; @@ -116,6 +117,9 @@ pub fn run(ctx: Context, args: SettleArgs) -> anyhow::Result<()> { program_id: ctx.program_id, solver, finalize_ix_index, + // Token resolution builds legacy SPL accounts throughout (see + // `crate::token`), so Token-2022's slot stays empty. + token_programs: TokenPrograms::SPL_TOKEN, orders: &initialized_intents, auction_id: 0, }; @@ -132,6 +136,7 @@ pub fn run(ctx: Context, args: SettleArgs) -> anyhow::Result<()> { let finalize_ix = FinalizeSettle { program_id: ctx.program_id, begin_ix_index, + token_programs: TokenPrograms::SPL_TOKEN, orders: &settled, };