diff --git a/bench-report.json b/bench-report.json index 3fe35f5..8984ae7 100644 --- a/bench-report.json +++ b/bench-report.json @@ -10,15 +10,15 @@ "reclaim_buffer/max_buffers_in_one_instruction": 64, "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 9, "reclaim_order/happy_path_returns_lamports_and_closes_pda": 4, - "settle/finalizes_with_no_pushes": 5, - "settle/pulls_from_multiple_orders": 15, - "settle/pulls_funds_to_destination": 10, - "settle/pulls_to_multiple_destinations": 11, - "settle/pushes_a_single_order": 9, - "settle/pushes_several_orders_from_different_buffers": 13, - "settle/pushes_several_orders_from_one_buffer": 12, - "settle/settles_a_single_order": 9, - "settle/settles_multiple_orders": 17, + "settle/finalizes_with_no_pushes": 6, + "settle/pulls_from_multiple_orders": 16, + "settle/pulls_funds_to_destination": 11, + "settle/pulls_to_multiple_destinations": 12, + "settle/pushes_a_single_order": 10, + "settle/pushes_several_orders_from_different_buffers": 14, + "settle/pushes_several_orders_from_one_buffer": 13, + "settle/settles_a_single_order": 10, + "settle/settles_multiple_orders": 18, "transfer_authority/manager_can_transfer_manager": 4, "transfer_authority/manager_can_transfer_reclaim_authority": 4, "transfer_authority/reclaim_authority_can_transfer_itself": 4 @@ -29,20 +29,20 @@ "create_buffers/max_buffers_in_one_instruction": 177519, "create_order/happy_path_creates_order_pda_with_expected_body": 7914, "initialize/happy_path_initializes_state_pda_with_expected_data": 4519, - "reclaim_buffer/funded_buffer_is_skipped": 6321, - "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7470, - "reclaim_buffer/max_buffers_in_one_instruction": 137046, - "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18083, + "reclaim_buffer/funded_buffer_is_skipped": 6364, + "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7512, + "reclaim_buffer/max_buffers_in_one_instruction": 138335, + "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18169, "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2127, - "settle/finalizes_with_no_pushes": 7025, - "settle/pulls_from_multiple_orders": 19713, - "settle/pulls_funds_to_destination": 13390, - "settle/pulls_to_multiple_destinations": 14536, - "settle/pushes_a_single_order": 12241, - "settle/pushes_several_orders_from_different_buffers": 17416, - "settle/pushes_several_orders_from_one_buffer": 17417, - "settle/settles_a_single_order": 12259, - "settle/settles_multiple_orders": 22635, + "settle/finalizes_with_no_pushes": 7097, + "settle/pulls_from_multiple_orders": 20157, + "settle/pulls_funds_to_destination": 13656, + "settle/pulls_to_multiple_destinations": 14802, + "settle/pushes_a_single_order": 12507, + "settle/pushes_several_orders_from_different_buffers": 17856, + "settle/pushes_several_orders_from_one_buffer": 17858, + "settle/settles_a_single_order": 12525, + "settle/settles_multiple_orders": 23257, "transfer_authority/manager_can_transfer_manager": 3163, "transfer_authority/manager_can_transfer_reclaim_authority": 3165, "transfer_authority/reclaim_authority_can_transfer_itself": 3168 @@ -58,15 +58,15 @@ "reclaim_buffer/max_buffers_in_one_instruction": 332, "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 466, "reclaim_order/happy_path_returns_lamports_and_closes_pda": 236, - "settle/finalizes_with_no_pushes": 289, - "settle/pulls_from_multiple_orders": 655, - "settle/pulls_funds_to_destination": 472, - "settle/pulls_to_multiple_destinations": 513, - "settle/pushes_a_single_order": 431, - "settle/pushes_several_orders_from_different_buffers": 573, - "settle/pushes_several_orders_from_one_buffer": 541, - "settle/settles_a_single_order": 431, - "settle/settles_multiple_orders": 715, + "settle/finalizes_with_no_pushes": 323, + "settle/pulls_from_multiple_orders": 689, + "settle/pulls_funds_to_destination": 506, + "settle/pulls_to_multiple_destinations": 547, + "settle/pushes_a_single_order": 465, + "settle/pushes_several_orders_from_different_buffers": 607, + "settle/pushes_several_orders_from_one_buffer": 575, + "settle/settles_a_single_order": 465, + "settle/settles_multiple_orders": 749, "transfer_authority/manager_can_transfer_manager": 333, "transfer_authority/manager_can_transfer_reclaim_authority": 333, "transfer_authority/reclaim_authority_can_transfer_itself": 333 diff --git a/client/src/instructions.rs b/client/src/instructions.rs index ef0bea9..33ba214 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -13,7 +13,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 @@ -30,6 +30,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>], } @@ -50,6 +54,7 @@ impl From> for Instruction { state_pda, 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>], } @@ -113,6 +121,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, @@ -256,7 +265,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, }, @@ -283,6 +293,7 @@ mod tests { program_id, finalize_ix_index, auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &orders, }); @@ -348,6 +359,7 @@ mod tests { let ix = Instruction::from(FinalizeSettle { program_id, begin_ix_index, + token_programs: TokenPrograms::SPL_TOKEN, orders: &orders, }); @@ -393,9 +405,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 4217186..2734e92 100644 --- a/client/src/parse.rs +++ b/client/src/parse.rs @@ -69,6 +69,7 @@ mod tests { use super::*; use crate::instructions::{ BeginSettle, CreateBuffers, CreateOrder, FinalizeSettle, Initialize, InitializedIntent, + TokenPrograms, }; use cow_settlement_interface::{ data::intent::{fixtures::sample_intent, OrderKind}, @@ -111,6 +112,7 @@ mod tests { program_id, finalize_ix_index: 1, auction_id: 42, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[InitializedIntent { intent: &intent, pulls: &[], @@ -120,6 +122,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 4a9f525..e665c67 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`. @@ -33,9 +33,12 @@ pub struct Pull { /// Wire format (grouped, with `n` orders and `T` total transfers): /// `[discriminator=0][finalize_ix_index: u16 LE][auction_id: i64 LE][n: u8] /// [transfer_count×n][amount: u64 LE ×T]`. -/// Required accounts: `[instructions_sysvar (R), state_pda (R), token_program -/// (R)]` followed, per order, by `[order_pda (W), sell_token_account (W), -/// destination (W)...]`. +/// Required accounts: `[instructions_sysvar (R), state_pda (R), +/// 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. /// /// The program requires the order PDAs to be strictly increasing by address. /// This builder establishes that ordering for the caller: it sorts the orders by @@ -49,6 +52,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's pulls are issued against, one + /// account each. Every account a pull touches must be owned by one of them. + pub token_programs: TokenPrograms, pub order_pdas: &'a [Pubkey], pub sell_token_accounts: &'a [Pubkey], pub pulls: &'a [&'a [Pull]], @@ -61,6 +67,7 @@ impl From> for Instruction { state_pda, finalize_ix_index, auction_id, + token_programs, order_pdas, sell_token_accounts, pulls, @@ -89,12 +96,16 @@ impl From> for Instruction { .concat(); // Read-only accounts for instruction introspection, settlement state, and - // the SPL token program. + // one slot per supported token program. 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(|program| AccountMeta::new_readonly(program, false)), + ); for &i in &order { // Writable account for the order: `BeginSettle` updates its filled // amounts (`amount_withdrawn`/`amount_received`). @@ -196,7 +207,11 @@ pub struct BeginSettleInput<'a, A> { pub auction_id: i64, 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 system + /// program where this settlement moves no legacy token. + 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>, } @@ -209,7 +224,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 [instructions_sysvar_account, state_pda_account, token_program_account, order_accounts @ ..] = + let [instructions_sysvar_account, state_pda_account, spl_token_program_account, token_2022_program_account, order_accounts @ ..] = accounts else { return Err(ProgramError::NotEnoughAccountKeys); @@ -263,7 +278,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, orders: SettledOrders { order_accounts, counts, @@ -280,14 +296,18 @@ 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, SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, + }; use crate::instruction::tests::assert_readonly_nonsigner; use hex_literal::hex; use solana_account_view::AccountView; use solana_address::Address; /// The fixed accounts every `BeginSettle` carries before its order accounts: - /// the instructions sysvar, the settlement state PDA, and the token program. - const FIXED_ACCOUNTS: usize = 3; + /// the instructions sysvar, the settlement state PDA, and one slot per + /// supported token program. + const FIXED_ACCOUNTS: usize = 4; /// A placeholder auction id for the tests where its specific value is /// incidental. The wire-layout tests spell out the literal bytes instead. @@ -306,6 +326,7 @@ mod tests { state_pda, finalize_ix_index: 0x1337, auction_id: 0x0102_0304_0506_0708, + token_programs: TokenPrograms::SPL_TOKEN, order_pdas: &[], sell_token_accounts: &[], pulls: &[], @@ -321,14 +342,45 @@ mod tests { [0], // order count ], ); - // 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 four 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(), 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 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(), + finalize_ix_index: 0, + auction_id: 0, + token_programs, + order_pdas: &[], + sell_token_accounts: &[], + pulls: &[], + }); + let slots: Vec = 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] @@ -346,6 +398,7 @@ mod tests { state_pda, 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: &[&[], &[]], @@ -367,6 +420,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, @@ -410,6 +464,7 @@ mod tests { state_pda, 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: &[ @@ -450,6 +505,7 @@ mod tests { INSTRUCTIONS_SYSVAR_ID, state_pda, SPL_TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, order_a, sell_a, dest_a0, @@ -476,13 +532,16 @@ mod tests { #[test] fn begin_settle_input_parses_valid_input() { let sysvar = Address::new_from_array([0x42u8; 32]); - // The state-PDA and token-program slots are reserved but not surfaced. + // Parsing is positional and validates nothing, so the state-PDA and + // token-program slots are stand-ins here. let state = Address::new_from_array([0x43u8; 32]); - let token_program = Address::new_from_array([0x44u8; 32]); + let spl_token_program = Address::new_from_array([0x44u8; 32]); + let token_2022_program = Address::new_from_array([0x45u8; 32]); 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::BeginSettle.discriminator()], @@ -495,14 +554,16 @@ mod tests { auction_id, 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); } @@ -532,6 +593,24 @@ mod tests { ); } + /// One account short of the fixed block: the token-program slots are read + /// positionally, so a settlement that omits one has no order accounts to + /// parse from either. + #[test] + fn begin_settle_input_rejects_a_missing_token_program_slot() { + let accounts = fake_sequential_accounts::<{ FIXED_ACCOUNTS - 1 }>(); + let data = ix_data![ + [SettlementInstruction::BeginSettle.discriminator()], + [0, 0], // finalize index + AUCTION_ID.to_le_bytes(), // auction id + [0x00], // order count + ]; + assert_eq!( + BeginSettleInput::parse(&data, &accounts).err(), + Some(ProgramError::NotEnoughAccountKeys), + ); + } + #[test] fn begin_settle_input_rejects_missing_auction_id() { // The body carries the finalize index but fewer than eight bytes for the @@ -552,13 +631,15 @@ mod tests { fn begin_settle_input_pairs_orders_with_their_accounts() { let sysvar = Address::new_from_array([1u8; 32]); let state = Address::new_from_array([0xa1u8; 32]); - let token_program = Address::new_from_array([0xa2u8; 32]); + let spl_token_program = Address::new_from_array([0xa2u8; 32]); + let token_2022_program = Address::new_from_array([0xa3u8; 32]); let order_pda = Address::new_from_array([2u8; 32]); let sell_token = Address::new_from_array([3u8; 32]); let accounts = [ 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), ]; @@ -575,12 +656,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); let mut orders = orders.iter(); @@ -595,7 +678,8 @@ mod tests { fn begin_settle_input_parses_transfers() { let sysvar = Address::new_from_array([1u8; 32]); let state = Address::new_from_array([0xa1u8; 32]); - let token_program = Address::new_from_array([0xa2u8; 32]); + let spl_token_program = Address::new_from_array([0xa2u8; 32]); + let token_2022_program = Address::new_from_array([0xa3u8; 32]); let order_pda = Address::new_from_array([2u8; 32]); let sell_token = Address::new_from_array([3u8; 32]); let dest0 = Address::new_from_array([4u8; 32]); @@ -603,7 +687,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(order_pda), fake_account(sell_token), fake_account(dest0), @@ -647,12 +732,13 @@ mod tests { expected.push((order_pda, sell_token)); } - // The three fixed accounts (`[0xff..]`, `[0xfe..]`, `[0xfd..]`) differ - // from every order/token address above. + // The four fixed accounts (`[0xff..]` down to `[0xfc..]`) 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]), ]; 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 5ef15d8..9e101fb 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 @@ -79,8 +79,12 @@ pub fn finalize_push_amounts( /// 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: each push is issued against the program that +/// owns the accounts it moves between, and a program this settlement doesn't +/// touch is left out with the system program. /// /// `FinalizeSettle` validates that each source is the canonical buffer for its /// destination's mint and executes the transfers; the order correspondence and @@ -92,6 +96,9 @@ pub struct FinalizeSettle<'a> { pub program_id: Pubkey, pub state_pda: Pubkey, pub begin_ix_index: u16, + /// The token programs this settlement's pushes are issued against, one + /// account each. Every account a push touches must be owned by one of them. + pub token_programs: TokenPrograms, pub source_buffers: &'a [Pubkey], pub destinations: &'a [Pubkey], pub bumps: &'a [u8], @@ -104,6 +111,7 @@ impl From> for Instruction { program_id, state_pda, begin_ix_index, + token_programs, source_buffers, destinations, bumps, @@ -119,8 +127,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(|program| AccountMeta::new_readonly(program, false)), + ); for (source, destination) in source_buffers.iter().zip(destinations) { accounts.push(AccountMeta::new(*source, false)); accounts.push(AccountMeta::new(*destination, false)); @@ -200,7 +212,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 system + /// program where this settlement moves no legacy token. + 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>, } @@ -213,7 +229,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); @@ -235,7 +251,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, @@ -252,6 +269,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, SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, + }; use crate::instruction::tests::assert_readonly_nonsigner; use hex_literal::hex; use proptest::prelude::*; @@ -266,6 +286,7 @@ mod tests { program_id: Pubkey::new_unique(), state_pda: Pubkey::new_unique(), begin_ix_index: 0, + token_programs: TokenPrograms::SPL_TOKEN, source_buffers: &[], destinations: &[], bumps: &[], @@ -286,6 +307,7 @@ mod tests { program_id, state_pda, begin_ix_index: 0x1337, + token_programs: TokenPrograms::SPL_TOKEN, source_buffers: &[], destinations: &[], bumps: &[], @@ -300,14 +322,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 four 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] @@ -323,6 +376,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], @@ -348,6 +402,7 @@ mod tests { INSTRUCTIONS_SYSVAR_ID, state_pda, SPL_TOKEN_PROGRAM_ID, + TOKEN_2022_PROGRAM_ID, source_a, dest_a, source_b, @@ -369,13 +424,16 @@ mod tests { #[test] fn finalize_settle_input_parses_no_pushes() { let sysvar = Address::new_from_array([0x42u8; 32]); - // The state-PDA and token-program slots are reserved but not surfaced. + // Parsing is positional and validates nothing, so the state-PDA and + // token-program slots are stand-ins here. let state = Address::new_from_array([0x43u8; 32]); - let token_program = Address::new_from_array([0x44u8; 32]); + let spl_token_program = Address::new_from_array([0x44u8; 32]); + let token_2022_program = Address::new_from_array([0x45u8; 32]); 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()], @@ -385,13 +443,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); } @@ -399,7 +459,8 @@ mod tests { fn finalize_settle_input_parses_pushes() { let sysvar = Address::new_from_array([1u8; 32]); let state = Address::new_from_array([0xa1u8; 32]); - let token_program = Address::new_from_array([0xa2u8; 32]); + let spl_token_program = Address::new_from_array([0xa2u8; 32]); + let token_2022_program = Address::new_from_array([0xa3u8; 32]); // The same source buffer funds both pushes: parsing makes no uniqueness // assumption about source buffers. let source = Address::new_from_array([3u8; 32]); @@ -408,7 +469,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), @@ -469,12 +531,13 @@ mod tests { }); } - // The three fixed accounts (`[0xff..]`, `[0xfe..]`, `[0xfd..]`) differ - // from every source/destination address above. + // The four 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(); @@ -529,6 +592,22 @@ mod tests { ); } + /// One account short of the fixed block: the token-program slots are read + /// positionally, so a settlement that omits one has no push accounts to + /// parse from either. + #[test] + fn finalize_settle_input_rejects_a_missing_token_program_slot() { + let accounts = fake_sequential_accounts::<{ FINALIZE_FIXED_ACCOUNTS - 1 }>(); + let data = ix_data![ + [SettlementInstruction::FinalizeSettle.discriminator()], + [0, 0], // begin index + ]; + assert_eq!( + FinalizeSettleInput::parse(&data, &accounts).err(), + Some(ProgramError::NotEnoughAccountKeys), + ); + } + #[test] fn finalize_settle_input_rejects_account_count_mismatch() { // One push (a bump byte then a `u64` amount) needs exactly two push @@ -579,6 +658,7 @@ mod tests { program_id: Pubkey::new_unique(), state_pda: Pubkey::new_unique(), begin_ix_index: 0x1337, + token_programs: TokenPrograms::SPL_TOKEN, source_buffers: &[Pubkey::new_unique(), Pubkey::new_unique()], destinations: &[Pubkey::new_unique(), Pubkey::new_unique()], bumps: &[0xa1, 0xb1], @@ -596,6 +676,7 @@ mod tests { program_id: Pubkey::new_unique(), state_pda: Pubkey::new_unique(), begin_ix_index: 0, + token_programs: TokenPrograms::SPL_TOKEN, source_buffers: &[], destinations: &[], bumps: &[], @@ -611,6 +692,7 @@ mod tests { program_id: Pubkey::new_unique(), state_pda: Pubkey::new_unique(), begin_ix_index: 0, + token_programs: TokenPrograms::SPL_TOKEN, source_buffers: &[Pubkey::new_unique()], destinations: &[Pubkey::new_unique()], bumps: &[0xff], @@ -645,6 +727,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 a8450ee..bac0c5d 100644 --- a/interface/src/instruction/settle/mod.rs +++ b/interface/src/instruction/settle/mod.rs @@ -3,7 +3,9 @@ use solana_program_error::ProgramError; -pub use crate::token_program::SPL_TOKEN_PROGRAM_ID; +pub use crate::token_program::{ + TokenPrograms, SPL_TOKEN_PROGRAM_ID, SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, +}; 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 5348ac5..3b89224 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -225,6 +225,12 @@ pub enum SettlementError { /// buffer. Defensive: a token program that fails this query aborts the /// instruction on its own. BufferSizeUnavailable = 35, + /// `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 = 36, } impl From for u32 { diff --git a/interface/src/token_program.rs b/interface/src/token_program.rs index 498dfc3..b296944 100644 --- a/interface/src/token_program.rs +++ b/interface/src/token_program.rs @@ -1,15 +1,17 @@ -//! The token programs a buffer may be created under. +//! The token programs settlement transfers may be issued against. //! -//! `CreateBuffer` and `ReclaimBuffer` take a single `token_program` account and -//! issue every one of their CPIs against it. That account has to be one of -//! [`SUPPORTED_TOKEN_PROGRAMS`], which is what [`is_supported`] checks. +//! An instruction that moves tokens has to name the program to issue its +//! transfers against, and that program has to be one of +//! [`SUPPORTED_TOKEN_PROGRAMS`], which is what [`is_supported`] checks. How it +//! names them differs by instruction: //! -//! Because the program account is shared by the whole instruction, the mints an -//! instruction touches must all live under the same token program: a legacy SPL -//! mint and a Token-2022 mint can't have their buffers created by one -//! `CreateBuffer`. Splitting them across two is the caller's job. -//! -//! `BeginSettle` and `FinalizeSettle` still accept only the legacy program. +//! - `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; @@ -21,16 +23,89 @@ use crate::Pubkey; pub use spl_token_2022_interface::inline_spl_token::ID as SPL_TOKEN_PROGRAM_ID; /// The SPL Token-2022 program. Its instruction encoding is a superset of the -/// legacy program's, so the instructions this program issues are byte-identical +/// legacy program's, so the transfers this program issues are byte-identical /// either way and only the CPI target changes. pub use spl_token_2022_interface::ID as TOKEN_2022_PROGRAM_ID; -/// Every token program a token-moving instruction accepts, in no particular -/// order. +/// 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; + +/// Every token program a token-moving instruction accepts. The order is the one +/// `BeginSettle` and `FinalizeSettle` lay their token-program accounts out in; +/// see [`TokenPrograms::addresses`]. pub const SUPPORTED_TOKEN_PROGRAMS: [Pubkey; 2] = [SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID]; -/// Whether `address` is a token program buffers may be created under, that is, -/// whether it is one of [`SUPPORTED_TOKEN_PROGRAMS`]. +/// Which of [`SUPPORTED_TOKEN_PROGRAMS`] a `BeginSettle`/`FinalizeSettle` pair +/// carries. +/// +/// Both instructions take one account per supported program, at fixed positions +/// and in [`SUPPORTED_TOKEN_PROGRAMS`] 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 [`SUPPORTED_TOKEN_PROGRAMS`] 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; SUPPORTED_TOKEN_PROGRAMS.len()] { + [ + if self.spl_token { + SPL_TOKEN_PROGRAM_ID + } else { + SYSTEM_PROGRAM_ID + }, + if self.token_2022 { + TOKEN_2022_PROGRAM_ID + } else { + SYSTEM_PROGRAM_ID + }, + ] + } +} + +/// Whether `address` is a token program settlement transfers may be issued +/// against, that is, whether it is one of [`SUPPORTED_TOKEN_PROGRAMS`]. pub fn is_supported(address: &Pubkey) -> bool { SUPPORTED_TOKEN_PROGRAMS.contains(address) } @@ -58,4 +133,48 @@ mod tests { fn supported_programs_are_distinct() { assert_ne!(SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID); } + + /// 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!(!is_supported(&SYSTEM_PROGRAM_ID)); + } + + /// 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() { + assert_eq!( + TokenPrograms::BOTH.addresses(), + [SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID], + ); + assert_eq!( + TokenPrograms::SPL_TOKEN.addresses(), + [SPL_TOKEN_PROGRAM_ID, SYSTEM_PROGRAM_ID], + ); + assert_eq!( + TokenPrograms::TOKEN_2022.addresses(), + [SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID], + ); + assert_eq!( + TokenPrograms::NONE.addresses(), + [SYSTEM_PROGRAM_ID, SYSTEM_PROGRAM_ID], + ); + } + + /// The slots are laid out in [`SUPPORTED_TOKEN_PROGRAMS`] 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(), SUPPORTED_TOKEN_PROGRAMS); + } + + /// 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 e9aad48..059dcbf 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -26,11 +26,14 @@ use pinocchio::{ }, AccountView, Address, ProgramResult, }; -use pinocchio_token::{instructions::Transfer, state::Account as TokenAccount}; +use pinocchio_token::instructions::Transfer; -use crate::processor::{is_cpi_call, with_state_pda_signer}; +use crate::{ + processor::{is_cpi_call, with_state_pda_signer}, + token::{read_token_account, TokenPrograms}, +}; -use super::{validate_counterpart, validate_token_program_account}; +use super::validate_counterpart; pub fn process_begin_settle( program_id: &Address, @@ -69,7 +72,10 @@ pub fn process_begin_settle( let finalize_ix = instructions.load_instruction_at(usize::from(input.finalize_ix_index))?; - validate_token_program_account(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| { settle_orders( @@ -78,6 +84,7 @@ pub fn process_begin_settle( state_pda_signer, &input.orders, &finalize_ix, + &token_programs, ) }) } @@ -179,6 +186,7 @@ fn settle_orders( state_pda_signer: &Signer, orders: &SettledOrders<'_, AccountView>, finalize_ix: &IntrospectedInstruction, + token_programs: &TokenPrograms<'_>, ) -> ProgramResult { // Orders must be passed strictly increasing by address; this rejects // duplicates (settling the same order twice) without a separate scan. @@ -209,6 +217,7 @@ fn settle_orders( now, state_pda_account, state_pda_signer, + token_programs, )?; } @@ -231,6 +240,7 @@ fn process_order( now: i64, state_account: &AccountView, state_pda_signer: &Signer, + token_programs: &TokenPrograms<'_>, ) -> ProgramResult { let SettledOrder { order_pda, @@ -262,17 +272,21 @@ 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 matches that 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. 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()); - } + // `read_token_account` confirms this is a real token account of that token + // program before we read its owner, and reads by value, so nothing is left + // borrowing the account when the transfers below touch it. + let sell_token_owner = read_token_account(token_program, sell_token_account) + .map_err(|_| SettlementError::SellTokenAccountInvalid)? + .owner; + if sell_token_owner != intent.owner { + return Err(SettlementError::SellTokenOwnerMismatch.into()); } // Pull the configured amounts out of the sell token account, summing them @@ -285,7 +299,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, + )?; } validate_limit_price(intent, amount_in, push_amount)?; @@ -377,7 +394,9 @@ mod tests { use cow_settlement_interface::data::intent::fixtures::{arb_order_intent, sample_intent}; 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::*; @@ -978,6 +997,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 416610f..08b7274 100644 --- a/programs/settlement/src/settle/finalize.rs +++ b/programs/settlement/src/settle/finalize.rs @@ -11,11 +11,14 @@ use cow_settlement_interface::{ use pinocchio::{ cpi::Signer, sysvars::instructions::Instructions, AccountView, Address, ProgramResult, }; -use pinocchio_token::{instructions::Transfer, state::Account as TokenAccount}; +use pinocchio_token::instructions::Transfer; -use crate::processor::{is_cpi_call, with_state_pda_signer}; +use crate::{ + processor::{is_cpi_call, with_state_pda_signer}, + token::{read_token_account, TokenPrograms}, +}; -use super::{validate_counterpart, validate_token_program_account}; +use super::validate_counterpart; pub fn process_finalize_settle( program_id: &Address, @@ -44,7 +47,10 @@ pub fn process_finalize_settle( // validated the push count and destinations. `push_funds` adds the only // remaining check: each push draws from the buffer for its mint. - validate_token_program_account(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( @@ -52,6 +58,7 @@ pub fn process_finalize_settle( input.state_pda_account, state_pda_signer, input.pushes, + &token_programs, ) }) } @@ -66,15 +73,21 @@ fn push_funds<'a>( state_pda_account: &AccountView, state_pda_signer: &Signer, pushes: Pushes<'a, AccountView>, + token_programs: &TokenPrograms<'_>, ) -> ProgramResult { for push in pushes.iter() { - // Read the destination's mint; the borrow ends with this block, before - // the transfer reuses the account. - let mint = { - let destination = TokenAccount::from_account_view(push.destination) - .map_err(|_| SettlementError::InvalidBuyTokenAccount)?; - *destination.mint() - }; + // 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, so there is no mint to derive the buffer from. + let token_program = token_programs + .program_for(push.destination)? + .ok_or(SettlementError::InvalidBuyTokenAccount)?; + // The read is by value, so nothing is left borrowing the destination + // when the transfer below reuses it. + let mint = read_token_account(token_program, push.destination) + .map_err(|_| SettlementError::InvalidBuyTokenAccount)? + .mint; validate_buffer_pda(program_id, push.source_buffer, &mint, push.bump)?; Transfer::new( @@ -83,7 +96,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, + )?; } Ok(()) diff --git a/programs/settlement/src/settle/mod.rs b/programs/settlement/src/settle/mod.rs index 25a01d7..f76737a 100644 --- a/programs/settlement/src/settle/mod.rs +++ b/programs/settlement/src/settle/mod.rs @@ -3,12 +3,10 @@ use std::ops::Deref; use cow_settlement_interface::{ - instruction::{create_buffer::SPL_TOKEN_PROGRAM_ID, settle::recover_counterpart}, - recover_discriminator, SettlementError, SettlementInstruction, -}; -use pinocchio::{ - error::ProgramError, sysvars::instructions::Instructions, AccountView, Address, ProgramResult, + instruction::settle::recover_counterpart, recover_discriminator, SettlementError, + SettlementInstruction, }; +use pinocchio::{sysvars::instructions::Instructions, Address, ProgramResult}; mod begin; mod finalize; @@ -44,13 +42,3 @@ fn validate_counterpart>( } Ok(()) } - -/// Validate that `token_program_account` is the legacy SPL Token program, which -/// every settlement transfer is issued against. -#[must_use = "ignoring the output may lead to an unintended on-chain state"] -fn validate_token_program_account(token_program_account: &AccountView) -> ProgramResult { - if token_program_account.address() != &SPL_TOKEN_PROGRAM_ID { - return Err(ProgramError::IncorrectProgramId); - } - Ok(()) -} diff --git a/programs/settlement/src/token.rs b/programs/settlement/src/token.rs index 8ed52bf..59cc462 100644 --- a/programs/settlement/src/token.rs +++ b/programs/settlement/src/token.rs @@ -1,20 +1,27 @@ -//! Token-program validation and token-account reads shared by `CreateBuffer` -//! and `ReclaimBuffer`. +//! Token-program validation and token-account reads shared by every +//! instruction that moves tokens. //! -//! Each takes one `token_program` account, validates it with -//! [`validate_token_program`], and issues all of its CPIs against the address -//! that returns. Token-2022 encodes the instructions this program issues -//! exactly as the legacy program does, so only the CPI target changes; nothing -//! else about them depends on which program it is. +//! Token-2022 encodes the instructions this program issues exactly as the +//! legacy program does, so only the CPI target changes; nothing else about a +//! transfer depends on which program it is. How an instruction names that +//! target depends on how many programs it works with at once: //! -//! What does differ is the account data. A Token-2022 account carrying -//! extensions is longer than the base layout, so the legacy reader (which -//! insists on an exact length and the legacy owner) rejects it. Read token -//! accounts through [`read_token_account`], which dispatches on the validated -//! program. +//! - `CreateBuffer` and `ReclaimBuffer` take one `token_program` account, +//! validate it with [`validate_token_program`], and issue every transfer +//! against the address that returns. +//! - `BeginSettle` and `FinalizeSettle` take one account per supported program, +//! validate the pair with [`TokenPrograms::validate`], and ask it for the +//! program owning each account they touch, so one settlement can move tokens +//! under both. +//! +//! What does differ between the programs is the account data. A Token-2022 +//! account carrying extensions is longer than the base layout, so the legacy +//! reader (which insists on an exact length and the legacy owner) rejects it. +//! Read token accounts through [`read_token_account`], which dispatches on the +//! validated program. use cow_settlement_interface::{ - token_program::{is_supported, SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID}, + token_program::{is_supported, SPL_TOKEN_PROGRAM_ID, SYSTEM_PROGRAM_ID, TOKEN_2022_PROGRAM_ID}, SettlementError, }; use pinocchio::{cpi::get_return_data, error::ProgramError, AccountView, Address}; @@ -24,8 +31,9 @@ use pinocchio_token::{instructions::GetAccountDataSize, state::Mint}; /// every legacy token account and a Token-2022 one carrying no extensions. const BASE_TOKEN_ACCOUNT_LEN: u64 = pinocchio_token::state::Account::LEN as u64; -/// Validate that `token_program_account` is a token program this program may -/// issue CPIs against, returning its address for the instruction to target. +/// Validate that `token_program_account` is a token program settlement +/// transfers may be issued against, returning its address for the instruction's +/// CPIs to target. /// /// This is the single gate in front of every token CPI: the callers pass the /// address it returns to `invoke_*_with_unverified_program`, which skips the @@ -41,6 +49,85 @@ pub fn validate_token_program( Ok(address) } +/// The token programs a `BeginSettle`/`FinalizeSettle` was handed: one slot per +/// entry of `SUPPORTED_TOKEN_PROGRAMS`, holding either that program or the +/// system program standing in for "this settlement moves no token under it". +/// +/// Built by [`TokenPrograms::validate`] and asked, per account, +/// [`which program owns it`](TokenPrograms::program_for). The instruction +/// issues that account's transfers against the answer, which is what lets a +/// single settlement mix tokens from both programs. +pub struct TokenPrograms<'a> { + /// The legacy SPL Token program, or `None` where its slot held the + /// placeholder. + spl_token: Option<&'a Address>, + /// Token-2022, filled the same way. + token_2022: Option<&'a Address>, +} + +impl<'a> TokenPrograms<'a> { + /// Validate a settlement's two token-program slots, in the order the + /// instruction lays them out. + /// + /// Each slot has to hold either the program it stands for or + /// `SYSTEM_PROGRAM_ID`; anything else is a caller mistake rather than an + /// opt-out. The addresses are taken from the accounts themselves so the + /// programs the transfers CPI into are ones the transaction named, which is + /// what makes those CPIs dispatchable at all. + #[must_use = "the returned slots decide which program each transfer targets"] + pub fn validate( + spl_token_account: &'a AccountView, + token_2022_account: &'a AccountView, + ) -> Result { + Ok(Self { + spl_token: validate_slot(spl_token_account, &SPL_TOKEN_PROGRAM_ID)?, + token_2022: validate_slot(token_2022_account, &TOKEN_2022_PROGRAM_ID)?, + }) + } + + /// The token program `account`'s transfers must be issued against: the one + /// that owns it. + /// + /// `None` when `account` isn't owned by a supported token program, which + /// means it is no token account at all and the caller reports it as + /// whatever it failed to be. An account owned by a supported program whose + /// slot held the placeholder is a different matter: the settlement can't + /// reach that program, so it says so with + /// [`SettlementError::TokenProgramNotProvided`] rather than pretending the + /// account is malformed. + pub fn program_for( + &self, + account: &AccountView, + ) -> Result, SettlementError> { + let owner = account.owner(); + let slot = if owner == &SPL_TOKEN_PROGRAM_ID { + self.spl_token + } else if owner == &TOKEN_2022_PROGRAM_ID { + self.token_2022 + } else { + return Ok(None); + }; + slot.map(Some) + .ok_or(SettlementError::TokenProgramNotProvided) + } +} + +/// One [`TokenPrograms`] slot: `program` where the account holds it, `None` +/// where it holds the placeholder instead. +fn validate_slot<'a>( + account: &'a AccountView, + program: &Address, +) -> Result, ProgramError> { + let address = account.address(); + if address == program { + Ok(Some(address)) + } else if address == &SYSTEM_PROGRAM_ID { + Ok(None) + } else { + Err(ProgramError::IncorrectProgramId) + } +} + /// The data length a token account holding `mint` has to be allocated at, /// under `token_program`. /// @@ -107,9 +194,11 @@ pub fn token_account_len(token_program: &Address, mint: &AccountView) -> Result< /// [`read_token_account`]. /// /// Held by value rather than borrowed from the account so the caller can go on -/// to use the same account in a CPI that touches it: a live borrow would make -/// that CPI fail. +/// to use the same account in a transfer: a live borrow would make the CPI +/// fail. pub struct TokenAccount { + pub mint: Address, + pub owner: Address, pub amount: u64, } @@ -131,11 +220,15 @@ pub fn read_token_account( if token_program == &SPL_TOKEN_PROGRAM_ID { let account = pinocchio_token::state::Account::from_account_view(account)?; Ok(TokenAccount { + mint: *account.mint(), + owner: *account.owner(), amount: account.amount(), }) } else if token_program == &TOKEN_2022_PROGRAM_ID { let account = pinocchio_token_2022::state::Account::from_account_view(account)?; Ok(TokenAccount { + mint: *account.mint(), + owner: *account.owner(), amount: account.amount(), }) } else { @@ -236,6 +329,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 SUPPORTED_TOKEN_PROGRAMS { @@ -264,6 +474,8 @@ mod tests { fake_account_owned_by(UNRELATED, program, &base_layout(mint, owner, 4_200)); let read = read_token_account(&program, &account) .unwrap_or_else(|error| panic!("{program} account should read: {error:?}")); + assert_eq!(read.mint, mint); + assert_eq!(read.owner, owner); assert_eq!(read.amount, 4_200); } } @@ -284,6 +496,8 @@ mod tests { let account = fake_account_owned_by(UNRELATED, TOKEN_2022_PROGRAM_ID, &data); let read = read_token_account(&TOKEN_2022_PROGRAM_ID, &account) .expect("an extended Token-2022 account should read"); + assert_eq!(read.mint, mint); + assert_eq!(read.owner, owner); assert_eq!(read.amount, 7); } diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index 77736d3..200aec2 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::{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; @@ -129,11 +129,13 @@ fn settle_and_pay_amounts( program_id: *program_id, 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()] @@ -239,6 +241,7 @@ fn rejects_fabricated_program_owned_account() { state_pda: find_state_pda(&program_id).0, 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), @@ -249,6 +252,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], @@ -278,6 +282,7 @@ fn rejects_non_order_account_in_order_slot() { state_pda: find_state_pda(&program_id).0, 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), @@ -287,6 +292,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], @@ -434,7 +440,7 @@ 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 `[instructions_sysvar, state_pda, - // token_program, (order_pda, sell_token_account)...]`. The finalize's push + // 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 = [ @@ -457,8 +463,12 @@ fn rejects_orders_in_wrong_address_order() { let mut accounts = vec![ 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, sell_token_account, _) in orders { accounts.push(AccountMeta::new_readonly(order_pda, false)); accounts.push(AccountMeta::new(sell_token_account, false)); @@ -481,6 +491,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, @@ -1016,11 +1027,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. - // Accounts: `[sysvar, state, token_program, source, destination]`. - let destination_index = 4; + // 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, &orders, finalize); @@ -1044,6 +1056,7 @@ fn rejects_fewer_pushes_than_orders() { let finalize = FinalizeSettle { program_id, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], }; @@ -1064,6 +1077,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, mint: unique_pubkey(), @@ -1091,6 +1105,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 2dddd41..3180ae3 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 litesvm::LiteSVM; @@ -10,7 +11,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 { @@ -30,11 +31,16 @@ pub fn ensure_buffer_exists( 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(), 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 26dbf7b..08a1d76 100644 --- a/programs/settlement/tests/common/settlement.rs +++ b/programs/settlement/tests/common/settlement.rs @@ -1,6 +1,8 @@ //! Scaffolding for building `[BeginSettle, FinalizeSettle]` settlement pairs. -use cow_settlement_client::instructions::{BeginSettle, FinalizedIntent, InitializedIntent}; +use cow_settlement_client::instructions::{ + BeginSettle, FinalizedIntent, InitializedIntent, TokenPrograms, +}; use cow_settlement_interface::Instruction; use solana_sdk::pubkey::Pubkey; @@ -30,6 +32,7 @@ pub fn build_settlement( program_id: *program_id, finalize_ix_index: FINALIZE_INDEX.into(), auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &begin_orders, }; vec![begin.into(), finalize.into()] diff --git a/programs/settlement/tests/common/token.rs b/programs/settlement/tests/common/token.rs index 4a21e81..38f78a3 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,14 +25,65 @@ 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()` /// internally and offers no way to supply one. A mint address is a seed of its /// buffer PDA, so a random one makes buffer bumps — and the compute cost of /// deriving them — vary between runs. See [`super::unique_pubkey`]. -pub fn create_mint(svm: &mut LiteSVM, payer: &Keypair) -> Pubkey { +pub fn create_mint_under(svm: &mut LiteSVM, payer: &Keypair, token_program: &Pubkey) -> Pubkey { /// `litesvm_token::CreateMint`'s default, kept so the two agree. const DECIMALS: u8 = 8; @@ -33,37 +93,56 @@ pub fn create_mint(svm: &mut LiteSVM, payer: &Keypair) -> Pubkey { &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"); - let tx = Transaction::new_signed_with_payer( - &[create, initialize], - Some(&payer.pubkey()), - &[payer, &mint], - svm.latest_blockhash(), + // A mint with no extension data, which is every legacy mint and the shape a + // Token-2022 mint takes when nothing asks for more. That is what keeps a + // buffer for it at the base layout under either program. + let initialize = under( + initialize_mint2(&TOKEN_ID, &mint.pubkey(), &payer.pubkey(), None, DECIMALS) + .expect("initialize_mint2 should build"), + token_program, ); - 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 @@ -90,9 +169,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 @@ -118,9 +201,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 @@ -143,7 +230,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 c367054..6365374 100644 --- a/programs/settlement/tests/finalize_settle_pushes.rs +++ b/programs/settlement/tests/finalize_settle_pushes.rs @@ -18,10 +18,11 @@ use crate::common::{ setup, to_instruction_error, token, unique_pubkey, }; use cow_settlement_client::cow_settlement_interface::{ - instruction::settle::SPL_TOKEN_PROGRAM_ID, pda::state::find_state_pda, Instruction, - SettlementError, + instruction::settle::{FINALIZE_FIXED_ACCOUNTS, 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 solana_sdk::{ instruction::InstructionError, program_error::ProgramError, pubkey::Pubkey, signature::Signer, transaction::TransactionError, @@ -44,6 +45,7 @@ fn finalize(program_id: &Pubkey, orders: &[FinalizedIntent]) -> Vec let finalize = FinalizeSettle { program_id: *program_id, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::SPL_TOKEN, orders, }; build_settlement(program_id, orders, finalize) @@ -210,14 +212,15 @@ fn rejects_push_from_substituted_source() { let mut finalize = Instruction::from(FinalizeSettle { program_id, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::SPL_TOKEN, orders: &orders, }); // Point the push at an account that isn't the canonical buffer, leaving the - // rest well-formed. Accounts: `[sysvar, state, token_program, source, + // rest well-formed. Accounts: `[FINALIZE_FIXED_ACCOUNTS..., source, // destination]`. `BeginSettle` doesn't validate the source, so it passes; // `FinalizeSettle` re-derives the buffer from the destination's mint and // rejects the mismatch before touching the substituted account. - let source_index = 3; + let source_index = FINALIZE_FIXED_ACCOUNTS; finalize.accounts[source_index].pubkey = unique_pubkey(); let instructions = build_settlement(&program_id, &orders, finalize); @@ -288,6 +291,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 @@ -313,6 +317,7 @@ 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 @@ -354,6 +359,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 @@ -446,6 +452,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 bce11ff..2d95765 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}, @@ -39,12 +39,14 @@ fn run_sequence( program_id: *program_id, 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(), @@ -189,6 +191,7 @@ fn rejects_non_instructions_sysvar_account_at_position_zero() { program_id, finalize_ix_index: 1, auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], } .into(); @@ -196,6 +199,7 @@ fn rejects_non_instructions_sysvar_account_at_position_zero() { let finalize = FinalizeSettle { program_id, begin_ix_index: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], }; @@ -226,6 +230,7 @@ fn rejects_counterpart_instruction_in_different_program() { program_id, 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 @@ -234,6 +239,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: &[], }; @@ -287,6 +293,7 @@ fn rejects_cpi_call_to_begin_settle() { program_id: settlement_id, finalize_ix_index: 1, auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], }, ); @@ -318,6 +325,7 @@ fn rejects_cpi_call_to_finalize_settle() { FinalizeSettle { program_id: settlement_id, begin_ix_index: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], }, ); @@ -350,6 +358,7 @@ fn rejects_counterpart_with_unrecoverable_discriminator() { program_id, finalize_ix_index: 1, auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &[], }; // Uses the settlement program, but no data: `recover_discriminator` fails @@ -391,6 +400,7 @@ fn rejects_counterpart_with_unrecoverable_counterpart_index() { program_id, 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 9c2ed66..ac47e6d 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, @@ -33,12 +33,14 @@ fn program_can_be_invoked() { program_id, 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_limit_prices.rs b/programs/settlement/tests/settle_limit_prices.rs index 894e64f..df3f1cd 100644 --- a/programs/settlement/tests/settle_limit_prices.rs +++ b/programs/settlement/tests/settle_limit_prices.rs @@ -19,7 +19,7 @@ use cow_settlement_client::cow_settlement_interface::{ SettlementError, }; use cow_settlement_client::instructions::{ - BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, + BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, TokenPrograms, }; use litesvm::LiteSVM; use solana_sdk::{ @@ -109,11 +109,13 @@ fn settle_all( program_id: *program_id, finalize_ix_index: FINALIZE_INDEX.into(), auction_id: 0, + token_programs: TokenPrograms::SPL_TOKEN, orders: &initialized, }; let finalize = FinalizeSettle { program_id: *program_id, begin_ix_index: BEGIN_INDEX.into(), + token_programs: TokenPrograms::SPL_TOKEN, orders: &finalized, }; let tx = Transaction::new_signed_with_payer( 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 17762b1..88fa0aa 100644 --- a/test-cli/src/cmd/settle.rs +++ b/test-cli/src/cmd/settle.rs @@ -8,6 +8,7 @@ use cow_settlement_client::{ }, instructions::{ BeginSettle, CreateBuffers, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, + TokenPrograms, }, }; use solana_hash::Hash; @@ -111,6 +112,9 @@ pub fn run(ctx: Context, args: SettleArgs) -> anyhow::Result<()> { let begin_ix = BeginSettle { program_id: ctx.program_id, 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, }; @@ -128,6 +132,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, };