From 57e7c784f23a935477e6f72a3de9c3a672cfc8eb Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:10:57 +0900 Subject: [PATCH 1/2] Support Token-2022 in `BeginSettle` and `FinalizeSettle` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BeginSettle` and `FinalizeSettle` each took a `token_program` account and rejected anything that wasn't the legacy SPL Token program, so the buffers `CreateBuffer` can now open under Token-2022 had no way to be settled. They accept it too, and issue every transfer against whichever of the two they were handed, through the same `token::validate_token_program` gate the buffer instructions use. Token-2022 encodes `Transfer` exactly as the legacy program does, so only the CPI target changes. What differs is the account data: a Token-2022 account carrying extensions is longer than the base layout, and the legacy reader insists on an exact length. Both sides now read through `token::read_token_account`, which dispatches on the validated program — the sell account's owner in `BeginSettle`, the destination's mint in `FinalizeSettle`. That reader grows the `mint` and `owner` fields the settlement needs and the buffer instructions didn't, which costs `ReclaimBuffer` a little: its `max_buffers_in_one_instruction` goes 137,046 -> 138,392 CU for the wider read. The settle benchmarks rise 0.3-1.6% from the added dispatch. The `token_program` account is shared by the whole instruction, so every token one settlement touches must live under the same program; a mixed settlement still needs two instruction pairs. Co-Authored-By: Claude Opus 5 (1M context) --- bench-report.json | 34 +++++++++---------- interface/src/token_program.rs | 23 ++++++------- programs/settlement/src/settle/begin.rs | 39 +++++++++++++--------- programs/settlement/src/settle/finalize.rs | 30 ++++++++++------- programs/settlement/src/settle/mod.rs | 18 ++-------- programs/settlement/src/token.rs | 33 ++++++++++++------ 6 files changed, 95 insertions(+), 82 deletions(-) diff --git a/bench-report.json b/bench-report.json index 3fe35f5..9889d2e 100644 --- a/bench-report.json +++ b/bench-report.json @@ -24,25 +24,25 @@ "transfer_authority/reclaim_authority_can_transfer_itself": 4 }, "compute_units": { - "create_buffers/happy_path_creates_initialized_buffer_token_account": 10361, - "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21790, - "create_buffers/max_buffers_in_one_instruction": 177519, - "create_order/happy_path_creates_order_pda_with_expected_body": 7914, + "create_buffers/happy_path_creates_initialized_buffer_token_account": 10360, + "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21789, + "create_buffers/max_buffers_in_one_instruction": 177518, + "create_order/happy_path_creates_order_pda_with_expected_body": 7916, "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": 6363, + "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7511, + "reclaim_buffer/max_buffers_in_one_instruction": 138392, + "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18170, "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": 7049, + "settle/pulls_from_multiple_orders": 20034, + "settle/pulls_funds_to_destination": 13564, + "settle/pulls_to_multiple_destinations": 14712, + "settle/pushes_a_single_order": 12410, + "settle/pushes_several_orders_from_different_buffers": 17727, + "settle/pushes_several_orders_from_one_buffer": 17728, + "settle/settles_a_single_order": 12428, + "settle/settles_multiple_orders": 23088, "transfer_authority/manager_can_transfer_manager": 3163, "transfer_authority/manager_can_transfer_reclaim_authority": 3165, "transfer_authority/reclaim_authority_can_transfer_itself": 3168 diff --git a/interface/src/token_program.rs b/interface/src/token_program.rs index 498dfc3..7aa35f5 100644 --- a/interface/src/token_program.rs +++ b/interface/src/token_program.rs @@ -1,15 +1,14 @@ -//! 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 +//! Every instruction that moves tokens takes a single `token_program` account +//! and issues all of its transfers against it. That account has to be one of //! [`SUPPORTED_TOKEN_PROGRAMS`], which is what [`is_supported`] checks. //! -//! 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. +//! Because the program account is shared by the whole instruction, the tokens +//! an instruction touches must all live under the same token program: a mint +//! registered with SPL Token can't be settled by the same `BeginSettle` / +//! `FinalizeSettle` pair as a Token-2022 mint. Splitting them across two pairs +//! is the caller's job. use crate::Pubkey; @@ -21,7 +20,7 @@ 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; @@ -29,8 +28,8 @@ pub use spl_token_2022_interface::ID as TOKEN_2022_PROGRAM_ID; /// order. 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`]. +/// 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) } diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index e9aad48..7167cc8 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, validate_token_program}, +}; -use super::{validate_counterpart, validate_token_program_account}; +use super::validate_counterpart; pub fn process_begin_settle( program_id: &Address, @@ -69,7 +72,7 @@ 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_program = validate_token_program(input.token_program_account)?; with_state_pda_signer(program_id, input.state_pda_account, |state_pda_signer| { settle_orders( @@ -78,6 +81,7 @@ pub fn process_begin_settle( state_pda_signer, &input.orders, &finalize_ix, + token_program, ) }) } @@ -179,6 +183,7 @@ fn settle_orders( state_pda_signer: &Signer, orders: &SettledOrders<'_, AccountView>, finalize_ix: &IntrospectedInstruction, + token_program: &Address, ) -> ProgramResult { // Orders must be passed strictly increasing by address; this rejects // duplicates (settling the same order twice) without a separate scan. @@ -209,6 +214,7 @@ fn settle_orders( now, state_pda_account, state_pda_signer, + token_program, )?; } @@ -231,6 +237,7 @@ fn process_order( now: i64, state_account: &AccountView, state_pda_signer: &Signer, + token_program: &Address, ) -> ProgramResult { let SettledOrder { order_pda, @@ -263,16 +270,15 @@ fn process_order( return Err(SettlementError::SellTokenAccountMismatch.into()); } // 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 the + // instruction's 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 +291,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)?; diff --git a/programs/settlement/src/settle/finalize.rs b/programs/settlement/src/settle/finalize.rs index 416610f..078606f 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, validate_token_program}, +}; -use super::{validate_counterpart, validate_token_program_account}; +use super::validate_counterpart; pub fn process_finalize_settle( program_id: &Address, @@ -44,7 +47,7 @@ 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_program = validate_token_program(input.token_program_account)?; with_state_pda_signer(program_id, input.state_pda_account, |state_pda_signer| { push_funds( @@ -52,6 +55,7 @@ pub fn process_finalize_settle( input.state_pda_account, state_pda_signer, input.pushes, + token_program, ) }) } @@ -66,15 +70,14 @@ fn push_funds<'a>( state_pda_account: &AccountView, state_pda_signer: &Signer, pushes: Pushes<'a, AccountView>, + token_program: &Address, ) -> 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 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 +86,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..5ed01e1 100644 --- a/programs/settlement/src/token.rs +++ b/programs/settlement/src/token.rs @@ -1,11 +1,11 @@ -//! 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. +//! Each such instruction takes one `token_program` account, validates it with +//! [`validate_token_program`], and issues all of its transfers 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 a transfer depends on which program it is. //! //! What does differ is the account data. A Token-2022 account carrying //! extensions is longer than the base layout, so the legacy reader (which @@ -24,8 +24,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 @@ -107,9 +108,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 +134,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 { @@ -264,6 +271,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 +293,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); } From 35bb3fec8351904ff2f603d72520d0ff550a247a Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:03:33 +0900 Subject: [PATCH 2/2] Carry both token programs in a settlement pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BeginSettle` and `FinalizeSettle` took one `token_program` account, so every token an instruction touched had to live under the same program: settling a legacy SPL mint and a Token-2022 mint meant two instruction pairs. They now take one account per supported program, at fixed positions after the state PDA, and issue each transfer against the program that owns the account it moves. One pair can settle both, and the two sides of a single order need not agree — the pull follows the sell account's owner, the push the buy account's. A program the settlement doesn't touch is left out by putting the system program in its slot. The transfers still need their program named by the transaction, so the placeholder is how an instruction says this one isn't; in a transaction that already references the system program it costs an account index instead of another 32-byte address. Nothing new goes into the instruction data: the owner is the authority on which program an account belongs to, and the slots only decide whether the settlement can reach it. Resolving an account gives one of three answers: - owned by a carried program: its transfers CPI into that program; - owned by a supported program whose slot holds the placeholder: the new `SettlementError::TokenProgramNotProvided` (36), so a forgotten slot reads as itself rather than as a malformed account; - owned by neither: the existing `SellTokenAccountInvalid` / `InvalidBuyTokenAccount`, unchanged. The slots are positional. Each holds its own program or the placeholder; anything else, swapping the two included, is `IncorrectProgramId`. `FINALIZE_FIXED_ACCOUNTS` becomes 4, which `push_destinations` follows on its own. `CreateBuffer` and `ReclaimBuffer` keep their single `token_program` account: each works on one mint at a time, so there is nothing to mix. The settle benchmarks each gain one account, 34 transaction bytes, and 50-170 CU. The one- and two-unit drift on the unrelated create/reclaim lines is codegen, not behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- bench-report.json | 70 +-- client/src/instructions.rs | 24 +- client/src/parse.rs | 3 + interface/src/instruction/settle/begin.rs | 144 ++++-- interface/src/instruction/settle/finalize.rs | 131 +++++- interface/src/instruction/settle/mod.rs | 4 +- interface/src/lib.rs | 6 + interface/src/token_program.rs | 140 +++++- programs/settlement/src/settle/begin.rs | 33 +- programs/settlement/src/settle/finalize.rs | 18 +- programs/settlement/src/token.rs | 225 ++++++++- .../settlement/tests/begin_settle_orders.rs | 29 +- programs/settlement/tests/common/buffer.rs | 10 +- .../settlement/tests/common/settlement.rs | 5 +- programs/settlement/tests/common/token.rs | 152 +++++-- .../tests/finalize_settle_pushes.rs | 17 +- .../tests/matching_begin_finalize.rs | 12 +- .../settlement/tests/program_deployment.rs | 4 +- .../settlement/tests/settle_limit_prices.rs | 4 +- .../settlement/tests/settle_token_programs.rs | 429 ++++++++++++++++++ test-cli/src/cmd/settle.rs | 5 + 21 files changed, 1287 insertions(+), 178 deletions(-) create mode 100644 programs/settlement/tests/settle_token_programs.rs diff --git a/bench-report.json b/bench-report.json index 9889d2e..8984ae7 100644 --- a/bench-report.json +++ b/bench-report.json @@ -10,39 +10,39 @@ "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 }, "compute_units": { - "create_buffers/happy_path_creates_initialized_buffer_token_account": 10360, - "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21789, - "create_buffers/max_buffers_in_one_instruction": 177518, - "create_order/happy_path_creates_order_pda_with_expected_body": 7916, + "create_buffers/happy_path_creates_initialized_buffer_token_account": 10361, + "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21790, + "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": 6363, - "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7511, - "reclaim_buffer/max_buffers_in_one_instruction": 138392, - "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18170, + "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": 7049, - "settle/pulls_from_multiple_orders": 20034, - "settle/pulls_funds_to_destination": 13564, - "settle/pulls_to_multiple_destinations": 14712, - "settle/pushes_a_single_order": 12410, - "settle/pushes_several_orders_from_different_buffers": 17727, - "settle/pushes_several_orders_from_one_buffer": 17728, - "settle/settles_a_single_order": 12428, - "settle/settles_multiple_orders": 23088, + "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 7aa35f5..b296944 100644 --- a/interface/src/token_program.rs +++ b/interface/src/token_program.rs @@ -1,14 +1,17 @@ //! The token programs settlement transfers may be issued against. //! -//! Every instruction that moves tokens takes a single `token_program` account -//! and issues all of its transfers 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 tokens -//! an instruction touches must all live under the same token program: a mint -//! registered with SPL Token can't be settled by the same `BeginSettle` / -//! `FinalizeSettle` pair as a Token-2022 mint. Splitting them across two pairs -//! is the caller's job. +//! - `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; @@ -24,10 +27,83 @@ pub use spl_token_2022_interface::inline_spl_token::ID as SPL_TOKEN_PROGRAM_ID; /// 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]; +/// 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 { @@ -57,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 7167cc8..059dcbf 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -30,7 +30,7 @@ use pinocchio_token::instructions::Transfer; use crate::{ processor::{is_cpi_call, with_state_pda_signer}, - token::{read_token_account, validate_token_program}, + token::{read_token_account, TokenPrograms}, }; use super::validate_counterpart; @@ -72,7 +72,10 @@ pub fn process_begin_settle( let finalize_ix = instructions.load_instruction_at(usize::from(input.finalize_ix_index))?; - let token_program = validate_token_program(input.token_program_account)?; + let token_programs = TokenPrograms::validate( + input.spl_token_program_account, + input.token_2022_program_account, + )?; with_state_pda_signer(program_id, input.state_pda_account, |state_pda_signer| { settle_orders( @@ -81,7 +84,7 @@ pub fn process_begin_settle( state_pda_signer, &input.orders, &finalize_ix, - token_program, + &token_programs, ) }) } @@ -183,7 +186,7 @@ fn settle_orders( state_pda_signer: &Signer, orders: &SettledOrders<'_, AccountView>, finalize_ix: &IntrospectedInstruction, - token_program: &Address, + token_programs: &TokenPrograms<'_>, ) -> ProgramResult { // Orders must be passed strictly increasing by address; this rejects // duplicates (settling the same order twice) without a separate scan. @@ -214,7 +217,7 @@ fn settle_orders( now, state_pda_account, state_pda_signer, - token_program, + token_programs, )?; } @@ -237,7 +240,7 @@ fn process_order( now: i64, state_account: &AccountView, state_pda_signer: &Signer, - token_program: &Address, + token_programs: &TokenPrograms<'_>, ) -> ProgramResult { let SettledOrder { order_pda, @@ -269,11 +272,16 @@ 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. - // `read_token_account` confirms this is a real token account of the - // instruction's token program before we read its owner, and reads by value, - // so nothing is left borrowing the account when the transfers below touch - // it. + // `read_token_account` confirms this is a real token account of that token + // program before we read its owner, and 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; @@ -386,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::*; @@ -987,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 078606f..08b7274 100644 --- a/programs/settlement/src/settle/finalize.rs +++ b/programs/settlement/src/settle/finalize.rs @@ -15,7 +15,7 @@ use pinocchio_token::instructions::Transfer; use crate::{ processor::{is_cpi_call, with_state_pda_signer}, - token::{read_token_account, validate_token_program}, + token::{read_token_account, TokenPrograms}, }; use super::validate_counterpart; @@ -47,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. - let token_program = validate_token_program(input.token_program_account)?; + let token_programs = TokenPrograms::validate( + input.spl_token_program_account, + input.token_2022_program_account, + )?; with_state_pda_signer(program_id, input.state_pda_account, |state_pda_signer| { push_funds( @@ -55,7 +58,7 @@ pub fn process_finalize_settle( input.state_pda_account, state_pda_signer, input.pushes, - token_program, + &token_programs, ) }) } @@ -70,9 +73,16 @@ fn push_funds<'a>( state_pda_account: &AccountView, state_pda_signer: &Signer, pushes: Pushes<'a, AccountView>, - token_program: &Address, + token_programs: &TokenPrograms<'_>, ) -> ProgramResult { for push in pushes.iter() { + // The push moves this destination's tokens, so it is issued against the + // token program that owns it — the one this settlement has to be + // carrying. An account under neither program isn't a token account at + // all, 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) diff --git a/programs/settlement/src/token.rs b/programs/settlement/src/token.rs index 5ed01e1..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 every //! instruction that moves tokens. //! -//! Each such instruction takes one `token_program` account, validates it with -//! [`validate_token_program`], and issues all of its transfers 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 a transfer 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}; @@ -42,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`. /// @@ -243,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 { 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, };