From aa75ae4eecccd797e78c45b27758d5900c1e275a Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:08:57 +0900 Subject: [PATCH 01/27] Support Token-2022 in `CreateBuffer` and `ReclaimBuffer` `CreateBuffer` and `ReclaimBuffer` each took a `token_program` account and rejected anything that wasn't the legacy SPL Token program. They now accept Token-2022 as well and issue all of their CPIs against whichever of the two they were handed, so a buffer can be allocated, initialized and closed under either. Token-2022 encodes the instructions this program issues exactly as the legacy program does, so only the CPI target changes. Two things do differ: - Account data. A Token-2022 account carrying extensions is longer than the base layout, so the legacy reader (exact length, legacy owner) rejects it. `token::read_token_account` dispatches on the validated program and reads by value, which also drops the borrow before `ReclaimBuffer` closes the same account. - Buffer sizing. A buffer now gets the length its mint actually needs: a mint with no extension data keeps the base layout, and anything longer is priced by asking the token program via `GetAccountDataSize`, the way the associated-token-account program does. That keeps the answer authoritative at run time rather than freezing a mint-extension-to-account-extension table into the program. The program account is shared by the whole instruction, so the mints one instruction touches must all live under the same token program; splitting a mixed batch across two instructions is the caller's job. Adds `SettlementError::BufferSizeUnavailable` (35), reachable only defensively: a token program that fails the size query aborts the instruction on its own. `BeginSettle` and `FinalizeSettle` keep rejecting everything but the legacy program; Token-2022 for the settlement pair follows separately. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 136 ++++++++- Cargo.toml | 4 +- bench-report.json | 44 +-- interface/Cargo.toml | 1 + interface/src/instruction/create_buffer.rs | 7 +- interface/src/lib.rs | 6 + interface/src/pda/buffer.rs | 4 +- interface/src/token_program.rs | 57 ++++ programs/settlement/Cargo.toml | 1 + programs/settlement/src/create_buffer.rs | 37 ++- programs/settlement/src/lib.rs | 1 + programs/settlement/src/reclaim_buffer.rs | 26 +- programs/settlement/src/token.rs | 338 +++++++++++++++++++++ 13 files changed, 600 insertions(+), 62 deletions(-) create mode 100644 interface/src/token_program.rs create mode 100644 programs/settlement/src/token.rs diff --git a/Cargo.lock b/Cargo.lock index e72c592..5f41caf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -910,6 +910,7 @@ dependencies = [ "solana-sdk-ids", "solana-sha256-hasher", "solana-system-interface 3.2.0", + "spl-token-2022-interface 3.1.1", "spl-token-interface 3.0.0", ] @@ -2005,6 +2006,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libsecp256k1" version = "0.7.2" @@ -2338,6 +2345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2489,14 +2497,29 @@ dependencies = [ [[package]] name = "pinocchio-token" -version = "0.6.0" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217e3259f93a1520e4692b18653dad4d29af54ffc8b3a09819808be85dada074" +dependencies = [ + "solana-account-view", + "solana-address 2.6.1", + "solana-instruction-view", + "solana-program-error", +] + +[[package]] +name = "pinocchio-token-2022" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "825f59c8348e5c2d3fd56432ef927f5819542b3b05fae4f5b6869801113e775e" +checksum = "3a3c164973916f044bebff3c8cb4467088bafb13cbdb818522b9ca8782940d06" dependencies = [ + "pinocchio-token", "solana-account-view", "solana-address 2.6.1", "solana-instruction-view", + "solana-nullable", "solana-program-error", + "solana-zero-copy", ] [[package]] @@ -3145,6 +3168,7 @@ dependencies = [ "pinocchio", "pinocchio-system", "pinocchio-token", + "pinocchio-token-2022", "proptest", "solana-address-lookup-table-interface", "solana-compute-budget", @@ -3321,10 +3345,10 @@ dependencies = [ "solana-sysvar 4.1.0", "solana-vote-interface", "spl-generic-token", - "spl-token-2022-interface", + "spl-token-2022-interface 2.1.0", "spl-token-group-interface", "spl-token-interface 2.0.0", - "spl-token-metadata-interface", + "spl-token-metadata-interface 0.8.0", "thiserror 2.0.19", "zstd", ] @@ -4156,6 +4180,7 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "889194d8c5faec648f2f6fadddb60566249921ebb074e2707e7095458d5864e2" dependencies = [ + "borsh", "bytemuck", ] @@ -5199,6 +5224,22 @@ dependencies = [ "bytemuck_derive", ] +[[package]] +name = "solana-zk-elgamal-proof-interface" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8da7f01db2148a1dc16261ff1dc6f3930a1e255a33cece4f1b56658694f27f7" +dependencies = [ + "bytemuck", + "bytemuck_derive", + "num-derive", + "num-traits", + "solana-address 2.6.1", + "solana-instruction", + "solana-sdk-ids", + "solana-zk-sdk-pod", +] + [[package]] name = "solana-zk-elgamal-proof-program" version = "4.1.2" @@ -5284,6 +5325,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "solana-zk-sdk-pod" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a800583b7a4cea3e851686af162cc6e4712eef97fa91dfa9aca2459b95c84777" +dependencies = [ + "base64", + "bytemuck", + "bytemuck_derive", + "solana-nullable", + "thiserror 2.0.19", +] + [[package]] name = "solana-zk-token-proof-program" version = "4.1.2" @@ -5427,10 +5481,40 @@ dependencies = [ "solana-sdk-ids", "solana-zk-sdk 4.0.0", "spl-pod", - "spl-token-confidential-transfer-proof-extraction", + "spl-token-confidential-transfer-proof-extraction 0.5.1", "spl-token-confidential-transfer-proof-generation", "spl-token-group-interface", - "spl-token-metadata-interface", + "spl-token-metadata-interface 0.8.0", + "spl-type-length-value", + "thiserror 2.0.19", +] + +[[package]] +name = "spl-token-2022-interface" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "821d96d034ea31c4965d182c742153c491ae0abee531331b55771086c5030d86" +dependencies = [ + "arrayref", + "bytemuck", + "getrandom 0.2.17", + "num-derive", + "num-traits", + "num_enum", + "solana-account-info", + "solana-address 2.6.1", + "solana-instruction", + "solana-nullable", + "solana-program-error", + "solana-program-option", + "solana-program-pack", + "solana-sdk-ids", + "solana-zero-copy", + "solana-zk-elgamal-proof-interface", + "solana-zk-sdk-pod", + "spl-token-confidential-transfer-proof-extraction 0.6.1", + "spl-token-group-interface", + "spl-token-metadata-interface 1.0.1", "spl-type-length-value", "thiserror 2.0.19", ] @@ -5455,6 +5539,26 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "spl-token-confidential-transfer-proof-extraction" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a845ec724e807643a04f1d43439ee3571dd913848112ac76aa90b850eaf7c" +dependencies = [ + "bytemuck", + "solana-account-info", + "solana-address 2.6.1", + "solana-curve25519 4.0.1", + "solana-instruction", + "solana-instructions-sysvar 3.0.1", + "solana-msg", + "solana-program-error", + "solana-sdk-ids", + "solana-zk-elgamal-proof-interface", + "solana-zk-sdk-pod", + "thiserror 2.0.19", +] + [[package]] name = "spl-token-confidential-transfer-proof-generation" version = "0.5.1" @@ -5544,6 +5648,26 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "spl-token-metadata-interface" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3d96f175e7022ff200464dfa75a3708a4e9b70c83c4ecd04fe52ee479f4fef" +dependencies = [ + "borsh", + "num-derive", + "num-traits", + "num_enum", + "solana-address 2.6.1", + "solana-borsh", + "solana-instruction", + "solana-nullable", + "solana-program-error", + "spl-discriminator", + "spl-type-length-value", + "thiserror 2.0.19", +] + [[package]] name = "spl-type-length-value" version = "0.9.1" diff --git a/Cargo.toml b/Cargo.toml index 6e24208..5aeaf74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,8 @@ litesvm-token = "0.15" num_enum = "0.7" pinocchio = "0.11" pinocchio-system = "0.6" -pinocchio-token = "0.6" +pinocchio-token = "0.7" +pinocchio-token-2022 = "0.4" proptest = "1" cow-settlement-client = { path = "client", version = "0.2.0" } cow-settlement-interface = { path = "interface", version = "0.2.0" } @@ -56,6 +57,7 @@ solana-sha256-hasher = { version = "3", features = ["sha2"] } solana-system-interface = "3" spl-associated-token-account-interface = "2" spl-token = "9" +spl-token-2022-interface = "3" spl-token-interface = "3" [workspace.metadata.cli] diff --git a/bench-report.json b/bench-report.json index 2b9053c..3fe35f5 100644 --- a/bench-report.json +++ b/bench-report.json @@ -24,28 +24,28 @@ "transfer_authority/reclaim_authority_can_transfer_itself": 4 }, "compute_units": { - "create_buffers/happy_path_creates_initialized_buffer_token_account": 10340, - "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21731, - "create_buffers/max_buffers_in_one_instruction": 176947, - "create_order/happy_path_creates_order_pda_with_expected_body": 7921, - "initialize/happy_path_initializes_state_pda_with_expected_data": 4526, - "reclaim_buffer/funded_buffer_is_skipped": 6299, - "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7447, - "reclaim_buffer/max_buffers_in_one_instruction": 136501, - "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18043, - "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2133, - "settle/finalizes_with_no_pushes": 7043, - "settle/pulls_from_multiple_orders": 19750, - "settle/pulls_funds_to_destination": 13417, - "settle/pulls_to_multiple_destinations": 14564, - "settle/pushes_a_single_order": 12267, - "settle/pushes_several_orders_from_different_buffers": 17451, - "settle/pushes_several_orders_from_one_buffer": 17452, - "settle/settles_a_single_order": 12285, - "settle/settles_multiple_orders": 22679, - "transfer_authority/manager_can_transfer_manager": 3170, - "transfer_authority/manager_can_transfer_reclaim_authority": 3172, - "transfer_authority/reclaim_authority_can_transfer_itself": 3175 + "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": 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_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, + "transfer_authority/manager_can_transfer_manager": 3163, + "transfer_authority/manager_can_transfer_reclaim_authority": 3165, + "transfer_authority/reclaim_authority_can_transfer_itself": 3168 }, "transaction_bytes": { "create_buffers/happy_path_creates_initialized_buffer_token_account": 303, diff --git a/interface/Cargo.toml b/interface/Cargo.toml index 348ef7b..a922b38 100644 --- a/interface/Cargo.toml +++ b/interface/Cargo.toml @@ -30,6 +30,7 @@ solana-pubkey = { workspace = true, features = ["curve25519"] } solana-sdk-ids.workspace = true solana-sha256-hasher.workspace = true solana-system-interface.workspace = true +spl-token-2022-interface.workspace = true spl-token-interface.workspace = true [dev-dependencies] diff --git a/interface/src/instruction/create_buffer.rs b/interface/src/instruction/create_buffer.rs index 0d3b25e..77d69ce 100644 --- a/interface/src/instruction/create_buffer.rs +++ b/interface/src/instruction/create_buffer.rs @@ -14,9 +14,10 @@ pub use solana_system_interface::program::ID as SYSTEM_PROGRAM_ID; use super::InstructionInputParsing; use crate::SettlementInstruction; -/// The SPL Token program. Buffers are created as token accounts owned by this -/// program. -pub use spl_token_interface::ID as SPL_TOKEN_PROGRAM_ID; +/// The legacy SPL Token program, the default this builder places in the +/// `token_program` account. Buffers are created as token accounts owned by +/// whichever of [`crate::token_program::SUPPORTED_TOKEN_PROGRAMS`] is passed. +pub use crate::token_program::SPL_TOKEN_PROGRAM_ID; /// Builder for a `CreateBuffer` instruction that creates one buffer per /// `(buffer_pda, mint)` pair in `buffers`. diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 0e019aa..5348ac5 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -9,6 +9,7 @@ solana_pubkey::declare_id!("J516Mv7YvvvJyMvNEca8tWNTJyDHbFpzwDZD96BNfR3w"); pub mod data; pub mod instruction; pub mod pda; +pub mod token_program; #[derive(Clone, Copy, Debug, Eq, PartialEq, num_enum::TryFromPrimitive)] #[repr(u8)] @@ -219,6 +220,11 @@ pub enum SettlementError { /// `TransferAuthority`'s signer is neither the manager nor the current /// holder of the role being transferred, so it may not transfer it. UnauthorizedAuthorityTransfer = 34, + /// `CreateBuffer` asked the token program how long a token account for a + /// mint has to be and couldn't read the answer, so it can't size the + /// buffer. Defensive: a token program that fails this query aborts the + /// instruction on its own. + BufferSizeUnavailable = 35, } impl From for u32 { diff --git a/interface/src/pda/buffer.rs b/interface/src/pda/buffer.rs index 8345f7d..143b2cb 100644 --- a/interface/src/pda/buffer.rs +++ b/interface/src/pda/buffer.rs @@ -16,8 +16,8 @@ //! contents are stranded. //! //! Unlike the order PDA, which stores its own bump (see -//! [`crate::data::order::OrderAccount`]), a buffer is a fixed-size SPL token -//! account with no room for one. +//! [`crate::data::order::OrderAccount`]), a buffer's layout belongs entirely to +//! the token program, leaving no room for one. use solana_account_view::AccountView; use solana_address::Address; diff --git a/interface/src/token_program.rs b/interface/src/token_program.rs new file mode 100644 index 0000000..5a72ada --- /dev/null +++ b/interface/src/token_program.rs @@ -0,0 +1,57 @@ +//! The token programs a buffer may be created under. +//! +//! `CreateBuffer` and `ReclaimBuffer` take a single `token_program` account and +//! issue every one of their CPIs against it. That account has to be one of +//! [`SUPPORTED_TOKEN_PROGRAMS`], which is what [`is_supported`] checks. +//! +//! 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. + +use crate::Pubkey; + +/// The legacy SPL Token program. +pub use spl_token_interface::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 +/// 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. +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`]. +pub fn is_supported(address: &Pubkey) -> bool { + SUPPORTED_TOKEN_PROGRAMS.contains(address) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fixtures::pubkey_from_seed; + + #[test] + fn supported_programs_are_supported() { + for program in SUPPORTED_TOKEN_PROGRAMS { + assert!(is_supported(&program), "{program} should be supported"); + } + } + + #[test] + fn unrelated_program_is_not_supported() { + assert!(!is_supported(&pubkey_from_seed("not a token program"))); + } + + /// The list is two distinct programs, so it can't have been built from one + /// program repeated. + #[test] + fn supported_programs_are_distinct() { + assert_ne!(SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID); + } +} diff --git a/programs/settlement/Cargo.toml b/programs/settlement/Cargo.toml index 7638cf5..05a0f16 100644 --- a/programs/settlement/Cargo.toml +++ b/programs/settlement/Cargo.toml @@ -21,6 +21,7 @@ crate-type = ["cdylib", "lib"] pinocchio = { workspace = true, features = ["cpi"] } pinocchio-system.workspace = true pinocchio-token.workspace = true +pinocchio-token-2022.workspace = true cow-settlement-interface.workspace = true solana-instruction = { workspace = true, features = ["syscalls"] } diff --git a/programs/settlement/src/create_buffer.rs b/programs/settlement/src/create_buffer.rs index c1a59ae..653fb40 100644 --- a/programs/settlement/src/create_buffer.rs +++ b/programs/settlement/src/create_buffer.rs @@ -2,15 +2,18 @@ use cow_settlement_interface::{ instruction::{ - create_buffer::{BufferAccounts, CreateBufferInput, SPL_TOKEN_PROGRAM_ID}, + create_buffer::{BufferAccounts, CreateBufferInput}, InstructionInputParsing, }, pda::{buffer::buffer_pda_seeds, state::state_pda_seeds}, }; -use pinocchio::{error::ProgramError, AccountView, Address, ProgramResult}; -use pinocchio_token::{instructions::InitializeAccount3, state::Account as TokenAccount}; +use pinocchio::{AccountView, Address, ProgramResult}; +use pinocchio_token::instructions::InitializeAccount3; -use crate::processor::CanonicalPda; +use crate::{ + processor::CanonicalPda, + token::{token_account_len, validate_token_program}, +}; pub fn process_create_buffer( program_id: &Address, @@ -19,12 +22,10 @@ pub fn process_create_buffer( ) -> ProgramResult { let input = CreateBufferInput::parse(instruction_data, accounts)?; - // Only the legacy SPL Token program is supported. The InitializeAccount3 - // CPI targets that program unconditionally; reject a mismatching account - // up front so the caller gets a clear error. - if input.token_program.address() != &SPL_TOKEN_PROGRAM_ID { - return Err(ProgramError::IncorrectProgramId); - } + // Every buffer this instruction creates belongs to the one token program + // it was handed, so reject an unsupported one up front rather than at the + // first CPI. + let token_program = validate_token_program(input.token_program)?; // The buffers' token authority is the settlement state PDA, the single // authority over every buffer. Derive it once for all buffers. @@ -34,8 +35,12 @@ pub fn process_create_buffer( // One buffer per token. `CanonicalPda::create_idempotent` derives the // canonical bump and, by signing the allocation with the buffer seeds, // rejects any `buffer_pda` that isn't the canonical address. The buffer - // is a token account, so it's assigned to the SPL Token program rather - // than to the settlement program. + // is a token account, so it's assigned to the token program rather than + // to the settlement program. + // + // The buffer is sized for the mint it will hold: a Token-2022 mint + // whose extensions require matching ones on its token accounts needs + // room for them, and `token_account_len` is what works out how much. // // We don't validate `mint` here. `InitializeAccount3` requires a real, // token-program-owned mint (and special-cases the native mint), so a @@ -45,8 +50,8 @@ pub fn process_create_buffer( program_id, payer: input.payer, pda: buffer_pda, - size: TokenAccount::LEN as u64, - owner: &SPL_TOKEN_PROGRAM_ID, + size: token_account_len(token_program, mint)?, + owner: token_program, seeds: buffer_pda_seeds(mint_key), } .create_idempotent()?; @@ -54,7 +59,8 @@ pub fn process_create_buffer( // An existing buffer is already an initialized token account, so only // initialize a freshly created one. if created { - InitializeAccount3::new(buffer_pda, mint, &state_pda).invoke()?; + InitializeAccount3::new(buffer_pda, mint, &state_pda) + .invoke_with_unverified_program(token_program)?; } } @@ -68,6 +74,7 @@ mod tests { create_buffer_data, NUM_SHARED_ACCOUNTS, }; use cow_settlement_interface::instruction::fixtures::fake_sequential_accounts; + use pinocchio::error::ProgramError; /// Arbitrary placeholder program id. The failure path exercised below /// returns before the program id is used for any syscall. diff --git a/programs/settlement/src/lib.rs b/programs/settlement/src/lib.rs index af2dfa5..6509061 100644 --- a/programs/settlement/src/lib.rs +++ b/programs/settlement/src/lib.rs @@ -7,6 +7,7 @@ mod processor; mod reclaim_buffer; mod reclaim_order; mod settle; +mod token; mod transfer_authority; use cow_settlement_interface::{recover_discriminator, SettlementInstruction}; diff --git a/programs/settlement/src/reclaim_buffer.rs b/programs/settlement/src/reclaim_buffer.rs index 30e57d8..d06216f 100644 --- a/programs/settlement/src/reclaim_buffer.rs +++ b/programs/settlement/src/reclaim_buffer.rs @@ -7,17 +7,17 @@ use cow_settlement_interface::{ data::state::EncodedStateAccount, - instruction::{ - create_buffer::SPL_TOKEN_PROGRAM_ID, reclaim_buffer::ReclaimBufferInput, - InstructionInputParsing, - }, + instruction::{reclaim_buffer::ReclaimBufferInput, InstructionInputParsing}, pda::buffer::find_buffer_pda, Pubkey, Role, SettlementError, }; use pinocchio::{error::ProgramError, AccountView, Address, ProgramResult}; -use pinocchio_token::{instructions::CloseAccount, state::Account as TokenAccount}; +use pinocchio_token::instructions::CloseAccount; -use crate::processor::with_state_pda_signer; +use crate::{ + processor::with_state_pda_signer, + token::{read_token_account, validate_token_program}, +}; pub fn process_reclaim_buffer( program_id: &Address, @@ -32,9 +32,7 @@ pub fn process_reclaim_buffer( buffers, } = ReclaimBufferInput::parse(instruction_data, accounts)?; - if token_program.address() != &SPL_TOKEN_PROGRAM_ID { - return Err(ProgramError::IncorrectProgramId); - } + let token_program = validate_token_program(token_program)?; with_state_pda_signer(program_id, state_pda, |state_signer| { let reclaim_authority_pubkey: Pubkey = { @@ -58,9 +56,7 @@ pub fn process_reclaim_buffer( return Err(SettlementError::ReclaimBufferNotCanonical.into()); } - let amount = TokenAccount::from_account_view(buffer_pda) - .map_err(|_| ProgramError::InvalidAccountData)? - .amount(); + let amount = read_token_account(token_program, buffer_pda)?.amount; // A token account can't be closed while it still holds a balance, and this // instruction has no mandate to move those tokens elsewhere or destroy them. @@ -70,7 +66,10 @@ pub fn process_reclaim_buffer( } CloseAccount::new(buffer_pda, reclaim_recipient, state_pda) - .invoke_signed(core::slice::from_ref(state_signer))?; + .invoke_signed_with_unverified_program( + core::slice::from_ref(state_signer), + token_program, + )?; } Ok(()) @@ -88,6 +87,7 @@ mod tests { reclaim_buffer_data, NUM_SHARED_ACCOUNTS, }; use cow_settlement_interface::pda::state::state_pda_seeds; + use cow_settlement_interface::token_program::SPL_TOKEN_PROGRAM_ID; use litesvm_token::spl_token::state::{Account as SplTokenAccount, AccountState}; use solana_program_pack::Pack; diff --git a/programs/settlement/src/token.rs b/programs/settlement/src/token.rs new file mode 100644 index 0000000..8ed52bf --- /dev/null +++ b/programs/settlement/src/token.rs @@ -0,0 +1,338 @@ +//! Token-program validation and token-account reads shared by `CreateBuffer` +//! and `ReclaimBuffer`. +//! +//! 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. +//! +//! 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. + +use cow_settlement_interface::{ + token_program::{is_supported, SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID}, + SettlementError, +}; +use pinocchio::{cpi::get_return_data, error::ProgramError, AccountView, Address}; +use pinocchio_token::{instructions::GetAccountDataSize, state::Mint}; + +/// The length of a token account holding nothing but the base layout, which is +/// 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. +/// +/// 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 +/// program check that this already made. +#[must_use = "the returned address is the program the transfers must target"] +pub fn validate_token_program( + token_program_account: &AccountView, +) -> Result<&Address, ProgramError> { + let address = token_program_account.address(); + if !is_supported(address) { + return Err(ProgramError::IncorrectProgramId); + } + Ok(address) +} + +/// The data length a token account holding `mint` has to be allocated at, +/// under `token_program`. +/// +/// A mint carrying no extension data needs no extension space on the accounts +/// that hold it, which is the base layout. Every legacy mint is exactly that +/// long, and so is a Token-2022 mint without extensions; a Token-2022 mint that +/// carries any is padded out past a token account's base layout to make room +/// for its account-type marker, so nothing that short can be one. Anything +/// shorter still isn't a mint at all — including the native mint, which the +/// token program recognizes by address without reading an account — and +/// `InitializeAccount3` is what rejects the ones that matter, as it always has. +/// +/// Otherwise the token program is asked, the way the associated-token-account +/// program asks it. That keeps the answer authoritative at run time instead of +/// freezing a mint-extension-to-account-extension table into this program, +/// which would need a redeploy every time Token-2022 grows one. +/// +/// Nothing trusts this length for safety, only for liveness: too short and +/// `InitializeAccount3` rejects the account, reverting the whole instruction; +/// too long and the only cost is rent, paid by this instruction's own payer and +/// recovered when the buffer is reclaimed. +// `get_return_data` returns its 1 KiB buffer by value, so keep it in a leaf +// frame of its own rather than the caller's: SBF stack frames are 4 KiB and +// don't grow. +#[inline(never)] +pub fn token_account_len(token_program: &Address, mint: &AccountView) -> Result { + if mint.data_len() <= Mint::LEN { + return Ok(BASE_TOKEN_ACCOUNT_LEN); + } + + // The CPI below targets whatever address it's handed, so re-establish that + // it is a token program at all before handing it the mint. Callers have + // validated it already; this keeps the guarantee local, as + // `read_token_account` does. + if !is_supported(token_program) { + return Err(ProgramError::IncorrectProgramId); + } + + GetAccountDataSize::new(mint).invoke_with_unverified_program(token_program)?; + + // The token program reports the length as return data. That buffer is a + // per-transaction global, so it's the program that last set it which makes + // the value trustworthy. Everything below is defensive: a token program + // that can't answer this query fails the CPI, which aborts the instruction + // without returning here at all. + let reported = get_return_data().ok_or(SettlementError::BufferSizeUnavailable)?; + if reported.program_id() != token_program { + return Err(SettlementError::BufferSizeUnavailable.into()); + } + let length: [u8; 8] = reported + .as_slice() + .try_into() + .map_err(|_| SettlementError::BufferSizeUnavailable)?; + let length = u64::from_le_bytes(length); + // A token account is at least its base layout, whatever its mint carries. + if length < BASE_TOKEN_ACCOUNT_LEN { + return Err(SettlementError::BufferSizeUnavailable.into()); + } + + Ok(length) +} + +/// The base-layout fields of a token account, as read by +/// [`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. +pub struct TokenAccount { + pub amount: u64, +} + +/// Read the base fields of the token account at `account`, which must be owned +/// by `token_program`. +/// +/// `token_program` must have come from [`validate_token_program`]; any other +/// address is rejected. The two programs share the base layout, and differ only +/// in what else may follow it, so which reader applies is decided by the +/// program rather than by the data: +/// +/// - under SPL Token the data is exactly the base layout; +/// - under Token-2022 extensions may follow it, and an account that carries any +/// is recognized by the account-type marker sitting just past the base. +pub fn read_token_account( + token_program: &Address, + account: &AccountView, +) -> Result { + if token_program == &SPL_TOKEN_PROGRAM_ID { + let account = pinocchio_token::state::Account::from_account_view(account)?; + Ok(TokenAccount { + amount: account.amount(), + }) + } else if token_program == &TOKEN_2022_PROGRAM_ID { + let account = pinocchio_token_2022::state::Account::from_account_view(account)?; + Ok(TokenAccount { + amount: account.amount(), + }) + } else { + Err(ProgramError::IncorrectProgramId) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cow_settlement_interface::instruction::fixtures::{fake_account, fake_account_owned_by}; + use cow_settlement_interface::token_program::SUPPORTED_TOKEN_PROGRAMS; + use pinocchio_token_2022::state::AccountType; + + /// An address that is not a token program. + const UNRELATED: Address = Address::new_from_array([0x99; 32]); + + /// The length of a token account holding nothing but the base layout. Both + /// programs share it: it is Token-2022's `BASE_LEN` and the whole of a + /// legacy account. + const BASE_LEN: usize = pinocchio_token::state::Account::LEN; + + /// The base layout of a token account holding `amount` of `mint` for + /// `owner`, with every other field left zeroed. + fn base_layout(mint: Address, owner: Address, amount: u64) -> Vec { + let mut data = vec![0u8; BASE_LEN]; + data[..32].copy_from_slice(mint.as_array()); + data[32..64].copy_from_slice(owner.as_array()); + data[64..72].copy_from_slice(&amount.to_le_bytes()); + data + } + + /// The addresses the off-chain crate offers are the ones the on-chain token + /// crates CPI into. Both sides name the same programs from their own + /// dependency, so this is what keeps them from drifting apart. + #[test] + fn interface_and_pinocchio_agree_on_the_program_ids() { + assert_eq!(SPL_TOKEN_PROGRAM_ID, pinocchio_token::ID); + assert_eq!(TOKEN_2022_PROGRAM_ID, pinocchio_token_2022::ID); + } + + /// The base layout is the same under both programs, so one reader's idea of + /// its length is the other's too. + #[test] + fn both_programs_share_the_base_layout_length() { + assert_eq!(BASE_LEN, pinocchio_token_2022::state::Account::BASE_LEN); + } + + /// A mint carrying no extension data — every legacy mint, and a Token-2022 + /// mint without extensions — needs only a base-layout token account, and + /// that is settled without asking the token program. + #[test] + fn token_account_len_is_the_base_layout_for_a_plain_mint() { + for program in SUPPORTED_TOKEN_PROGRAMS { + let mint = fake_account_owned_by(UNRELATED, program, &[0u8; Mint::LEN]); + assert_eq!( + token_account_len(&program, &mint), + Ok(BASE_TOKEN_ACCOUNT_LEN), + "a base-layout mint should need a base-layout account under {program}", + ); + } + } + + /// An account too short to be a mint at all still gets the base layout, + /// leaving `InitializeAccount3` to reject it — which is also how the native + /// mint works, since the token program knows it by address and litesvm + /// leaves the account itself absent. + #[test] + fn token_account_len_is_the_base_layout_for_a_too_short_account() { + let mint = fake_account(UNRELATED); + assert_eq!( + token_account_len(&SPL_TOKEN_PROGRAM_ID, &mint), + Ok(BASE_TOKEN_ACCOUNT_LEN), + ); + } + + /// A longer mint has to be asked about, and off-chain there is nobody to + /// ask: the CPI is a no-op and no return data comes back. On-chain a token + /// program that can't answer aborts the instruction instead of returning + /// here, so this is the error's only reachable path. + #[test] + fn token_account_len_reports_unavailable_without_an_answer() { + let mint = fake_account_owned_by(UNRELATED, TOKEN_2022_PROGRAM_ID, &[0u8; Mint::LEN + 1]); + assert_eq!( + token_account_len(&TOKEN_2022_PROGRAM_ID, &mint).err(), + Some(SettlementError::BufferSizeUnavailable.into()), + ); + } + + /// The query is a CPI, so an unsupported program is turned away before it + /// is handed the mint. + #[test] + fn token_account_len_rejects_an_unsupported_program() { + let mint = fake_account_owned_by(UNRELATED, UNRELATED, &[0u8; Mint::LEN + 1]); + assert_eq!( + token_account_len(&UNRELATED, &mint).err(), + Some(ProgramError::IncorrectProgramId), + ); + } + + #[test] + fn validate_token_program_accepts_every_supported_program() { + for program in SUPPORTED_TOKEN_PROGRAMS { + let account = fake_account(program); + assert_eq!(validate_token_program(&account), Ok(&program)); + } + } + + #[test] + fn validate_token_program_rejects_unrelated_program() { + let account = fake_account(UNRELATED); + assert_eq!( + validate_token_program(&account), + Err(ProgramError::IncorrectProgramId), + ); + } + + /// A plain account, the only shape the legacy program has and the shape a + /// Token-2022 account without extensions also takes, reads under either. + #[test] + fn read_token_account_reads_a_base_layout_account() { + let mint = Address::new_from_array([0x11; 32]); + let owner = Address::new_from_array([0x22; 32]); + for program in SUPPORTED_TOKEN_PROGRAMS { + let account = + 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.amount, 4_200); + } + } + + /// The point of the Token-2022 reader: an account whose extensions push it + /// past the base layout still reads, where the legacy reader's exact-length + /// check would have rejected it. + #[test] + fn read_token_account_reads_past_token_2022_extensions() { + let mint = Address::new_from_array([0x33; 32]); + let owner = Address::new_from_array([0x44; 32]); + let mut data = base_layout(mint, owner, 7); + // Extensions are preceded by the account-type marker, which is what + // distinguishes a longer account from a mint of the same size. + data.push(AccountType::Account as u8); + data.extend_from_slice(&[0xab; 16]); + + 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.amount, 7); + } + + /// An over-long account marked as a mint rather than a token account is + /// still rejected, so the tolerance for extensions doesn't let a mint be + /// read as if it held a balance. + #[test] + fn read_token_account_rejects_an_extended_mint() { + let mut data = base_layout(UNRELATED, UNRELATED, 7); + data.push(AccountType::Mint as u8); + + let account = fake_account_owned_by(UNRELATED, TOKEN_2022_PROGRAM_ID, &data); + assert_eq!( + read_token_account(&TOKEN_2022_PROGRAM_ID, &account).err(), + Some(ProgramError::InvalidAccountData), + ); + } + + #[test] + fn read_token_account_rejects_unvalidated_program() { + // A well-formed legacy token account, so the rejection can only come + // from the program address. + let account = fake_account_owned_by( + UNRELATED, + SPL_TOKEN_PROGRAM_ID, + &base_layout(UNRELATED, UNRELATED, 0), + ); + assert_eq!( + read_token_account(&UNRELATED, &account).err(), + Some(ProgramError::IncorrectProgramId), + ); + } + + /// Each reader is tied to its own program: an otherwise well-formed account + /// owned by one token program can't be read as if it belonged to the other, + /// which is what stops an instruction from mixing the two. + #[test] + fn read_token_account_rejects_the_other_programs_account() { + for [program, other] in [ + [SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID], + [TOKEN_2022_PROGRAM_ID, SPL_TOKEN_PROGRAM_ID], + ] { + let account = + fake_account_owned_by(UNRELATED, other, &base_layout(UNRELATED, UNRELATED, 0)); + assert_eq!( + read_token_account(&program, &account).err(), + Some(ProgramError::InvalidAccountData), + "an account owned by {other} should not read under {program}", + ); + } + } +} From e499b86cc42dd277da6a0b7e4fc37846b6102b9c Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:23:35 +0900 Subject: [PATCH 02/27] Take the legacy program id from the Token-2022 crate `spl-token-interface` was a dependency of the interface crate for one thing: a 32-byte constant naming a program this code never calls directly. `spl-token-2022-interface` already carries that address in `inline_spl_token`, which exists precisely so a program that has to recognize both doesn't grow a second dependency for the one it only compares against. Both ids now come from there, and the legacy crate leaves the interface crate's dependency graph -- and with it the settlement program's. `instruction::settle` re-exported the same id straight from the legacy crate, so it moves to `token_program` too. That was the second import keeping the dependency alive, not a cosmetic change. The `.so` is byte-identical at 51,056 bytes: the constant was already inlined, so this narrows the dependency graph rather than the program. The program crate keeps `pinocchio-token`. Its state readers are not parameterized over the token program the way 0.7's instruction builders are -- `pinocchio_token_2022::state::Account` hardcodes an owner check against Token-2022 in all of its safe constructors -- so reading a legacy account without it means `from_bytes_unchecked` plus hand-rolled owner and length checks, in exchange for a crate `pinocchio-token-2022` depends on anyway. `test-cli` keeps the legacy crate as well. It talks to no other program, and the two crates' `native_mint` are different addresses, so swapping them there is a change to make deliberately rather than in passing. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 - interface/Cargo.toml | 1 - interface/src/instruction/settle/mod.rs | 2 +- interface/src/token_program.rs | 6 +++++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5f41caf..005e230 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -911,7 +911,6 @@ dependencies = [ "solana-sha256-hasher", "solana-system-interface 3.2.0", "spl-token-2022-interface 3.1.1", - "spl-token-interface 3.0.0", ] [[package]] diff --git a/interface/Cargo.toml b/interface/Cargo.toml index a922b38..da903ed 100644 --- a/interface/Cargo.toml +++ b/interface/Cargo.toml @@ -31,7 +31,6 @@ solana-sdk-ids.workspace = true solana-sha256-hasher.workspace = true solana-system-interface.workspace = true spl-token-2022-interface.workspace = true -spl-token-interface.workspace = true [dev-dependencies] hex-literal.workspace = true diff --git a/interface/src/instruction/settle/mod.rs b/interface/src/instruction/settle/mod.rs index 85ce25e..a8450ee 100644 --- a/interface/src/instruction/settle/mod.rs +++ b/interface/src/instruction/settle/mod.rs @@ -3,8 +3,8 @@ use solana_program_error::ProgramError; +pub use crate::token_program::SPL_TOKEN_PROGRAM_ID; pub use solana_sdk_ids::sysvar::instructions::ID as INSTRUCTIONS_SYSVAR_ID; -pub use spl_token_interface::ID as SPL_TOKEN_PROGRAM_ID; mod begin; mod finalize; diff --git a/interface/src/token_program.rs b/interface/src/token_program.rs index 5a72ada..498dfc3 100644 --- a/interface/src/token_program.rs +++ b/interface/src/token_program.rs @@ -14,7 +14,11 @@ use crate::Pubkey; /// The legacy SPL Token program. -pub use spl_token_interface::ID as SPL_TOKEN_PROGRAM_ID; +/// +/// Taken from the Token-2022 crate, which carries the address precisely so a +/// program that has to recognize both doesn't grow a second dependency for the +/// one it never calls directly. +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 From 881178bd6fc5a2c85b3f858c957fcd1255482dfa Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:47:00 +0900 Subject: [PATCH 03/27] fixes from final manual review --- interface/src/lib.rs | 3 +- interface/src/token_program.rs | 26 +---- programs/settlement/src/create_buffer.rs | 4 - programs/settlement/src/token.rs | 115 +++-------------------- 4 files changed, 15 insertions(+), 133 deletions(-) diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 3e133bd..ca3606b 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -236,8 +236,7 @@ pub enum SettlementError { OrderCreatedOnChainMismatch = 38, /// `CreateBuffer` asked the token program how long a token account for a /// mint has to be and couldn't read the answer, so it can't size the - /// buffer. Defensive: a token program that fails this query aborts the - /// instruction on its own. + /// buffer. BufferSizeUnavailable = 39, } diff --git a/interface/src/token_program.rs b/interface/src/token_program.rs index 498dfc3..1e7ee58 100644 --- a/interface/src/token_program.rs +++ b/interface/src/token_program.rs @@ -1,36 +1,18 @@ -//! The token programs a buffer may be created under. -//! -//! `CreateBuffer` and `ReclaimBuffer` take a single `token_program` account and -//! issue every one of their CPIs against it. That account has to be one of -//! [`SUPPORTED_TOKEN_PROGRAMS`], which is what [`is_supported`] checks. -//! -//! 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. +//! Utilities related to the token programs supported by the settlement program. use crate::Pubkey; /// The legacy SPL Token program. -/// -/// Taken from the Token-2022 crate, which carries the address precisely so a -/// program that has to recognize both doesn't grow a second dependency for the -/// one it never calls directly. 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 -/// either way and only the CPI target changes. +/// The SPL Token-2022 program. pub use spl_token_2022_interface::ID as TOKEN_2022_PROGRAM_ID; /// Every token program a token-moving instruction accepts, in no particular /// 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 supported token program pub fn is_supported(address: &Pubkey) -> bool { SUPPORTED_TOKEN_PROGRAMS.contains(address) } @@ -52,8 +34,6 @@ mod tests { assert!(!is_supported(&pubkey_from_seed("not a token program"))); } - /// The list is two distinct programs, so it can't have been built from one - /// program repeated. #[test] fn supported_programs_are_distinct() { assert_ne!(SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID); diff --git a/programs/settlement/src/create_buffer.rs b/programs/settlement/src/create_buffer.rs index 653fb40..e39d32c 100644 --- a/programs/settlement/src/create_buffer.rs +++ b/programs/settlement/src/create_buffer.rs @@ -38,10 +38,6 @@ pub fn process_create_buffer( // is a token account, so it's assigned to the token program rather than // to the settlement program. // - // The buffer is sized for the mint it will hold: a Token-2022 mint - // whose extensions require matching ones on its token accounts needs - // room for them, and `token_account_len` is what works out how much. - // // We don't validate `mint` here. `InitializeAccount3` requires a real, // token-program-owned mint (and special-cases the native mint), so a // check of our own would be redundant. diff --git a/programs/settlement/src/token.rs b/programs/settlement/src/token.rs index 8ed52bf..1458fe1 100644 --- a/programs/settlement/src/token.rs +++ b/programs/settlement/src/token.rs @@ -1,17 +1,4 @@ -//! Token-program validation and token-account reads shared by `CreateBuffer` -//! and `ReclaimBuffer`. -//! -//! 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. -//! -//! 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. +//! Token-program validation and token-account reads use cow_settlement_interface::{ token_program::{is_supported, SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID}, @@ -20,17 +7,13 @@ use cow_settlement_interface::{ use pinocchio::{cpi::get_return_data, error::ProgramError, AccountView, Address}; use pinocchio_token::{instructions::GetAccountDataSize, state::Mint}; -/// The length of a token account holding nothing but the base layout, which is -/// every legacy token account and a Token-2022 one carrying no extensions. +/// The length of a SPL token program account. Token2022 extensions may make +/// the actual token account longer than this. const BASE_TOKEN_ACCOUNT_LEN: u64 = pinocchio_token::state::Account::LEN as u64; /// Validate that `token_program_account` is a token program this program may /// issue CPIs against, returning its address for the instruction 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 -/// program check that this already made. -#[must_use = "the returned address is the program the transfers must target"] +#[must_use = "not consuming skips validation"] pub fn validate_token_program( token_program_account: &AccountView, ) -> Result<&Address, ProgramError> { @@ -41,51 +24,18 @@ pub fn validate_token_program( Ok(address) } -/// The data length a token account holding `mint` has to be allocated at, -/// under `token_program`. -/// -/// A mint carrying no extension data needs no extension space on the accounts -/// that hold it, which is the base layout. Every legacy mint is exactly that -/// long, and so is a Token-2022 mint without extensions; a Token-2022 mint that -/// carries any is padded out past a token account's base layout to make room -/// for its account-type marker, so nothing that short can be one. Anything -/// shorter still isn't a mint at all — including the native mint, which the -/// token program recognizes by address without reading an account — and -/// `InitializeAccount3` is what rejects the ones that matter, as it always has. -/// -/// Otherwise the token program is asked, the way the associated-token-account -/// program asks it. That keeps the answer authoritative at run time instead of -/// freezing a mint-extension-to-account-extension table into this program, -/// which would need a redeploy every time Token-2022 grows one. -/// -/// Nothing trusts this length for safety, only for liveness: too short and -/// `InitializeAccount3` rejects the account, reverting the whole instruction; -/// too long and the only cost is rent, paid by this instruction's own payer and -/// recovered when the buffer is reclaimed. -// `get_return_data` returns its 1 KiB buffer by value, so keep it in a leaf -// frame of its own rather than the caller's: SBF stack frames are 4 KiB and -// don't grow. +/// The data length a token account holding `mint` has to be allocated at. +/// It is assumed that `token_program` has already been validated with [`validate_token_program`]. #[inline(never)] pub fn token_account_len(token_program: &Address, mint: &AccountView) -> Result { + // If the mint is of base SPL Mint length, the token accounts must be of base length accordingly. if mint.data_len() <= Mint::LEN { return Ok(BASE_TOKEN_ACCOUNT_LEN); } - // The CPI below targets whatever address it's handed, so re-establish that - // it is a token program at all before handing it the mint. Callers have - // validated it already; this keeps the guarantee local, as - // `read_token_account` does. - if !is_supported(token_program) { - return Err(ProgramError::IncorrectProgramId); - } - + // SPL token provides a function to get the actual required account data size GetAccountDataSize::new(mint).invoke_with_unverified_program(token_program)?; - // The token program reports the length as return data. That buffer is a - // per-transaction global, so it's the program that last set it which makes - // the value trustworthy. Everything below is defensive: a token program - // that can't answer this query fails the CPI, which aborts the instruction - // without returning here at all. let reported = get_return_data().ok_or(SettlementError::BufferSizeUnavailable)?; if reported.program_id() != token_program { return Err(SettlementError::BufferSizeUnavailable.into()); @@ -105,25 +55,15 @@ pub fn token_account_len(token_program: &Address, mint: &AccountView) -> Result< /// The base-layout fields of a token account, as read by /// [`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. +/// For our purposes, we only need the `amount`. pub struct TokenAccount { pub amount: u64, } /// Read the base fields of the token account at `account`, which must be owned /// by `token_program`. -/// -/// `token_program` must have come from [`validate_token_program`]; any other -/// address is rejected. The two programs share the base layout, and differ only -/// in what else may follow it, so which reader applies is decided by the -/// program rather than by the data: -/// -/// - under SPL Token the data is exactly the base layout; -/// - under Token-2022 extensions may follow it, and an account that carries any -/// is recognized by the account-type marker sitting just past the base. +/// It is assumed that `token_program` has already been validated with [`validate_token_program`], +/// or else a program error will be thrown. pub fn read_token_account( token_program: &Address, account: &AccountView, @@ -184,9 +124,6 @@ mod tests { assert_eq!(BASE_LEN, pinocchio_token_2022::state::Account::BASE_LEN); } - /// A mint carrying no extension data — every legacy mint, and a Token-2022 - /// mint without extensions — needs only a base-layout token account, and - /// that is settled without asking the token program. #[test] fn token_account_len_is_the_base_layout_for_a_plain_mint() { for program in SUPPORTED_TOKEN_PROGRAMS { @@ -199,10 +136,6 @@ mod tests { } } - /// An account too short to be a mint at all still gets the base layout, - /// leaving `InitializeAccount3` to reject it — which is also how the native - /// mint works, since the token program knows it by address and litesvm - /// leaves the account itself absent. #[test] fn token_account_len_is_the_base_layout_for_a_too_short_account() { let mint = fake_account(UNRELATED); @@ -212,10 +145,6 @@ mod tests { ); } - /// A longer mint has to be asked about, and off-chain there is nobody to - /// ask: the CPI is a no-op and no return data comes back. On-chain a token - /// program that can't answer aborts the instruction instead of returning - /// here, so this is the error's only reachable path. #[test] fn token_account_len_reports_unavailable_without_an_answer() { let mint = fake_account_owned_by(UNRELATED, TOKEN_2022_PROGRAM_ID, &[0u8; Mint::LEN + 1]); @@ -225,17 +154,6 @@ mod tests { ); } - /// The query is a CPI, so an unsupported program is turned away before it - /// is handed the mint. - #[test] - fn token_account_len_rejects_an_unsupported_program() { - let mint = fake_account_owned_by(UNRELATED, UNRELATED, &[0u8; Mint::LEN + 1]); - assert_eq!( - token_account_len(&UNRELATED, &mint).err(), - Some(ProgramError::IncorrectProgramId), - ); - } - #[test] fn validate_token_program_accepts_every_supported_program() { for program in SUPPORTED_TOKEN_PROGRAMS { @@ -253,8 +171,6 @@ mod tests { ); } - /// A plain account, the only shape the legacy program has and the shape a - /// Token-2022 account without extensions also takes, reads under either. #[test] fn read_token_account_reads_a_base_layout_account() { let mint = Address::new_from_array([0x11; 32]); @@ -268,9 +184,6 @@ mod tests { } } - /// The point of the Token-2022 reader: an account whose extensions push it - /// past the base layout still reads, where the legacy reader's exact-length - /// check would have rejected it. #[test] fn read_token_account_reads_past_token_2022_extensions() { let mint = Address::new_from_array([0x33; 32]); @@ -287,9 +200,6 @@ mod tests { assert_eq!(read.amount, 7); } - /// An over-long account marked as a mint rather than a token account is - /// still rejected, so the tolerance for extensions doesn't let a mint be - /// read as if it held a balance. #[test] fn read_token_account_rejects_an_extended_mint() { let mut data = base_layout(UNRELATED, UNRELATED, 7); @@ -317,9 +227,6 @@ mod tests { ); } - /// Each reader is tied to its own program: an otherwise well-formed account - /// owned by one token program can't be read as if it belonged to the other, - /// which is what stops an instruction from mixing the two. #[test] fn read_token_account_rejects_the_other_programs_account() { for [program, other] in [ From d690f26d11b3f55b2674e815cc84af0696fac700 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:05:18 +0900 Subject: [PATCH 04/27] switch fully away from legacy spl interface --- Cargo.lock | 2 +- Cargo.toml | 1 - DESIGN.md | 6 +- test-cli/Cargo.toml | 2 +- test-cli/src/cmd/create_order.rs | 2 +- test-cli/src/instructions.rs | 14 +- test-cli/src/token.rs | 279 ++++++++++++++++++++++++------- 7 files changed, 233 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a2f1f6..7c2a46f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -930,7 +930,7 @@ dependencies = [ "solana-system-interface 3.2.0", "spl-associated-token-account-interface", "spl-token", - "spl-token-interface 3.0.0", + "spl-token-2022-interface 3.1.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0e30342..127d192 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,6 @@ solana-system-interface = "3" spl-associated-token-account-interface = "2" spl-token = "9" spl-token-2022-interface = "3" -spl-token-interface = "3" [workspace.metadata.cli] # Used to help solana-verify identify the correct build image. diff --git a/DESIGN.md b/DESIGN.md index bd79007..e0412bc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -363,4 +363,8 @@ The main reason to prefer the SOL flow described here is the handling of the ren ## Token 2022 -The settlement program will natively support [Token-2022](https://www.solana-program.com/docs/token-2022) tokens. All operations available to standard tokens will be usable for tokens based on this standard, and no major front-end or back-end changes are expected in order to support the majority of tokens based on this standard. +The settlement program natively supports [Token-2022](https://www.solana-program.com/docs/token-2022) tokens. All token +extensions will be supported. + +Only one token program can be supplied to an instruction at a time. If mints from two separate token programs are required, +the client needs to divide it into separate instructions. diff --git a/test-cli/Cargo.toml b/test-cli/Cargo.toml index 0938df2..b4aa0dc 100644 --- a/test-cli/Cargo.toml +++ b/test-cli/Cargo.toml @@ -35,7 +35,7 @@ solana-sdk.workspace = true solana-system-interface = { workspace = true, features = ["bincode"] } spl-associated-token-account-interface.workspace = true spl-token.workspace = true -spl-token-interface.workspace = true +spl-token-2022-interface.workspace = true [lints] workspace = true diff --git a/test-cli/src/cmd/create_order.rs b/test-cli/src/cmd/create_order.rs index 3465203..3db66ec 100644 --- a/test-cli/src/cmd/create_order.rs +++ b/test-cli/src/cmd/create_order.rs @@ -150,7 +150,7 @@ fn execute(ctx: Context, parsed: ParsedOrder, common: CommonArgs) -> anyhow::Res // Approve the settlement state PDA to pull sell tokens on the user's behalf. ixs.push(crate::instructions::approve( &ctx.program_id, - &sell.ta, + &sell, &ctx.payer.pubkey(), sell_amount, )?); diff --git a/test-cli/src/instructions.rs b/test-cli/src/instructions.rs index 665b373..828fe28 100644 --- a/test-cli/src/instructions.rs +++ b/test-cli/src/instructions.rs @@ -5,7 +5,7 @@ use anyhow::Context as _; use cow_settlement_client::cow_settlement_interface::{pda::state::find_state_pda, Pubkey}; use solana_instruction::Instruction; use solana_rpc_client::rpc_client::RpcClient; -use spl_token_interface::instruction::{self as token_ix}; +use spl_token_2022_interface::instruction::{self as token_ix}; /// Build instructions that wrap `amount` lamports into the payer's WSOL ATA. /// @@ -27,26 +27,26 @@ pub fn wrap_sol( )); ixs.push( - token_ix::sync_native(&spl_token_interface::id(), &wsol.ta) + token_ix::sync_native(&wsol.token_program, &wsol.ta) .context("failed to build SyncNative instruction")?, ); Ok((wsol.ta, ixs)) } -/// Build an `Approve` instruction delegating `amount` tokens on `token_account` -/// to the PDA derived from `program_id`. +/// Build an `Approve` instruction delegating `amount` of `token` to the PDA +/// derived from `program_id`. pub fn approve( program_id: &Pubkey, - token_account: &Pubkey, + token: &token::ResolvedToken, owner: &Pubkey, amount: u64, ) -> anyhow::Result { let (settlement_pda, _) = find_state_pda(program_id); token_ix::approve( - &spl_token_interface::id(), - token_account, + &token.token_program, + &token.ta, &settlement_pda, owner, &[], diff --git a/test-cli/src/token.rs b/test-cli/src/token.rs index 8950510..5023599 100644 --- a/test-cli/src/token.rs +++ b/test-cli/src/token.rs @@ -1,21 +1,20 @@ //! Token resolution helpers: converts a user-supplied token string (alias, mint address, -//! or token-account address) into an SPL token account address and decimal count. +//! or token-account address) into an SPL token account address, the token program that +//! owns it, and the decoded mint. //! //! Entry point: [`resolve`]. use anyhow::Context as _; -use cow_settlement_client::cow_settlement_interface::Pubkey; +use cow_settlement_client::cow_settlement_interface::{token_program, Pubkey}; use solana_instruction::Instruction; -use solana_program_pack::Pack; use solana_pubkey::pubkey; use solana_rpc_client::rpc_client::RpcClient; -use solana_sdk::account::ReadableAccount; -use spl_associated_token_account_interface::address::{ - get_associated_token_address, get_associated_token_address_with_program_id, -}; +use solana_sdk::account::{Account, ReadableAccount}; +use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; use spl_associated_token_account_interface::instruction::create_associated_token_account_idempotent; -use spl_token_interface::native_mint; -use spl_token_interface::state::{Account as TokenAccount, Mint}; +use spl_token_2022_interface::extension::StateWithExtensions; +use spl_token_2022_interface::native_mint; +use spl_token_2022_interface::state::{Account as TokenAccount, Mint}; /// Inline registry of recognised token symbols. /// Avoids an RPC round-trip for well-known mints whose decimals are fixed. @@ -50,6 +49,11 @@ pub struct ResolvedToken { pub mint: Pubkey, /// The actual mint data pub mint_data: Mint, + /// The token program owning both `mint` and `ta` — one of + /// [`token_program::SUPPORTED_TOKEN_PROGRAMS`]. Any instruction touching + /// `ta` has to be built against it, so it travels with the resolved token + /// rather than being assumed. + pub token_program: Pubkey, /// `Some(owner)` when `ta` does not yet exist on-chain. Call with the /// transaction fee payer to build the instruction that creates it. create_ata: Option, @@ -64,7 +68,7 @@ impl ResolvedToken { payer, &owner, &self.mint, - &spl_token_interface::id(), + &self.token_program, )) } } @@ -73,20 +77,9 @@ impl ResolvedToken { pub fn resolve(rpc: &RpcClient, owner: &Pubkey, token_str: &str) -> anyhow::Result { let upper = token_str.to_uppercase(); - // 1. `"SOL"` / `"WSOL"` — payer's WSOL ATA, 9 decimals, no RPC call needed. + // 1. `"SOL"` / `"WSOL"` — payer's ATA for the native mint. if matches!(upper.as_str(), "SOL" | "WSOL") { - let wsol_mint: Pubkey = native_mint::id(); - let wsol_ata = get_associated_token_address_with_program_id( - owner, - &wsol_mint, - &spl_token_interface::id(), - ); - return Ok(ResolvedToken { - ta: wsol_ata, - mint: wsol_mint, - create_ata: determine_create_ata(rpc, &wsol_mint, owner)?, - mint_data: fetch_mint_data(rpc, &wsol_mint)?, - }); + return resolve_from_mint(rpc, owner, &native_mint::ID); } // 2. Base58 mint or token-account address — fetches decimals from the mint, and possibly the token account owner. @@ -100,17 +93,7 @@ pub fn resolve(rpc: &RpcClient, owner: &Pubkey, token_str: &str) -> anyhow::Resu .with_context(|| "failed to fetch genesis hash (is the RPC URL correct?)")? .to_string(); if let Some(known) = known_token(&genesis_hash, &upper) { - let ata = get_associated_token_address_with_program_id( - owner, - &known.mint, - &spl_token_interface::id(), - ); - return Ok(ResolvedToken { - ta: ata, - create_ata: determine_create_ata(rpc, &known.mint, owner)?, - mint: known.mint, - mint_data: fetch_mint_data(rpc, &known.mint)?, - }); + return resolve_from_mint(rpc, owner, &known.mint); } anyhow::bail!( @@ -132,13 +115,15 @@ pub fn resolve_from_token_account( ) })?; - let decoded_account = TokenAccount::unpack(account.data()) + let token_program = token_program_of(&account, token_account)?; + let decoded_account = unpack_token_account(account.data()) .with_context(|| format!("account {token_account} is not a token account"))?; Ok(ResolvedToken { ta: *token_account, mint: decoded_account.mint, - mint_data: fetch_mint_data(rpc, &decoded_account.mint)?, + mint_data: fetch_mint(rpc, &decoded_account.mint)?.1, + token_program, // The account was just fetched and unpacked above, so it already exists. create_ata: None, }) @@ -157,31 +142,32 @@ pub fn interpret_token_from_user_input( .get_account(token_account_or_mint) .with_context(|| format!("account {token_account_or_mint} not found on-chain"))?; - anyhow::ensure!( - account.owner == spl_token_interface::id(), - "{token_account_or_mint} is not owned by the token program (owner: {})", - account.owner - ); + let token_program = token_program_of(&account, token_account_or_mint)?; - if let Ok(token_account) = TokenAccount::unpack(&account.data) { + // Token accounts are tried first: a mint carrying enough extension data to + // reach the token account length is only told apart from an account by the + // account-type byte, which `unpack_token_account` checks. + if let Some(token_account) = unpack_token_account(account.data()) { Ok(ResolvedToken { ta: *token_account_or_mint, mint: token_account.mint, - mint_data: fetch_mint_data(rpc, &token_account.mint)?, + mint_data: fetch_mint(rpc, &token_account.mint)?.1, + token_program, // The account was just fetched and unpacked above, so it already exists. create_ata: None, }) - } else if let Ok(mint) = Mint::unpack(&account.data) { - let ata = get_associated_token_address_with_program_id( + } else if let Some(mint) = unpack_mint(account.data()) { + let ta = get_associated_token_address_with_program_id( owner, token_account_or_mint, - &spl_token_interface::id(), + &token_program, ); Ok(ResolvedToken { - ta: ata, + ta, mint_data: mint, mint: *token_account_or_mint, - create_ata: determine_create_ata(rpc, token_account_or_mint, owner)?, + token_program, + create_ata: determine_create_ata(rpc, &ta, owner)?, }) } else { anyhow::bail!( @@ -192,31 +178,202 @@ pub fn interpret_token_from_user_input( } } +/// Resolve `mint` to `owner`'s associated token account, derived under whichever +/// token program owns the mint. +fn resolve_from_mint( + rpc: &RpcClient, + owner: &Pubkey, + mint: &Pubkey, +) -> anyhow::Result { + let (token_program, mint_data) = fetch_mint(rpc, mint)?; + let ta = get_associated_token_address_with_program_id(owner, mint, &token_program); + + Ok(ResolvedToken { + ta, + mint: *mint, + mint_data, + token_program, + create_ata: determine_create_ata(rpc, &ta, owner)?, + }) +} + +/// The token program owning `account`, rejecting anything the settlement +/// program cannot move tokens with. +fn token_program_of(account: &Account, address: &Pubkey) -> anyhow::Result { + let owner = *account.owner(); + anyhow::ensure!( + token_program::is_supported(&owner), + "{address} is not owned by a supported token program (owner: {owner})", + ); + Ok(owner) +} + /// Used to set `create_ata` on `ResolvedToken`. Returns the ATA `owner` when the /// account still needs to be created. fn determine_create_ata( rpc: &RpcClient, - mint: &Pubkey, + token_account_address: &Pubkey, owner: &Pubkey, ) -> anyhow::Result> { - let token_account_address = get_associated_token_address(owner, mint); - let Ok(data) = rpc.get_account_data(&token_account_address) else { + let Ok(data) = rpc.get_account_data(token_account_address) else { return Ok(Some(*owner)); }; - TokenAccount::unpack(&data) - .map(|_| None) - .map_err(|_| anyhow::anyhow!("account {token_account_address} is not a token account")) + anyhow::ensure!( + unpack_token_account(&data).is_some(), + "account {token_account_address} is not a token account" + ); + Ok(None) } -fn fetch_mint_data(rpc: &RpcClient, mint: &Pubkey) -> anyhow::Result { - let data = rpc - .get_account_data(mint) +/// Fetch `mint` and return the token program owning it alongside its decoded state. +fn fetch_mint(rpc: &RpcClient, mint: &Pubkey) -> anyhow::Result<(Pubkey, Mint)> { + let account = rpc + .get_account(mint) .with_context(|| format!("mint account {mint} not found"))?; - if let Ok(mint_data) = Mint::unpack(&data) { - Ok(mint_data) - } else { - Err(anyhow::anyhow!("account {mint} is not a mint")) + let token_program = token_program_of(&account, mint)?; + let mint_data = + unpack_mint(account.data()).with_context(|| format!("account {mint} is not a mint"))?; + + Ok((token_program, mint_data)) +} + +/// Decode the base token-account state, skipping over any Token-2022 extensions. +/// The legacy layout is the same data without the extension suffix, so this +/// covers both token programs. +fn unpack_token_account(data: &[u8]) -> Option { + StateWithExtensions::::unpack(data) + .ok() + .map(|state| state.base) +} + +/// Decode the base mint state, skipping over any Token-2022 extensions. See +/// [`unpack_token_account`]. +fn unpack_mint(data: &[u8]) -> Option { + StateWithExtensions::::unpack(data) + .ok() + .map(|state| state.base) +} + +#[cfg(test)] +mod tests { + use super::*; + use solana_program_pack::Pack as _; + use spl_token_2022_interface::extension::mint_close_authority::MintCloseAuthority; + use spl_token_2022_interface::extension::{ + BaseStateWithExtensionsMut as _, ExtensionType, StateWithExtensionsMut, + }; + use spl_token_2022_interface::state::AccountState; + + /// A mint as the legacy token program stores it: exactly `Mint::LEN` bytes. + fn legacy_mint(decimals: u8) -> Vec { + let mint = Mint { + decimals, + is_initialized: true, + ..Default::default() + }; + let mut data = vec![0u8; Mint::LEN]; + mint.pack_into_slice(&mut data); + data + } + + /// A token account as the legacy token program stores it. + fn legacy_token_account(mint: Pubkey) -> Vec { + let account = TokenAccount { + mint, + owner: Pubkey::new_unique(), + state: AccountState::Initialized, + ..Default::default() + }; + let mut data = vec![0u8; TokenAccount::LEN]; + account.pack_into_slice(&mut data); + data + } + + /// A Token-2022 mint carrying one extension, which pads it past + /// `TokenAccount::LEN` and appends the account-type byte. + fn extended_mint(decimals: u8) -> Vec { + let len = + ExtensionType::try_calculate_account_len::(&[ExtensionType::MintCloseAuthority]) + .expect("mint length with a close authority"); + let mut data = vec![0u8; len]; + + let mut state = + StateWithExtensionsMut::::unpack_uninitialized(&mut data).expect("empty mint"); + state + .init_extension::(true) + .expect("close authority extension"); + state.base = Mint { + decimals, + is_initialized: true, + ..Default::default() + }; + state.pack_base(); + state.init_account_type().expect("account type"); + + data + } + + #[test] + fn unpacks_legacy_mint_and_token_account() { + assert_eq!(unpack_mint(&legacy_mint(6)).expect("mint").decimals, 6); + + let mint = Pubkey::new_unique(); + assert_eq!( + unpack_token_account(&legacy_token_account(mint)) + .expect("token account") + .mint, + mint, + ); + } + + #[test] + fn unpacks_token_2022_mint_with_extensions() { + // `Mint::unpack` rejects this outright: it insists on exactly `Mint::LEN`. + assert_eq!(unpack_mint(&extended_mint(2)).expect("mint").decimals, 2); + } + + #[test] + fn extended_mint_is_not_mistaken_for_a_token_account() { + // It is longer than `TokenAccount::LEN`, so only the account-type byte + // tells the two apart — which is why `interpret_token_from_user_input` + // may try the token account first. + let data = extended_mint(2); + assert!(data.len() > TokenAccount::LEN); + assert!(unpack_token_account(&data).is_none()); + } + + #[test] + fn legacy_mint_is_not_mistaken_for_a_token_account() { + assert!(unpack_token_account(&legacy_mint(9)).is_none()); + } + + #[test] + fn token_program_of_accepts_every_supported_program() { + let address = Pubkey::new_unique(); + for program in token_program::SUPPORTED_TOKEN_PROGRAMS { + let account = Account { + owner: program, + ..Default::default() + }; + assert_eq!(token_program_of(&account, &address).unwrap(), program); + } + } + + #[test] + fn token_program_of_rejects_other_owners() { + let address = Pubkey::new_unique(); + let account = Account { + owner: Pubkey::new_unique(), + ..Default::default() + }; + let err = token_program_of(&account, &address) + .expect_err("a non-token program is not a token program") + .to_string(); + assert!( + err.contains("not owned by a supported token program"), + "{err}" + ); } } From a056888148ef9d5edace5ff299765f2e9bbc1f98 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:28:13 +0900 Subject: [PATCH 05/27] fix inconsistencies in token program support should work on interface as well as the program itself. that was almost bad! --- client/src/instructions.rs | 4 ++ client/src/parse.rs | 2 + interface/src/instruction/create_buffer.rs | 50 ++++++++++++--------- interface/src/instruction/reclaim_buffer.rs | 21 +++++++-- programs/settlement/src/settle/begin.rs | 9 ++-- programs/settlement/src/settle/finalize.rs | 9 ++-- programs/settlement/src/settle/mod.rs | 18 ++------ programs/settlement/tests/common/buffer.rs | 2 + programs/settlement/tests/create_buffer.rs | 20 ++++++++- programs/settlement/tests/reclaim_buffer.rs | 12 ++++- test-cli/src/cmd/settle.rs | 28 +++++++----- 11 files changed, 117 insertions(+), 58 deletions(-) diff --git a/client/src/instructions.rs b/client/src/instructions.rs index e1c7976..849c92c 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -148,6 +148,7 @@ impl From> for Instruction { pub struct CreateBuffers<'a> { pub program_id: Pubkey, pub payer: Pubkey, + pub token_program: Pubkey, pub mints: &'a [Pubkey], } @@ -161,6 +162,7 @@ impl From> for Instruction { cow_settlement_interface::instruction::create_buffer::CreateBuffers { program_id: builder.program_id, payer: builder.payer, + token_program: builder.token_program, buffers: &buffers, } .into() @@ -199,6 +201,7 @@ pub struct ReclaimBuffer<'a> { pub program_id: Pubkey, pub reclaim_authority: Pubkey, pub reclaim_recipient: Pubkey, + pub token_program: Pubkey, pub mints: &'a [Pubkey], } @@ -218,6 +221,7 @@ impl From> for Instruction { state_pda, reclaim_authority: builder.reclaim_authority, reclaim_recipient: builder.reclaim_recipient, + token_program: builder.token_program, buffers: &buffers, } .into() diff --git a/client/src/parse.rs b/client/src/parse.rs index 6088546..ce15100 100644 --- a/client/src/parse.rs +++ b/client/src/parse.rs @@ -110,6 +110,7 @@ mod tests { SettlementInstruction::CreateBuffer => CreateBuffers { program_id, payer, + token_program: pubkey_from_seed("token program"), mints: &[pubkey_from_seed("mint")], } .into(), @@ -141,6 +142,7 @@ mod tests { state_pda: pubkey_from_seed("state pda"), reclaim_authority: payer, reclaim_recipient: payer, + token_program: pubkey_from_seed("token program"), buffers: &[(pubkey_from_seed("buffer pda"), pubkey_from_seed("mint"))], } .into(), diff --git a/interface/src/instruction/create_buffer.rs b/interface/src/instruction/create_buffer.rs index 77d69ce..e6f0173 100644 --- a/interface/src/instruction/create_buffer.rs +++ b/interface/src/instruction/create_buffer.rs @@ -14,11 +14,6 @@ pub use solana_system_interface::program::ID as SYSTEM_PROGRAM_ID; use super::InstructionInputParsing; use crate::SettlementInstruction; -/// The legacy SPL Token program, the default this builder places in the -/// `token_program` account. Buffers are created as token accounts owned by -/// whichever of [`crate::token_program::SUPPORTED_TOKEN_PROGRAMS`] is passed. -pub use crate::token_program::SPL_TOKEN_PROGRAM_ID; - /// Builder for a `CreateBuffer` instruction that creates one buffer per /// `(buffer_pda, mint)` pair in `buffers`. /// @@ -41,6 +36,10 @@ pub use crate::token_program::SPL_TOKEN_PROGRAM_ID; pub struct CreateBuffers<'a> { pub program_id: Pubkey, pub payer: Pubkey, + /// The token program that will own every buffer this instruction creates. + /// Must be one of [`crate::token_program::SUPPORTED_TOKEN_PROGRAMS`], and + /// must be the program owning every `mint` in `buffers`. + pub token_program: Pubkey, pub buffers: &'a [(Pubkey, Pubkey)], } @@ -49,7 +48,7 @@ impl From> for Instruction { let mut accounts = vec![ AccountMeta::new(builder.payer, true), AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), - AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(builder.token_program, false), ]; for (buffer_pda, mint) in builder.buffers { accounts.push(AccountMeta::new(*buffer_pda, false)); @@ -137,6 +136,7 @@ pub mod fixtures { Instruction::from(CreateBuffers { program_id: zero, payer: zero, + token_program: zero, buffers: &[(zero, zero)], }) .data @@ -160,14 +160,15 @@ mod tests { fn create_buffer_input_parses_valid_input() { let program_id: Address = Address::new_from_array([1; 32]); let payer: Address = Address::new_from_array([2; 32]); - let system_program = fake_account_from_array([4; 32]); let token_program = Address::new_from_array([3; 32]); + let system_program = fake_account_from_array([4; 32]); let buffer_pda = Address::new_from_array([5; 32]); let mint = Address::new_from_array([6; 32]); let data = Instruction::from(CreateBuffers { program_id, payer, + token_program, buffers: &[(buffer_pda, mint)], }) .data; @@ -194,14 +195,15 @@ mod tests { let program_id = Address::new_from_array([1; 32]); let payer = Address::new_from_array([2; 32]); let token_program = Address::new_from_array([3; 32]); - let buffer_a = Address::new_from_array([5; 32]); - let mint_a = Address::new_from_array([6; 32]); - let buffer_b = Address::new_from_array([7; 32]); - let mint_b = Address::new_from_array([8; 32]); + let buffer_a = Address::new_from_array([4; 32]); + let mint_a = Address::new_from_array([5; 32]); + let buffer_b = Address::new_from_array([6; 32]); + let mint_b = Address::new_from_array([7; 32]); let data = Instruction::from(CreateBuffers { program_id, payer, + token_program, buffers: &[(buffer_a, mint_a), (buffer_b, mint_b)], }) .data; @@ -277,11 +279,13 @@ mod tests { fn instruction_data_has_expected_layout() { let program_id = Pubkey::new_from_array([1; 32]); let payer = Pubkey::new_from_array([2; 32]); - let buffer_pda = Pubkey::new_from_array([3; 32]); - let mint = Pubkey::new_from_array([4; 32]); + let token_program = Pubkey::new_from_array([3; 32]); + let buffer_pda = Pubkey::new_from_array([4; 32]); + let mint = Pubkey::new_from_array([5; 32]); let Instruction { data, .. } = CreateBuffers { program_id, payer, + token_program, buffers: &[(buffer_pda, mint)], } .into(); @@ -295,11 +299,13 @@ mod tests { fn single_buffer_has_expected_accounts() { let program_id = Pubkey::new_from_array([1; 32]); let payer = Pubkey::new_from_array([2; 32]); - let buffer_pda = Pubkey::new_from_array([3; 32]); - let mint = Pubkey::new_from_array([4; 32]); + let token_program = Pubkey::new_from_array([3; 32]); + let buffer_pda = Pubkey::new_from_array([4; 32]); + let mint = Pubkey::new_from_array([5; 32]); let Instruction { accounts, .. } = CreateBuffers { program_id, payer, + token_program, buffers: &[(buffer_pda, mint)], } .into(); @@ -310,7 +316,7 @@ mod tests { // untouched. assert_writable_signer(&accounts[0], payer); assert_readonly_nonsigner(&accounts[1], SYSTEM_PROGRAM_ID); - assert_readonly_nonsigner(&accounts[2], SPL_TOKEN_PROGRAM_ID); + assert_readonly_nonsigner(&accounts[2], token_program); assert_writable_nonsigner(&accounts[3], buffer_pda); assert_readonly_nonsigner(&accounts[4], mint); } @@ -319,13 +325,15 @@ mod tests { fn multiple_buffers_append_pairs_after_shared_accounts() { let program_id = Pubkey::new_from_array([1; 32]); let payer = Pubkey::new_from_array([2; 32]); - let buffer_a = Pubkey::new_from_array([3; 32]); - let mint_a = Pubkey::new_from_array([4; 32]); - let buffer_b = Pubkey::new_from_array([5; 32]); - let mint_b = Pubkey::new_from_array([6; 32]); + let token_program = Pubkey::new_from_array([3; 32]); + let buffer_a = Pubkey::new_from_array([4; 32]); + let mint_a = Pubkey::new_from_array([5; 32]); + let buffer_b = Pubkey::new_from_array([6; 32]); + let mint_b = Pubkey::new_from_array([7; 32]); let Instruction { accounts, .. } = CreateBuffers { program_id, payer, + token_program, buffers: &[(buffer_a, mint_a), (buffer_b, mint_b)], } .into(); @@ -342,9 +350,11 @@ mod tests { fn empty_buffers_has_only_shared_accounts() { let program_id = Pubkey::new_from_array([1; 32]); let payer = Pubkey::new_from_array([2; 32]); + let token_program = Pubkey::new_from_array([3; 32]); let Instruction { accounts, .. } = CreateBuffers { program_id, payer, + token_program, buffers: &[], } .into(); diff --git a/interface/src/instruction/reclaim_buffer.rs b/interface/src/instruction/reclaim_buffer.rs index 5a8559e..5699f04 100644 --- a/interface/src/instruction/reclaim_buffer.rs +++ b/interface/src/instruction/reclaim_buffer.rs @@ -16,7 +16,6 @@ use solana_program_error::ProgramError; use solana_pubkey::Pubkey; use super::InstructionInputParsing; -pub use crate::instruction::create_buffer::SPL_TOKEN_PROGRAM_ID; use crate::SettlementInstruction; /// Builder for a `ReclaimBuffer` instruction that closes one buffer per @@ -37,6 +36,9 @@ pub struct ReclaimBuffer<'a> { pub state_pda: Pubkey, pub reclaim_authority: Pubkey, pub reclaim_recipient: Pubkey, + /// The token program owning every buffer this instruction closes. Must be + /// one of [`crate::token_program::SUPPORTED_TOKEN_PROGRAMS`]. + pub token_program: Pubkey, pub buffers: &'a [(Pubkey, Pubkey)], } @@ -46,7 +48,7 @@ impl From> for Instruction { AccountMeta::new_readonly(builder.state_pda, false), AccountMeta::new_readonly(builder.reclaim_authority, true), AccountMeta::new(builder.reclaim_recipient, false), - AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), + AccountMeta::new_readonly(builder.token_program, false), ]; for (buffer_pda, mint) in builder.buffers { accounts.push(AccountMeta::new(*buffer_pda, false)); @@ -126,6 +128,7 @@ pub mod fixtures { state_pda: zero, reclaim_authority: zero, reclaim_recipient: zero, + token_program: zero, buffers: &[(zero, zero)], }) .data @@ -148,6 +151,7 @@ mod tests { let state_pda = pubkey_from_seed("state pda"); let reclaim_authority = pubkey_from_seed("reclaim authority"); let reclaim_recipient = pubkey_from_seed("reclaim recipient"); + let token_program = pubkey_from_seed("token program"); let buffer_pda = pubkey_from_seed("buffer pda"); let mint = pubkey_from_seed("mint"); @@ -156,6 +160,7 @@ mod tests { state_pda, reclaim_authority, reclaim_recipient, + token_program, buffers: &[(buffer_pda, mint)], }) .data; @@ -201,6 +206,7 @@ mod tests { let data = Instruction::from(ReclaimBuffer { program_id, state_pda, + token_program, reclaim_authority, reclaim_recipient, buffers: &[(buffer_a, mint_a), (buffer_b, mint_b)], @@ -282,6 +288,7 @@ mod tests { let state_pda = pubkey_from_seed("state pda"); let reclaim_authority = pubkey_from_seed("reclaim authority"); let reclaim_recipient = pubkey_from_seed("reclaim recipient"); + let token_program = pubkey_from_seed("token program"); let buffer_pda = pubkey_from_seed("buffer pda"); let mint = pubkey_from_seed("mint"); let Instruction { data, .. } = ReclaimBuffer { @@ -289,6 +296,7 @@ mod tests { state_pda, reclaim_authority, reclaim_recipient, + token_program, buffers: &[(buffer_pda, mint)], } .into(); @@ -304,6 +312,7 @@ mod tests { let state_pda = pubkey_from_seed("state pda"); let reclaim_authority = pubkey_from_seed("reclaim authority"); let reclaim_recipient = pubkey_from_seed("reclaim recipient"); + let token_program = pubkey_from_seed("token program"); let buffer_pda = pubkey_from_seed("buffer pda"); let mint = pubkey_from_seed("mint"); let Instruction { accounts, .. } = ReclaimBuffer { @@ -311,6 +320,7 @@ mod tests { state_pda, reclaim_authority, reclaim_recipient, + token_program, buffers: &[(buffer_pda, mint)], } .into(); @@ -319,7 +329,7 @@ mod tests { assert_readonly_nonsigner(&accounts[0], state_pda); assert_readonly_signer(&accounts[1], reclaim_authority); assert_writable_nonsigner(&accounts[2], reclaim_recipient); - assert_readonly_nonsigner(&accounts[3], SPL_TOKEN_PROGRAM_ID); + assert_readonly_nonsigner(&accounts[3], token_program); assert_writable_nonsigner(&accounts[4], buffer_pda); assert_readonly_nonsigner(&accounts[5], mint); } @@ -332,6 +342,7 @@ mod tests { state_pda: pubkey_from_seed("state pda"), reclaim_authority, reclaim_recipient: reclaim_authority, + token_program: pubkey_from_seed("token program"), buffers: &[(pubkey_from_seed("buffer pda"), pubkey_from_seed("mint"))], } .into(); @@ -346,6 +357,7 @@ mod tests { let state_pda = pubkey_from_seed("state pda"); let reclaim_authority = pubkey_from_seed("reclaim authority"); let reclaim_recipient = pubkey_from_seed("reclaim recipient"); + let token_program = pubkey_from_seed("token program"); let buffer_a = pubkey_from_seed("buffer a"); let mint_a = pubkey_from_seed("mint a"); let buffer_b = pubkey_from_seed("buffer b"); @@ -355,6 +367,7 @@ mod tests { state_pda, reclaim_authority, reclaim_recipient, + token_program, buffers: &[(buffer_a, mint_a), (buffer_b, mint_b)], } .into(); @@ -373,11 +386,13 @@ mod tests { let state_pda = pubkey_from_seed("state pda"); let reclaim_authority = pubkey_from_seed("reclaim authority"); let reclaim_recipient = pubkey_from_seed("reclaim recipient"); + let token_program = pubkey_from_seed("token program"); let Instruction { accounts, .. } = ReclaimBuffer { program_id, state_pda, reclaim_authority, reclaim_recipient, + token_program, buffers: &[], } .into(); diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index 4100403..19d6927 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -29,11 +29,12 @@ use pinocchio::{ }; use pinocchio_token::{instructions::Transfer, state::Account as TokenAccount}; -use crate::processor::{ - check_state_pda, is_cpi_call, require_solver, with_state_pda_signer_from_bump, +use crate::{ + processor::{check_state_pda, is_cpi_call, require_solver, with_state_pda_signer_from_bump}, + token::validate_token_program, }; -use super::{validate_counterpart, validate_token_program_account}; +use super::validate_counterpart; pub fn process_begin_settle( program_id: &Address, @@ -75,7 +76,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)?; + validate_token_program(input.token_program_account)?; with_state_pda_signer_from_bump(state_bump, |signer| { settle_orders( diff --git a/programs/settlement/src/settle/finalize.rs b/programs/settlement/src/settle/finalize.rs index df190ee..ea20b01 100644 --- a/programs/settlement/src/settle/finalize.rs +++ b/programs/settlement/src/settle/finalize.rs @@ -12,9 +12,12 @@ use pinocchio::{ }; 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::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( // the canonical buffer for the order's buy mint. Nothing is left to check // here, so `push_funds` only executes the transfers. - validate_token_program_account(input.token_program_account)?; + validate_token_program(input.token_program_account)?; with_state_pda_signer(program_id, input.state_pda_account, |state_pda_signer| { push_funds(input.state_pda_account, state_pda_signer, input.pushes) 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/tests/common/buffer.rs b/programs/settlement/tests/common/buffer.rs index 2dddd41..c081e5b 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; @@ -33,6 +34,7 @@ pub fn ensure_buffer_exists( let ix = Instruction::from(CreateBuffers { program_id: *program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[*mint], }); let tx = Transaction::new_signed_with_payer( diff --git a/programs/settlement/tests/create_buffer.rs b/programs/settlement/tests/create_buffer.rs index f0aee17..17dd2c3 100644 --- a/programs/settlement/tests/create_buffer.rs +++ b/programs/settlement/tests/create_buffer.rs @@ -1,9 +1,10 @@ use cow_settlement_client::cow_settlement_interface::{ - instruction::create_buffer::{CreateBuffers as CreateBuffersRaw, SPL_TOKEN_PROGRAM_ID}, + instruction::create_buffer::CreateBuffers as CreateBuffersRaw, pda::{ buffer::{buffer_pda_seeds, find_buffer_pda}, state::find_state_pda, }, + token_program::SPL_TOKEN_PROGRAM_ID, }; use cow_settlement_client::instructions::CreateBuffers; use litesvm::LiteSVM; @@ -43,6 +44,7 @@ fn happy_path_creates_initialized_buffer_token_account() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[mint], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -110,6 +112,7 @@ fn buffer_can_receive_tokens() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[mint], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -148,6 +151,7 @@ fn happy_path_creates_native_token_buffer() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[native_mint::ID], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -183,6 +187,7 @@ fn happy_path_creates_multiple_buffers_in_one_instruction() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &mints, }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -227,6 +232,7 @@ fn rejects_no_buffers() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -255,6 +261,7 @@ fn rejects_arbitrary_wrong_buffer_pda() { let ix = CreateBuffersRaw { program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, buffers: &[(wrong_pda, mint)], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -275,6 +282,7 @@ fn rejects_non_canonical_bump_pda() { let ix = CreateBuffersRaw { program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, buffers: &[(non_canonical_pda, mint)], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -291,6 +299,7 @@ fn rejects_non_spl_token_program() { let mut ix: Instruction = CreateBuffers { program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[mint], } .into(); @@ -334,6 +343,7 @@ fn rejects_invalid_mint() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[not_a_mint], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -367,6 +377,7 @@ fn creates_buffer_when_address_is_prefunded() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[mint], }; common::signed_tx(svm, &payer, &payer, ix) @@ -383,6 +394,7 @@ fn recreating_same_buffer_is_idempotent() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[mint], }; common::signed_tx(svm, &payer, &payer, ix) @@ -400,6 +412,7 @@ fn batch_with_existing_buffer_passes_with_no_changes() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[existing], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -414,6 +427,7 @@ fn batch_with_existing_buffer_passes_with_no_changes() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[fresh, existing], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -449,6 +463,7 @@ fn one_failing_buffer_reverts_the_whole_batch() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[fresh, not_a_mint], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -483,6 +498,7 @@ fn same_mint_twice_in_one_instruction_is_idempotent() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[mint, mint], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -508,6 +524,7 @@ fn max_buffers_via_lookup_table(svm: &mut LiteSVM, program_id: &Pubkey, payer: & let ix = CreateBuffersRaw { program_id: *program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, buffers: &buffers, }; common::lookup_table::lookup_table_tx(svm, payer, ix) @@ -575,6 +592,7 @@ fn max_buffers_in_one_instruction() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &mints, }; let tx = common::lookup_table::lookup_table_tx(&mut svm, &payer, ix); diff --git a/programs/settlement/tests/reclaim_buffer.rs b/programs/settlement/tests/reclaim_buffer.rs index ad5607d..be614c6 100644 --- a/programs/settlement/tests/reclaim_buffer.rs +++ b/programs/settlement/tests/reclaim_buffer.rs @@ -2,7 +2,7 @@ use cow_settlement_client::instructions::ReclaimBuffer; use cow_settlement_interface::Instruction; use cow_settlement_interface::{ instruction::reclaim_buffer::ReclaimBuffer as ReclaimBufferRaw, pda::buffer::find_buffer_pda, - pda::state::find_state_pda, SettlementError, + pda::state::find_state_pda, token_program::SPL_TOKEN_PROGRAM_ID, SettlementError, }; use litesvm::LiteSVM; use solana_sdk::{ @@ -46,6 +46,7 @@ fn happy_path_reclaims_to_a_recipient_chosen_by_the_authority() { program_id, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: recipient, + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[mint], }; let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); @@ -93,6 +94,7 @@ fn happy_path_reclaims_empty_buffer_to_the_authority_itself() { program_id, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: reclaim_authority.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[mint], }; let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); @@ -132,6 +134,7 @@ fn funded_buffer_is_skipped() { program_id, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: reclaim_authority.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[mint], }; let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); @@ -177,6 +180,7 @@ fn reclaims_to_the_settlements_own_state_pda() { program_id, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: recipient, + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[mint], }; let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); @@ -225,6 +229,7 @@ fn reclaims_multiple_buffers_skipping_funded() { program_id, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: reclaim_authority.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[mint_a, mint_b], }; let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); @@ -261,6 +266,7 @@ fn rejects_the_same_buffer_twice_in_one_instruction() { program_id, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: recipient, + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[mint, mint], }; let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); @@ -290,6 +296,7 @@ fn rejects_when_signer_is_not_the_configured_reclaim_authority() { program_id, reclaim_authority: impostor.pubkey(), reclaim_recipient: impostor.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[mint], }; let tx = common::signed_tx(&svm, &payer, &impostor, ix); @@ -321,6 +328,7 @@ fn rejects_when_the_reclaim_authority_does_not_sign() { program_id, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: recipient, + token_program: SPL_TOKEN_PROGRAM_ID, mints: &[mint], }); @@ -360,6 +368,7 @@ fn max_buffers_reclaim_via_lookup_table( state_pda, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: reclaim_authority.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, buffers: &buffers, }; common::lookup_table::lookup_table_tx(svm, reclaim_authority, ix) @@ -438,6 +447,7 @@ fn max_buffers_in_one_instruction() { program_id, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: reclaim_authority.pubkey(), + token_program: SPL_TOKEN_PROGRAM_ID, mints: &mints, }; let tx = common::lookup_table::lookup_table_tx(&mut svm, &reclaim_authority, ix); diff --git a/test-cli/src/cmd/settle.rs b/test-cli/src/cmd/settle.rs index 2d7bb5a..23b6e42 100644 --- a/test-cli/src/cmd/settle.rs +++ b/test-cli/src/cmd/settle.rs @@ -235,16 +235,18 @@ fn resolve_intents(ctx: &Context, args: &SettleArgs) -> anyhow::Result, - mint_buffers_to_create: &mut HashSet, - mint: Pubkey, + mint_buffers_to_create: &mut HashMap>, + token: &ResolvedToken, amount: u64, ) -> anyhow::Result<()> { + let mint = token.mint; match tally.get(&mint) { Some(cur_amount) => { let new_amount = cur_amount @@ -255,7 +257,10 @@ fn tally_and_register_buffer( None => { let (buffer_pda, _) = find_buffer_pda(&ctx.program_id, &mint); if ctx.rpc.get_account(&buffer_pda).is_err() { - mint_buffers_to_create.insert(mint); + mint_buffers_to_create + .entry(token.token_program) + .or_default() + .insert(mint); } tally.insert(mint, amount); } @@ -273,7 +278,7 @@ fn prepare_setup_ixs( ) -> anyhow::Result<()> { let mut sell_amount_pulled: HashMap = HashMap::new(); let mut buy_amount_pushed: HashMap = HashMap::new(); - let mut mint_buffers_to_create: HashSet = HashSet::new(); + let mut mint_buffers_to_create: HashMap> = HashMap::new(); for intent in intents { // for both the buy and sell token: we need to tally the total transfer amounts @@ -282,26 +287,27 @@ fn prepare_setup_ixs( ctx, &mut sell_amount_pulled, &mut mint_buffers_to_create, - intent.sell.mint, + &intent.sell, intent.data.sell_amount, )?; tally_and_register_buffer( ctx, &mut buy_amount_pushed, &mut mint_buffers_to_create, - intent.buy.mint, + &intent.buy, intent.data.buy_amount, )?; } ensure_cow_balance(&sell_amount_pulled, &buy_amount_pushed)?; - if !mint_buffers_to_create.is_empty() { + for (token_program, mints) in mint_buffers_to_create { all_ixs.push( CreateBuffers { program_id: ctx.program_id, payer: ctx.payer.pubkey(), - mints: &mint_buffers_to_create.into_iter().collect::>(), + token_program, + mints: &mints.into_iter().collect::>(), } .into(), ); From 8248ade9e92e00538eb0d6b2128827f389d0f2d3 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:34:31 +0900 Subject: [PATCH 06/27] Keep the test CLI's Token-2022 support out of this PR The CLI's token resolution now has to know which token program owns a mint, which is a self-contained change with its own tests; it lands separately. All that stays here is the one field `CreateBuffers` gained, pinned to the legacy program, and the workspace entry for the legacy interface the CLI still builds against. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 3 + test-cli/Cargo.toml | 2 +- test-cli/src/cmd/create_order.rs | 2 +- test-cli/src/cmd/settle.rs | 32 ++-- test-cli/src/instructions.rs | 14 +- test-cli/src/token.rs | 279 +++++++------------------------ 7 files changed, 88 insertions(+), 246 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c2a46f..8a2f1f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -930,7 +930,7 @@ dependencies = [ "solana-system-interface 3.2.0", "spl-associated-token-account-interface", "spl-token", - "spl-token-2022-interface 3.1.1", + "spl-token-interface 3.0.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 127d192..a974d63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,9 @@ solana-system-interface = "3" spl-associated-token-account-interface = "2" spl-token = "9" spl-token-2022-interface = "3" +# Only the test CLI still builds against the legacy interface; it moves over +# together with its Token-2022 support. +spl-token-interface = "3" [workspace.metadata.cli] # Used to help solana-verify identify the correct build image. diff --git a/test-cli/Cargo.toml b/test-cli/Cargo.toml index b4aa0dc..0938df2 100644 --- a/test-cli/Cargo.toml +++ b/test-cli/Cargo.toml @@ -35,7 +35,7 @@ solana-sdk.workspace = true solana-system-interface = { workspace = true, features = ["bincode"] } spl-associated-token-account-interface.workspace = true spl-token.workspace = true -spl-token-2022-interface.workspace = true +spl-token-interface.workspace = true [lints] workspace = true diff --git a/test-cli/src/cmd/create_order.rs b/test-cli/src/cmd/create_order.rs index 3db66ec..3465203 100644 --- a/test-cli/src/cmd/create_order.rs +++ b/test-cli/src/cmd/create_order.rs @@ -150,7 +150,7 @@ fn execute(ctx: Context, parsed: ParsedOrder, common: CommonArgs) -> anyhow::Res // Approve the settlement state PDA to pull sell tokens on the user's behalf. ixs.push(crate::instructions::approve( &ctx.program_id, - &sell, + &sell.ta, &ctx.payer.pubkey(), sell_amount, )?); diff --git a/test-cli/src/cmd/settle.rs b/test-cli/src/cmd/settle.rs index 23b6e42..ead67e0 100644 --- a/test-cli/src/cmd/settle.rs +++ b/test-cli/src/cmd/settle.rs @@ -4,7 +4,7 @@ use cow_settlement_client::{ cow_settlement_interface::{ data::{intent::OrderIntent, order::OrderAccount}, pda::buffer::find_buffer_pda, - Pubkey, + token_program, Pubkey, }, instructions::{ BeginSettle, CreateBuffers, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, @@ -235,18 +235,16 @@ fn resolve_intents(ctx: &Context, args: &SettleArgs) -> anyhow::Result, - mint_buffers_to_create: &mut HashMap>, - token: &ResolvedToken, + mint_buffers_to_create: &mut HashSet, + mint: Pubkey, amount: u64, ) -> anyhow::Result<()> { - let mint = token.mint; match tally.get(&mint) { Some(cur_amount) => { let new_amount = cur_amount @@ -257,10 +255,7 @@ fn tally_and_register_buffer( None => { let (buffer_pda, _) = find_buffer_pda(&ctx.program_id, &mint); if ctx.rpc.get_account(&buffer_pda).is_err() { - mint_buffers_to_create - .entry(token.token_program) - .or_default() - .insert(mint); + mint_buffers_to_create.insert(mint); } tally.insert(mint, amount); } @@ -278,7 +273,7 @@ fn prepare_setup_ixs( ) -> anyhow::Result<()> { let mut sell_amount_pulled: HashMap = HashMap::new(); let mut buy_amount_pushed: HashMap = HashMap::new(); - let mut mint_buffers_to_create: HashMap> = HashMap::new(); + let mut mint_buffers_to_create: HashSet = HashSet::new(); for intent in intents { // for both the buy and sell token: we need to tally the total transfer amounts @@ -287,27 +282,28 @@ fn prepare_setup_ixs( ctx, &mut sell_amount_pulled, &mut mint_buffers_to_create, - &intent.sell, + intent.sell.mint, intent.data.sell_amount, )?; tally_and_register_buffer( ctx, &mut buy_amount_pushed, &mut mint_buffers_to_create, - &intent.buy, + intent.buy.mint, intent.data.buy_amount, )?; } ensure_cow_balance(&sell_amount_pulled, &buy_amount_pushed)?; - for (token_program, mints) in mint_buffers_to_create { + if !mint_buffers_to_create.is_empty() { all_ixs.push( CreateBuffers { program_id: ctx.program_id, payer: ctx.payer.pubkey(), - token_program, - mints: &mints.into_iter().collect::>(), + // The CLI only resolves tokens on the legacy program for now. + token_program: token_program::SPL_TOKEN_PROGRAM_ID, + mints: &mint_buffers_to_create.into_iter().collect::>(), } .into(), ); diff --git a/test-cli/src/instructions.rs b/test-cli/src/instructions.rs index 828fe28..665b373 100644 --- a/test-cli/src/instructions.rs +++ b/test-cli/src/instructions.rs @@ -5,7 +5,7 @@ use anyhow::Context as _; use cow_settlement_client::cow_settlement_interface::{pda::state::find_state_pda, Pubkey}; use solana_instruction::Instruction; use solana_rpc_client::rpc_client::RpcClient; -use spl_token_2022_interface::instruction::{self as token_ix}; +use spl_token_interface::instruction::{self as token_ix}; /// Build instructions that wrap `amount` lamports into the payer's WSOL ATA. /// @@ -27,26 +27,26 @@ pub fn wrap_sol( )); ixs.push( - token_ix::sync_native(&wsol.token_program, &wsol.ta) + token_ix::sync_native(&spl_token_interface::id(), &wsol.ta) .context("failed to build SyncNative instruction")?, ); Ok((wsol.ta, ixs)) } -/// Build an `Approve` instruction delegating `amount` of `token` to the PDA -/// derived from `program_id`. +/// Build an `Approve` instruction delegating `amount` tokens on `token_account` +/// to the PDA derived from `program_id`. pub fn approve( program_id: &Pubkey, - token: &token::ResolvedToken, + token_account: &Pubkey, owner: &Pubkey, amount: u64, ) -> anyhow::Result { let (settlement_pda, _) = find_state_pda(program_id); token_ix::approve( - &token.token_program, - &token.ta, + &spl_token_interface::id(), + token_account, &settlement_pda, owner, &[], diff --git a/test-cli/src/token.rs b/test-cli/src/token.rs index 5023599..8950510 100644 --- a/test-cli/src/token.rs +++ b/test-cli/src/token.rs @@ -1,20 +1,21 @@ //! Token resolution helpers: converts a user-supplied token string (alias, mint address, -//! or token-account address) into an SPL token account address, the token program that -//! owns it, and the decoded mint. +//! or token-account address) into an SPL token account address and decimal count. //! //! Entry point: [`resolve`]. use anyhow::Context as _; -use cow_settlement_client::cow_settlement_interface::{token_program, Pubkey}; +use cow_settlement_client::cow_settlement_interface::Pubkey; use solana_instruction::Instruction; +use solana_program_pack::Pack; use solana_pubkey::pubkey; use solana_rpc_client::rpc_client::RpcClient; -use solana_sdk::account::{Account, ReadableAccount}; -use spl_associated_token_account_interface::address::get_associated_token_address_with_program_id; +use solana_sdk::account::ReadableAccount; +use spl_associated_token_account_interface::address::{ + get_associated_token_address, get_associated_token_address_with_program_id, +}; use spl_associated_token_account_interface::instruction::create_associated_token_account_idempotent; -use spl_token_2022_interface::extension::StateWithExtensions; -use spl_token_2022_interface::native_mint; -use spl_token_2022_interface::state::{Account as TokenAccount, Mint}; +use spl_token_interface::native_mint; +use spl_token_interface::state::{Account as TokenAccount, Mint}; /// Inline registry of recognised token symbols. /// Avoids an RPC round-trip for well-known mints whose decimals are fixed. @@ -49,11 +50,6 @@ pub struct ResolvedToken { pub mint: Pubkey, /// The actual mint data pub mint_data: Mint, - /// The token program owning both `mint` and `ta` — one of - /// [`token_program::SUPPORTED_TOKEN_PROGRAMS`]. Any instruction touching - /// `ta` has to be built against it, so it travels with the resolved token - /// rather than being assumed. - pub token_program: Pubkey, /// `Some(owner)` when `ta` does not yet exist on-chain. Call with the /// transaction fee payer to build the instruction that creates it. create_ata: Option, @@ -68,7 +64,7 @@ impl ResolvedToken { payer, &owner, &self.mint, - &self.token_program, + &spl_token_interface::id(), )) } } @@ -77,9 +73,20 @@ impl ResolvedToken { pub fn resolve(rpc: &RpcClient, owner: &Pubkey, token_str: &str) -> anyhow::Result { let upper = token_str.to_uppercase(); - // 1. `"SOL"` / `"WSOL"` — payer's ATA for the native mint. + // 1. `"SOL"` / `"WSOL"` — payer's WSOL ATA, 9 decimals, no RPC call needed. if matches!(upper.as_str(), "SOL" | "WSOL") { - return resolve_from_mint(rpc, owner, &native_mint::ID); + let wsol_mint: Pubkey = native_mint::id(); + let wsol_ata = get_associated_token_address_with_program_id( + owner, + &wsol_mint, + &spl_token_interface::id(), + ); + return Ok(ResolvedToken { + ta: wsol_ata, + mint: wsol_mint, + create_ata: determine_create_ata(rpc, &wsol_mint, owner)?, + mint_data: fetch_mint_data(rpc, &wsol_mint)?, + }); } // 2. Base58 mint or token-account address — fetches decimals from the mint, and possibly the token account owner. @@ -93,7 +100,17 @@ pub fn resolve(rpc: &RpcClient, owner: &Pubkey, token_str: &str) -> anyhow::Resu .with_context(|| "failed to fetch genesis hash (is the RPC URL correct?)")? .to_string(); if let Some(known) = known_token(&genesis_hash, &upper) { - return resolve_from_mint(rpc, owner, &known.mint); + let ata = get_associated_token_address_with_program_id( + owner, + &known.mint, + &spl_token_interface::id(), + ); + return Ok(ResolvedToken { + ta: ata, + create_ata: determine_create_ata(rpc, &known.mint, owner)?, + mint: known.mint, + mint_data: fetch_mint_data(rpc, &known.mint)?, + }); } anyhow::bail!( @@ -115,15 +132,13 @@ pub fn resolve_from_token_account( ) })?; - let token_program = token_program_of(&account, token_account)?; - let decoded_account = unpack_token_account(account.data()) + let decoded_account = TokenAccount::unpack(account.data()) .with_context(|| format!("account {token_account} is not a token account"))?; Ok(ResolvedToken { ta: *token_account, mint: decoded_account.mint, - mint_data: fetch_mint(rpc, &decoded_account.mint)?.1, - token_program, + mint_data: fetch_mint_data(rpc, &decoded_account.mint)?, // The account was just fetched and unpacked above, so it already exists. create_ata: None, }) @@ -142,32 +157,31 @@ pub fn interpret_token_from_user_input( .get_account(token_account_or_mint) .with_context(|| format!("account {token_account_or_mint} not found on-chain"))?; - let token_program = token_program_of(&account, token_account_or_mint)?; + anyhow::ensure!( + account.owner == spl_token_interface::id(), + "{token_account_or_mint} is not owned by the token program (owner: {})", + account.owner + ); - // Token accounts are tried first: a mint carrying enough extension data to - // reach the token account length is only told apart from an account by the - // account-type byte, which `unpack_token_account` checks. - if let Some(token_account) = unpack_token_account(account.data()) { + if let Ok(token_account) = TokenAccount::unpack(&account.data) { Ok(ResolvedToken { ta: *token_account_or_mint, mint: token_account.mint, - mint_data: fetch_mint(rpc, &token_account.mint)?.1, - token_program, + mint_data: fetch_mint_data(rpc, &token_account.mint)?, // The account was just fetched and unpacked above, so it already exists. create_ata: None, }) - } else if let Some(mint) = unpack_mint(account.data()) { - let ta = get_associated_token_address_with_program_id( + } else if let Ok(mint) = Mint::unpack(&account.data) { + let ata = get_associated_token_address_with_program_id( owner, token_account_or_mint, - &token_program, + &spl_token_interface::id(), ); Ok(ResolvedToken { - ta, + ta: ata, mint_data: mint, mint: *token_account_or_mint, - token_program, - create_ata: determine_create_ata(rpc, &ta, owner)?, + create_ata: determine_create_ata(rpc, token_account_or_mint, owner)?, }) } else { anyhow::bail!( @@ -178,202 +192,31 @@ pub fn interpret_token_from_user_input( } } -/// Resolve `mint` to `owner`'s associated token account, derived under whichever -/// token program owns the mint. -fn resolve_from_mint( - rpc: &RpcClient, - owner: &Pubkey, - mint: &Pubkey, -) -> anyhow::Result { - let (token_program, mint_data) = fetch_mint(rpc, mint)?; - let ta = get_associated_token_address_with_program_id(owner, mint, &token_program); - - Ok(ResolvedToken { - ta, - mint: *mint, - mint_data, - token_program, - create_ata: determine_create_ata(rpc, &ta, owner)?, - }) -} - -/// The token program owning `account`, rejecting anything the settlement -/// program cannot move tokens with. -fn token_program_of(account: &Account, address: &Pubkey) -> anyhow::Result { - let owner = *account.owner(); - anyhow::ensure!( - token_program::is_supported(&owner), - "{address} is not owned by a supported token program (owner: {owner})", - ); - Ok(owner) -} - /// Used to set `create_ata` on `ResolvedToken`. Returns the ATA `owner` when the /// account still needs to be created. fn determine_create_ata( rpc: &RpcClient, - token_account_address: &Pubkey, + mint: &Pubkey, owner: &Pubkey, ) -> anyhow::Result> { - let Ok(data) = rpc.get_account_data(token_account_address) else { + let token_account_address = get_associated_token_address(owner, mint); + let Ok(data) = rpc.get_account_data(&token_account_address) else { return Ok(Some(*owner)); }; - anyhow::ensure!( - unpack_token_account(&data).is_some(), - "account {token_account_address} is not a token account" - ); - Ok(None) + TokenAccount::unpack(&data) + .map(|_| None) + .map_err(|_| anyhow::anyhow!("account {token_account_address} is not a token account")) } -/// Fetch `mint` and return the token program owning it alongside its decoded state. -fn fetch_mint(rpc: &RpcClient, mint: &Pubkey) -> anyhow::Result<(Pubkey, Mint)> { - let account = rpc - .get_account(mint) +fn fetch_mint_data(rpc: &RpcClient, mint: &Pubkey) -> anyhow::Result { + let data = rpc + .get_account_data(mint) .with_context(|| format!("mint account {mint} not found"))?; - let token_program = token_program_of(&account, mint)?; - let mint_data = - unpack_mint(account.data()).with_context(|| format!("account {mint} is not a mint"))?; - - Ok((token_program, mint_data)) -} - -/// Decode the base token-account state, skipping over any Token-2022 extensions. -/// The legacy layout is the same data without the extension suffix, so this -/// covers both token programs. -fn unpack_token_account(data: &[u8]) -> Option { - StateWithExtensions::::unpack(data) - .ok() - .map(|state| state.base) -} - -/// Decode the base mint state, skipping over any Token-2022 extensions. See -/// [`unpack_token_account`]. -fn unpack_mint(data: &[u8]) -> Option { - StateWithExtensions::::unpack(data) - .ok() - .map(|state| state.base) -} - -#[cfg(test)] -mod tests { - use super::*; - use solana_program_pack::Pack as _; - use spl_token_2022_interface::extension::mint_close_authority::MintCloseAuthority; - use spl_token_2022_interface::extension::{ - BaseStateWithExtensionsMut as _, ExtensionType, StateWithExtensionsMut, - }; - use spl_token_2022_interface::state::AccountState; - - /// A mint as the legacy token program stores it: exactly `Mint::LEN` bytes. - fn legacy_mint(decimals: u8) -> Vec { - let mint = Mint { - decimals, - is_initialized: true, - ..Default::default() - }; - let mut data = vec![0u8; Mint::LEN]; - mint.pack_into_slice(&mut data); - data - } - - /// A token account as the legacy token program stores it. - fn legacy_token_account(mint: Pubkey) -> Vec { - let account = TokenAccount { - mint, - owner: Pubkey::new_unique(), - state: AccountState::Initialized, - ..Default::default() - }; - let mut data = vec![0u8; TokenAccount::LEN]; - account.pack_into_slice(&mut data); - data - } - - /// A Token-2022 mint carrying one extension, which pads it past - /// `TokenAccount::LEN` and appends the account-type byte. - fn extended_mint(decimals: u8) -> Vec { - let len = - ExtensionType::try_calculate_account_len::(&[ExtensionType::MintCloseAuthority]) - .expect("mint length with a close authority"); - let mut data = vec![0u8; len]; - - let mut state = - StateWithExtensionsMut::::unpack_uninitialized(&mut data).expect("empty mint"); - state - .init_extension::(true) - .expect("close authority extension"); - state.base = Mint { - decimals, - is_initialized: true, - ..Default::default() - }; - state.pack_base(); - state.init_account_type().expect("account type"); - - data - } - - #[test] - fn unpacks_legacy_mint_and_token_account() { - assert_eq!(unpack_mint(&legacy_mint(6)).expect("mint").decimals, 6); - - let mint = Pubkey::new_unique(); - assert_eq!( - unpack_token_account(&legacy_token_account(mint)) - .expect("token account") - .mint, - mint, - ); - } - - #[test] - fn unpacks_token_2022_mint_with_extensions() { - // `Mint::unpack` rejects this outright: it insists on exactly `Mint::LEN`. - assert_eq!(unpack_mint(&extended_mint(2)).expect("mint").decimals, 2); - } - - #[test] - fn extended_mint_is_not_mistaken_for_a_token_account() { - // It is longer than `TokenAccount::LEN`, so only the account-type byte - // tells the two apart — which is why `interpret_token_from_user_input` - // may try the token account first. - let data = extended_mint(2); - assert!(data.len() > TokenAccount::LEN); - assert!(unpack_token_account(&data).is_none()); - } - - #[test] - fn legacy_mint_is_not_mistaken_for_a_token_account() { - assert!(unpack_token_account(&legacy_mint(9)).is_none()); - } - - #[test] - fn token_program_of_accepts_every_supported_program() { - let address = Pubkey::new_unique(); - for program in token_program::SUPPORTED_TOKEN_PROGRAMS { - let account = Account { - owner: program, - ..Default::default() - }; - assert_eq!(token_program_of(&account, &address).unwrap(), program); - } - } - - #[test] - fn token_program_of_rejects_other_owners() { - let address = Pubkey::new_unique(); - let account = Account { - owner: Pubkey::new_unique(), - ..Default::default() - }; - let err = token_program_of(&account, &address) - .expect_err("a non-token program is not a token program") - .to_string(); - assert!( - err.contains("not owned by a supported token program"), - "{err}" - ); + if let Ok(mint_data) = Mint::unpack(&data) { + Ok(mint_data) + } else { + Err(anyhow::anyhow!("account {mint} is not a mint")) } } From 4ed9541f0ea08d62f3352b2b654232352fd41687 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:14:46 +0900 Subject: [PATCH 07/27] switch to using an enum based approach, which seems clean --- interface/src/instruction/create_buffer.rs | 2 +- interface/src/instruction/reclaim_buffer.rs | 2 +- interface/src/instruction/settle/mod.rs | 4 +- interface/src/token_program.rs | 70 +++++++--- programs/settlement/src/create_buffer.rs | 5 +- programs/settlement/src/reclaim_buffer.rs | 7 +- programs/settlement/src/token.rs | 136 +++++++++----------- programs/settlement/tests/common/buffer.rs | 3 +- programs/settlement/tests/common/mod.rs | 5 + programs/settlement/tests/create_buffer.rs | 3 +- programs/settlement/tests/reclaim_buffer.rs | 3 +- test-cli/src/cmd/settle.rs | 5 +- 12 files changed, 138 insertions(+), 107 deletions(-) diff --git a/interface/src/instruction/create_buffer.rs b/interface/src/instruction/create_buffer.rs index e6f0173..b0146e0 100644 --- a/interface/src/instruction/create_buffer.rs +++ b/interface/src/instruction/create_buffer.rs @@ -37,7 +37,7 @@ pub struct CreateBuffers<'a> { pub program_id: Pubkey, pub payer: Pubkey, /// The token program that will own every buffer this instruction creates. - /// Must be one of [`crate::token_program::SUPPORTED_TOKEN_PROGRAMS`], and + /// Must be the address of a [`crate::token_program::TokenProgram`], and /// must be the program owning every `mint` in `buffers`. pub token_program: Pubkey, pub buffers: &'a [(Pubkey, Pubkey)], diff --git a/interface/src/instruction/reclaim_buffer.rs b/interface/src/instruction/reclaim_buffer.rs index 5699f04..dc633e4 100644 --- a/interface/src/instruction/reclaim_buffer.rs +++ b/interface/src/instruction/reclaim_buffer.rs @@ -37,7 +37,7 @@ pub struct ReclaimBuffer<'a> { pub reclaim_authority: Pubkey, pub reclaim_recipient: Pubkey, /// The token program owning every buffer this instruction closes. Must be - /// one of [`crate::token_program::SUPPORTED_TOKEN_PROGRAMS`]. + /// the address of a [`crate::token_program::TokenProgram`]. pub token_program: Pubkey, pub buffers: &'a [(Pubkey, Pubkey)], } diff --git a/interface/src/instruction/settle/mod.rs b/interface/src/instruction/settle/mod.rs index 5443341..8ba0859 100644 --- a/interface/src/instruction/settle/mod.rs +++ b/interface/src/instruction/settle/mod.rs @@ -1,9 +1,11 @@ //! `BeginSettle`/`FinalizeSettle` instruction tools, the instructions-sysvar //! account ID they all reference, and the off-chain instruction builders. +use crate::{token_program::TokenProgram, Pubkey}; use solana_program_error::ProgramError; -pub use crate::token_program::SPL_TOKEN_PROGRAM_ID; +/// The legacy SPL Token program, which the builders below target by default. +pub const SPL_TOKEN_PROGRAM_ID: Pubkey = TokenProgram::SplToken.address(); pub use solana_sdk_ids::sysvar::instructions::ID as INSTRUCTIONS_SYSVAR_ID; mod begin; diff --git a/interface/src/token_program.rs b/interface/src/token_program.rs index 1e7ee58..df70efd 100644 --- a/interface/src/token_program.rs +++ b/interface/src/token_program.rs @@ -1,20 +1,42 @@ //! Utilities related to the token programs supported by the settlement program. use crate::Pubkey; +use solana_program_error::ProgramError; -/// The legacy SPL Token program. -pub use spl_token_2022_interface::inline_spl_token::ID as SPL_TOKEN_PROGRAM_ID; +/// A token program a token-moving instruction accepts. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TokenProgram { + /// The legacy SPL Token program. + SplToken, + /// The SPL Token-2022 program. + Token2022, +} -/// The SPL Token-2022 program. -pub use spl_token_2022_interface::ID as TOKEN_2022_PROGRAM_ID; +impl TokenProgram { + /// Every supported token program, in no particular order. The single list + /// [`TryFrom`] resolves addresses against. + pub const ALL: [Self; 2] = [Self::SplToken, Self::Token2022]; -/// Every token program a token-moving instruction accepts, in no particular -/// order. -pub const SUPPORTED_TOKEN_PROGRAMS: [Pubkey; 2] = [SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID]; + /// The address the program is deployed at. + pub const fn address(self) -> Pubkey { + match self { + Self::SplToken => spl_token_2022_interface::inline_spl_token::ID, + Self::Token2022 => spl_token_2022_interface::ID, + } + } +} -/// Whether `address` is a supported token program -pub fn is_supported(address: &Pubkey) -> bool { - SUPPORTED_TOKEN_PROGRAMS.contains(address) +impl TryFrom<&Pubkey> for TokenProgram { + type Error = ProgramError; + + /// Resolves a program address to the token program it identifies, + /// rejecting any address that isn't a supported token program. + fn try_from(address: &Pubkey) -> Result { + Self::ALL + .into_iter() + .find(|program| program.address() == *address) + .ok_or(ProgramError::IncorrectProgramId) + } } #[cfg(test)] @@ -22,20 +44,34 @@ mod tests { use super::*; use crate::fixtures::pubkey_from_seed; + /// Adding a variant breaks this match, which is the reminder to add it to + /// [`TokenProgram::ALL`] as well #[test] - fn supported_programs_are_supported() { - for program in SUPPORTED_TOKEN_PROGRAMS { - assert!(is_supported(&program), "{program} should be supported"); + fn all_lists_every_variant() { + for program in TokenProgram::ALL { + match program { + TokenProgram::SplToken | TokenProgram::Token2022 => {} + } } } #[test] - fn unrelated_program_is_not_supported() { - assert!(!is_supported(&pubkey_from_seed("not a token program"))); + fn every_program_resolves_from_its_own_address() { + for program in TokenProgram::ALL { + let address = program.address(); + assert_eq!( + TokenProgram::try_from(&address), + Ok(program), + "{program:?} should resolve from {address}", + ); + } } #[test] - fn supported_programs_are_distinct() { - assert_ne!(SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID); + fn unrelated_program_cannot_be_resolved_as_token_program() { + assert_eq!( + TokenProgram::try_from(&pubkey_from_seed("not a token program")), + Err(ProgramError::IncorrectProgramId), + ); } } diff --git a/programs/settlement/src/create_buffer.rs b/programs/settlement/src/create_buffer.rs index e39d32c..40d2240 100644 --- a/programs/settlement/src/create_buffer.rs +++ b/programs/settlement/src/create_buffer.rs @@ -26,6 +26,7 @@ pub fn process_create_buffer( // it was handed, so reject an unsupported one up front rather than at the // first CPI. let token_program = validate_token_program(input.token_program)?; + let token_program_id = token_program.address(); // The buffers' token authority is the settlement state PDA, the single // authority over every buffer. Derive it once for all buffers. @@ -47,7 +48,7 @@ pub fn process_create_buffer( payer: input.payer, pda: buffer_pda, size: token_account_len(token_program, mint)?, - owner: token_program, + owner: &token_program_id, seeds: buffer_pda_seeds(mint_key), } .create_idempotent()?; @@ -56,7 +57,7 @@ pub fn process_create_buffer( // initialize a freshly created one. if created { InitializeAccount3::new(buffer_pda, mint, &state_pda) - .invoke_with_unverified_program(token_program)?; + .invoke_with_unverified_program(&token_program_id)?; } } diff --git a/programs/settlement/src/reclaim_buffer.rs b/programs/settlement/src/reclaim_buffer.rs index 11d128c..9c5961e 100644 --- a/programs/settlement/src/reclaim_buffer.rs +++ b/programs/settlement/src/reclaim_buffer.rs @@ -33,6 +33,7 @@ pub fn process_reclaim_buffer( } = ReclaimBufferInput::parse(instruction_data, accounts)?; let token_program = validate_token_program(token_program)?; + let token_program_id = token_program.address(); with_state_pda_signer(program_id, state_pda, |state_signer| { let reclaim_authority_pubkey: Pubkey = @@ -62,7 +63,7 @@ pub fn process_reclaim_buffer( CloseAccount::new(buffer_pda, reclaim_recipient, state_pda) .invoke_signed_with_unverified_program( core::slice::from_ref(state_signer), - token_program, + &token_program_id, )?; } @@ -81,7 +82,7 @@ mod tests { reclaim_buffer_data, NUM_SHARED_ACCOUNTS, }; use cow_settlement_interface::pda::state::state_pda_seeds; - use cow_settlement_interface::token_program::SPL_TOKEN_PROGRAM_ID; + use cow_settlement_interface::token_program::TokenProgram; use litesvm_token::spl_token::state::{Account as SplTokenAccount, AccountState}; use pinocchio::error::ProgramError; use solana_program_pack::Pack; @@ -92,6 +93,8 @@ mod tests { const AUTHORITY: Address = Address::new_from_array([101; 32]); const MANAGER: Address = Address::new_from_array([102; 32]); const UNRELATED: Address = Address::new_from_array([254; 32]); + /// The token program these fixtures build their buffers under. + const SPL_TOKEN_PROGRAM_ID: Address = TokenProgram::SplToken.address(); /// Number of accounts in a one-buffer reclaim: the shared ones plus a /// single `(buffer_pda, mint)` pair. diff --git a/programs/settlement/src/token.rs b/programs/settlement/src/token.rs index 1458fe1..d41fe76 100644 --- a/programs/settlement/src/token.rs +++ b/programs/settlement/src/token.rs @@ -1,10 +1,7 @@ //! Token-program validation and token-account reads -use cow_settlement_interface::{ - token_program::{is_supported, SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID}, - SettlementError, -}; -use pinocchio::{cpi::get_return_data, error::ProgramError, AccountView, Address}; +use cow_settlement_interface::{token_program::TokenProgram, SettlementError}; +use pinocchio::{cpi::get_return_data, error::ProgramError, AccountView}; use pinocchio_token::{instructions::GetAccountDataSize, state::Mint}; /// The length of a SPL token program account. Token2022 extensions may make @@ -12,32 +9,30 @@ use pinocchio_token::{instructions::GetAccountDataSize, state::Mint}; 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. +/// issue CPIs against, returning the program for the instruction to target. #[must_use = "not consuming skips validation"] pub fn validate_token_program( token_program_account: &AccountView, -) -> Result<&Address, ProgramError> { - let address = token_program_account.address(); - if !is_supported(address) { - return Err(ProgramError::IncorrectProgramId); - } - Ok(address) +) -> Result { + TokenProgram::try_from(token_program_account.address()) } /// The data length a token account holding `mint` has to be allocated at. -/// It is assumed that `token_program` has already been validated with [`validate_token_program`]. -#[inline(never)] -pub fn token_account_len(token_program: &Address, mint: &AccountView) -> Result { +pub fn token_account_len( + token_program: TokenProgram, + mint: &AccountView, +) -> Result { // If the mint is of base SPL Mint length, the token accounts must be of base length accordingly. if mint.data_len() <= Mint::LEN { return Ok(BASE_TOKEN_ACCOUNT_LEN); } + let token_program = token_program.address(); // SPL token provides a function to get the actual required account data size - GetAccountDataSize::new(mint).invoke_with_unverified_program(token_program)?; + GetAccountDataSize::new(mint).invoke_with_unverified_program(&token_program)?; let reported = get_return_data().ok_or(SettlementError::BufferSizeUnavailable)?; - if reported.program_id() != token_program { + if reported.program_id() != &token_program { return Err(SettlementError::BufferSizeUnavailable.into()); } let length: [u8; 8] = reported @@ -62,32 +57,26 @@ pub struct TokenAccount { /// Read the base fields of the token account at `account`, which must be owned /// by `token_program`. -/// It is assumed that `token_program` has already been validated with [`validate_token_program`], -/// or else a program error will be thrown. pub fn read_token_account( - token_program: &Address, + token_program: TokenProgram, account: &AccountView, ) -> Result { - if token_program == &SPL_TOKEN_PROGRAM_ID { - let account = pinocchio_token::state::Account::from_account_view(account)?; - Ok(TokenAccount { - amount: account.amount(), - }) - } else if token_program == &TOKEN_2022_PROGRAM_ID { - let account = pinocchio_token_2022::state::Account::from_account_view(account)?; - Ok(TokenAccount { - amount: account.amount(), - }) - } else { - Err(ProgramError::IncorrectProgramId) - } + let amount = match token_program { + TokenProgram::SplToken => { + pinocchio_token::state::Account::from_account_view(account)?.amount() + } + TokenProgram::Token2022 => { + pinocchio_token_2022::state::Account::from_account_view(account)?.amount() + } + }; + Ok(TokenAccount { amount }) } #[cfg(test)] mod tests { use super::*; use cow_settlement_interface::instruction::fixtures::{fake_account, fake_account_owned_by}; - use cow_settlement_interface::token_program::SUPPORTED_TOKEN_PROGRAMS; + use pinocchio::Address; use pinocchio_token_2022::state::AccountType; /// An address that is not a token program. @@ -113,8 +102,8 @@ mod tests { /// dependency, so this is what keeps them from drifting apart. #[test] fn interface_and_pinocchio_agree_on_the_program_ids() { - assert_eq!(SPL_TOKEN_PROGRAM_ID, pinocchio_token::ID); - assert_eq!(TOKEN_2022_PROGRAM_ID, pinocchio_token_2022::ID); + assert_eq!(TokenProgram::SplToken.address(), pinocchio_token::ID); + assert_eq!(TokenProgram::Token2022.address(), pinocchio_token_2022::ID); } /// The base layout is the same under both programs, so one reader's idea of @@ -126,12 +115,12 @@ mod tests { #[test] fn token_account_len_is_the_base_layout_for_a_plain_mint() { - for program in SUPPORTED_TOKEN_PROGRAMS { - let mint = fake_account_owned_by(UNRELATED, program, &[0u8; Mint::LEN]); + for program in TokenProgram::ALL { + let mint = fake_account_owned_by(UNRELATED, program.address(), &[0u8; Mint::LEN]); assert_eq!( - token_account_len(&program, &mint), + token_account_len(program, &mint), Ok(BASE_TOKEN_ACCOUNT_LEN), - "a base-layout mint should need a base-layout account under {program}", + "a base-layout mint should need a base-layout account under {program:?}", ); } } @@ -140,25 +129,29 @@ mod tests { fn token_account_len_is_the_base_layout_for_a_too_short_account() { let mint = fake_account(UNRELATED); assert_eq!( - token_account_len(&SPL_TOKEN_PROGRAM_ID, &mint), + token_account_len(TokenProgram::SplToken, &mint), Ok(BASE_TOKEN_ACCOUNT_LEN), ); } #[test] fn token_account_len_reports_unavailable_without_an_answer() { - let mint = fake_account_owned_by(UNRELATED, TOKEN_2022_PROGRAM_ID, &[0u8; Mint::LEN + 1]); + let mint = fake_account_owned_by( + UNRELATED, + TokenProgram::Token2022.address(), + &[0u8; Mint::LEN + 1], + ); assert_eq!( - token_account_len(&TOKEN_2022_PROGRAM_ID, &mint).err(), + token_account_len(TokenProgram::Token2022, &mint).err(), Some(SettlementError::BufferSizeUnavailable.into()), ); } #[test] fn validate_token_program_accepts_every_supported_program() { - for program in SUPPORTED_TOKEN_PROGRAMS { - let account = fake_account(program); - assert_eq!(validate_token_program(&account), Ok(&program)); + for program in TokenProgram::ALL { + let account = fake_account(program.address()); + assert_eq!(validate_token_program(&account), Ok(program)); } } @@ -175,11 +168,14 @@ mod tests { fn read_token_account_reads_a_base_layout_account() { let mint = Address::new_from_array([0x11; 32]); let owner = Address::new_from_array([0x22; 32]); - for program in SUPPORTED_TOKEN_PROGRAMS { - let account = - 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:?}")); + for program in TokenProgram::ALL { + let account = fake_account_owned_by( + UNRELATED, + program.address(), + &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.amount, 4_200); } } @@ -194,8 +190,8 @@ mod tests { data.push(AccountType::Account as u8); data.extend_from_slice(&[0xab; 16]); - let account = fake_account_owned_by(UNRELATED, TOKEN_2022_PROGRAM_ID, &data); - let read = read_token_account(&TOKEN_2022_PROGRAM_ID, &account) + let account = fake_account_owned_by(UNRELATED, TokenProgram::Token2022.address(), &data); + let read = read_token_account(TokenProgram::Token2022, &account) .expect("an extended Token-2022 account should read"); assert_eq!(read.amount, 7); } @@ -205,40 +201,28 @@ mod tests { let mut data = base_layout(UNRELATED, UNRELATED, 7); data.push(AccountType::Mint as u8); - let account = fake_account_owned_by(UNRELATED, TOKEN_2022_PROGRAM_ID, &data); + let account = fake_account_owned_by(UNRELATED, TokenProgram::Token2022.address(), &data); assert_eq!( - read_token_account(&TOKEN_2022_PROGRAM_ID, &account).err(), + read_token_account(TokenProgram::Token2022, &account).err(), Some(ProgramError::InvalidAccountData), ); } - #[test] - fn read_token_account_rejects_unvalidated_program() { - // A well-formed legacy token account, so the rejection can only come - // from the program address. - let account = fake_account_owned_by( - UNRELATED, - SPL_TOKEN_PROGRAM_ID, - &base_layout(UNRELATED, UNRELATED, 0), - ); - assert_eq!( - read_token_account(&UNRELATED, &account).err(), - Some(ProgramError::IncorrectProgramId), - ); - } - #[test] fn read_token_account_rejects_the_other_programs_account() { for [program, other] in [ - [SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID], - [TOKEN_2022_PROGRAM_ID, SPL_TOKEN_PROGRAM_ID], + [TokenProgram::SplToken, TokenProgram::Token2022], + [TokenProgram::Token2022, TokenProgram::SplToken], ] { - let account = - fake_account_owned_by(UNRELATED, other, &base_layout(UNRELATED, UNRELATED, 0)); + let account = fake_account_owned_by( + UNRELATED, + other.address(), + &base_layout(UNRELATED, UNRELATED, 0), + ); assert_eq!( - read_token_account(&program, &account).err(), + read_token_account(program, &account).err(), Some(ProgramError::InvalidAccountData), - "an account owned by {other} should not read under {program}", + "an account owned by {other:?} should not read under {program:?}", ); } } diff --git a/programs/settlement/tests/common/buffer.rs b/programs/settlement/tests/common/buffer.rs index c081e5b..b663832 100644 --- a/programs/settlement/tests/common/buffer.rs +++ b/programs/settlement/tests/common/buffer.rs @@ -1,7 +1,6 @@ //! Buffer-account helpers for the settlement integration tests. use cow_settlement_client::cow_settlement_interface::pda::buffer::find_buffer_pda; -use cow_settlement_client::cow_settlement_interface::token_program::SPL_TOKEN_PROGRAM_ID; use cow_settlement_client::cow_settlement_interface::Instruction; use cow_settlement_client::instructions::CreateBuffers; use litesvm::LiteSVM; @@ -11,7 +10,7 @@ use solana_sdk::{ transaction::Transaction, }; -use super::token; +use super::{token, SPL_TOKEN_PROGRAM_ID}; /// The canonical buffer PDA for `mint`. pub fn buffer_pda(program_id: &Pubkey, mint: &Pubkey) -> Pubkey { diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index cb54dcc..be3d52a 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -16,6 +16,7 @@ pub mod token; use cow_settlement_client::instructions::{AddSolver, Initialize}; use cow_settlement_interface::pda::state::find_state_pda; +use cow_settlement_interface::token_program::TokenProgram; use cow_settlement_interface::Instruction; use cow_settlement_interface::SettlementError; use litesvm::{types::TransactionMetadata, LiteSVM}; @@ -34,6 +35,10 @@ pub const PROGRAM_SO: &str = concat!( "/../../target/deploy/cow_settlement.so" ); +/// The legacy SPL Token program, which the tests create their buffers and +/// token accounts under unless they exercise Token-2022 specifically. +pub const SPL_TOKEN_PROGRAM_ID: Pubkey = TokenProgram::SplToken.address(); + pub const CPI_CALLER_SO: &str = concat!( env!("CARGO_MANIFEST_DIR"), "/../../target/deploy/test_cpi_caller.so" diff --git a/programs/settlement/tests/create_buffer.rs b/programs/settlement/tests/create_buffer.rs index 17dd2c3..30a2bd3 100644 --- a/programs/settlement/tests/create_buffer.rs +++ b/programs/settlement/tests/create_buffer.rs @@ -4,7 +4,6 @@ use cow_settlement_client::cow_settlement_interface::{ buffer::{buffer_pda_seeds, find_buffer_pda}, state::find_state_pda, }, - token_program::SPL_TOKEN_PROGRAM_ID, }; use cow_settlement_client::instructions::CreateBuffers; use litesvm::LiteSVM; @@ -29,7 +28,7 @@ use solana_sdk::{ use crate::common::{ benchmark::{send_transaction_metered, BenchLabel}, - unique_keypair, unique_pubkey, + unique_keypair, unique_pubkey, SPL_TOKEN_PROGRAM_ID, }; mod common; diff --git a/programs/settlement/tests/reclaim_buffer.rs b/programs/settlement/tests/reclaim_buffer.rs index be614c6..5231ed1 100644 --- a/programs/settlement/tests/reclaim_buffer.rs +++ b/programs/settlement/tests/reclaim_buffer.rs @@ -2,7 +2,7 @@ use cow_settlement_client::instructions::ReclaimBuffer; use cow_settlement_interface::Instruction; use cow_settlement_interface::{ instruction::reclaim_buffer::ReclaimBuffer as ReclaimBufferRaw, pda::buffer::find_buffer_pda, - pda::state::find_state_pda, token_program::SPL_TOKEN_PROGRAM_ID, SettlementError, + pda::state::find_state_pda, SettlementError, }; use litesvm::LiteSVM; use solana_sdk::{ @@ -15,6 +15,7 @@ use crate::common::benchmark::{send_transaction_metered, BenchLabel}; use crate::common::buffer::ensure_buffer_exists; use crate::common::{ assert_instruction_error, to_instruction_error, unique_pubkey, InitializedParams, + SPL_TOKEN_PROGRAM_ID, }; mod common; diff --git a/test-cli/src/cmd/settle.rs b/test-cli/src/cmd/settle.rs index ead67e0..e0a25f8 100644 --- a/test-cli/src/cmd/settle.rs +++ b/test-cli/src/cmd/settle.rs @@ -4,7 +4,8 @@ use cow_settlement_client::{ cow_settlement_interface::{ data::{intent::OrderIntent, order::OrderAccount}, pda::buffer::find_buffer_pda, - token_program, Pubkey, + token_program::TokenProgram, + Pubkey, }, instructions::{ BeginSettle, CreateBuffers, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, @@ -302,7 +303,7 @@ fn prepare_setup_ixs( program_id: ctx.program_id, payer: ctx.payer.pubkey(), // The CLI only resolves tokens on the legacy program for now. - token_program: token_program::SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken.address(), mints: &mint_buffers_to_create.into_iter().collect::>(), } .into(), From 5fd2f6ab5b4e9578d7e76cfa980dd5c330f6de41 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:26:22 +0900 Subject: [PATCH 08/27] idl update --- interface/src/lib.rs | 2 +- programs/settlement/idl/cow_settlement.json | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/interface/src/lib.rs b/interface/src/lib.rs index b510750..2b1ecd0 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -28,7 +28,7 @@ pub enum SettlementInstruction { CreateOrder = 2, /// Creates the singleton settlement state PDA. Succeeds only once. Initialize = 3, - /// Creates one or more per-token buffer PDAs (SPL token accounts) in a + /// Creates one or more per-token buffer PDAs (token accounts) in a /// single instruction. /// /// Each buffer_pda_i must be the canonical PDA for seeds diff --git a/programs/settlement/idl/cow_settlement.json b/programs/settlement/idl/cow_settlement.json index bb11b0d..19a8f58 100644 --- a/programs/settlement/idl/cow_settlement.json +++ b/programs/settlement/idl/cow_settlement.json @@ -85,7 +85,8 @@ { "name": "create_buffer", "docs": [ - "Creates one or more per-token buffer PDAs (SPL token accounts) in a single instruction.", + "Creates one or more per-token buffer PDAs (token accounts) in a single instruction.", + "Every buffer created by one instruction is owned by the single token_program the instruction is handed, so mints spread across both supported token programs need one instruction each.", "IDL LIMITATION: the real instruction accepts an unbounded number of (buffer_pda, mint) pairs as remaining accounts, one pair per buffer, with at least one pair required (CreateBuffer rejects zero buffers). IDL grammar has no 'repeated group' construct, so this file only declares the guaranteed index-0 template (buffer_pda_0/mint_0).", "Each buffer_pda_i must be the canonical PDA for seeds [SETTLEMENT_SEED, mint_i, \"buffer\"]." ], @@ -104,7 +105,9 @@ }, { "name": "token_program", - "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + "docs": [ + "The token program that will own the created buffer PDAs. Must be one of the supported token accounts." + ] }, { "name": "buffer_pda_0", @@ -330,6 +333,7 @@ "name": "reclaim_buffer", "docs": [ "Closes one or more buffer PDAs and sends each closed buffer's rent lamports to a reclaim_recipient of the caller's choosing. Only the current holder of the ReclaimAuthority role recorded in the state PDA may authorize this. A buffer that still holds tokens is skipped, not closed, and the instruction still succeeds.", + "Every buffer closed by one instruction must be owned by the single token_program the instruction is handed, so buffers spread across both supported token programs need one instruction each.", "IDL LIMITATION: the real instruction accepts an unbounded number of (buffer_pda, mint) pairs as remaining accounts, one pair per buffer, with at least one pair required (ReclaimBuffer rejects zero buffers). IDL grammar has no 'repeated group' construct, so this file only declares the guaranteed index-0 template (buffer_pda_0/mint_0).", "Each buffer_pda_i must be the canonical PDA for seeds [SETTLEMENT_SEED, mint_i, \"buffer\"]; mint_i is passed only so that derivation can be checked on-chain." ], @@ -387,7 +391,9 @@ }, { "name": "token_program", - "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + "docs": [ + "The token program that owns the created buffer PDAs. Must be one of the supported token accounts." + ] }, { "name": "buffer_pda_0", @@ -966,6 +972,11 @@ "code": 38, "name": "OrderCreatedOnChainMismatch", "msg": "A created order's intent isn't set with the created_on_chain flag corresponding to the behavior of the invoked order creation instruction." + }, + { + "code": 39, + "name": "BufferSizeUnavailable", + "msg": "CreateBuffer asked the token program how long a token account for a mint has to be and couldn't read the answer, so it can't size the buffer." } ] } From 7836f892b40572729990b08afeb15cb9390904de Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:27:50 +0900 Subject: [PATCH 09/27] remove the unnecessary early return which skips the length CPI --- programs/settlement/src/token.rs | 29 ++-------------------- programs/settlement/tests/common/token.rs | 25 ++++++++++++++++++- programs/settlement/tests/create_buffer.rs | 8 +++--- 3 files changed, 30 insertions(+), 32 deletions(-) diff --git a/programs/settlement/src/token.rs b/programs/settlement/src/token.rs index d41fe76..a491ebe 100644 --- a/programs/settlement/src/token.rs +++ b/programs/settlement/src/token.rs @@ -2,7 +2,7 @@ use cow_settlement_interface::{token_program::TokenProgram, SettlementError}; use pinocchio::{cpi::get_return_data, error::ProgramError, AccountView}; -use pinocchio_token::{instructions::GetAccountDataSize, state::Mint}; +use pinocchio_token::instructions::GetAccountDataSize; /// The length of a SPL token program account. Token2022 extensions may make /// the actual token account longer than this. @@ -22,11 +22,6 @@ pub fn token_account_len( token_program: TokenProgram, mint: &AccountView, ) -> Result { - // If the mint is of base SPL Mint length, the token accounts must be of base length accordingly. - if mint.data_len() <= Mint::LEN { - return Ok(BASE_TOKEN_ACCOUNT_LEN); - } - let token_program = token_program.address(); // SPL token provides a function to get the actual required account data size GetAccountDataSize::new(mint).invoke_with_unverified_program(&token_program)?; @@ -77,6 +72,7 @@ mod tests { use super::*; use cow_settlement_interface::instruction::fixtures::{fake_account, fake_account_owned_by}; use pinocchio::Address; + use pinocchio_token::state::Mint; use pinocchio_token_2022::state::AccountType; /// An address that is not a token program. @@ -113,27 +109,6 @@ mod tests { assert_eq!(BASE_LEN, pinocchio_token_2022::state::Account::BASE_LEN); } - #[test] - fn token_account_len_is_the_base_layout_for_a_plain_mint() { - for program in TokenProgram::ALL { - let mint = fake_account_owned_by(UNRELATED, program.address(), &[0u8; Mint::LEN]); - assert_eq!( - token_account_len(program, &mint), - Ok(BASE_TOKEN_ACCOUNT_LEN), - "a base-layout mint should need a base-layout account under {program:?}", - ); - } - } - - #[test] - fn token_account_len_is_the_base_layout_for_a_too_short_account() { - let mint = fake_account(UNRELATED); - assert_eq!( - token_account_len(TokenProgram::SplToken, &mint), - Ok(BASE_TOKEN_ACCOUNT_LEN), - ); - } - #[test] fn token_account_len_reports_unavailable_without_an_answer() { let mint = fake_account_owned_by( diff --git a/programs/settlement/tests/common/token.rs b/programs/settlement/tests/common/token.rs index a6e7b89..6889c46 100644 --- a/programs/settlement/tests/common/token.rs +++ b/programs/settlement/tests/common/token.rs @@ -3,7 +3,7 @@ use cow_settlement_client::cow_settlement_interface::pda::state::find_state_pda; use litesvm::{types::TransactionMetadata, LiteSVM}; use litesvm_token::{ - spl_token::{instruction::initialize_mint2, state::Mint}, + spl_token::{instruction::initialize_mint2, native_mint, state::Mint}, Approve, CreateAccount, CreateAssociatedTokenAccount, MintTo, Transfer, TOKEN_ID, }; use solana_program_pack::Pack; @@ -48,6 +48,29 @@ pub fn create_mint(svm: &mut LiteSVM, payer: &Keypair) -> Pubkey { mint.pubkey() } +/// Seed the wrapped-SOL mint account, which `LiteSVM` does not create. +/// +/// On chain the native mint is a real, token-program-owned mint account, and +/// the program queries it with `GetAccountDataSize` to size a buffer. That +/// query rejects an account the token program does not own, so a test touching +/// the native mint has to put the account there first. +pub fn create_native_mint(svm: &mut LiteSVM) { + /// The native mint's fixed decimals, matching `spl_token::native_mint`. + const DECIMALS: u8 = 9; + + let mut data = vec![0u8; Mint::LEN]; + Mint { + mint_authority: None.into(), + supply: 0, + decimals: DECIMALS, + is_initialized: true, + freeze_authority: None.into(), + } + .pack_into_slice(&mut data); + let token_program = Pubkey::new_from_array(TOKEN_ID.to_bytes()); + super::create_account_at(svm, native_mint::ID, &token_program, &data); +} + /// 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`. diff --git a/programs/settlement/tests/create_buffer.rs b/programs/settlement/tests/create_buffer.rs index 30a2bd3..a826093 100644 --- a/programs/settlement/tests/create_buffer.rs +++ b/programs/settlement/tests/create_buffer.rs @@ -140,11 +140,11 @@ fn buffer_can_receive_tokens() { #[test] fn happy_path_creates_native_token_buffer() { - // The native mint is special-cased by the token program: it's recognized by - // key (no mint-account validation) and the buffer is initialized as a - // wrapped-SOL account. Since we fund exactly the rent-exempt minimum, the - // wrapped balance starts at zero. + // `InitializeAccount` special-cases the native mint: it's recognized by key + // and the buffer is initialized as a wrapped-SOL account. Since we fund + // exactly the rent-exempt minimum, the wrapped balance starts at zero. let (mut svm, program_id, payer) = common::setup(); + common::token::create_native_mint(&mut svm); let (buffer_pda, _bump) = find_buffer_pda(&program_id, &native_mint::ID); let ix = CreateBuffers { From 421a92669feb061f8e9167e47ffbc12647778887 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:10:34 +0900 Subject: [PATCH 10/27] update documentation and design --- DESIGN.md | 8 +++----- interface/src/instruction/create_buffer.rs | 5 +++++ interface/src/instruction/reclaim_buffer.rs | 5 +++++ 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index e0412bc..a3283ae 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -363,8 +363,6 @@ The main reason to prefer the SOL flow described here is the handling of the ren ## Token 2022 -The settlement program natively supports [Token-2022](https://www.solana-program.com/docs/token-2022) tokens. All token -extensions will be supported. - -Only one token program can be supplied to an instruction at a time. If mints from two separate token programs are required, -the client needs to divide it into separate instructions. +The settlement program natively supports [Token-2022](https://www.solana-program.com/docs/token-2022) tokens. The settlement program does not explicitly block any +token extensions, but if the extension interferes with the settlement process such that it causes a failure (such as locked tokens), then any affected orders will +be unsettleable. diff --git a/interface/src/instruction/create_buffer.rs b/interface/src/instruction/create_buffer.rs index b0146e0..30a67e5 100644 --- a/interface/src/instruction/create_buffer.rs +++ b/interface/src/instruction/create_buffer.rs @@ -25,6 +25,11 @@ use crate::SettlementInstruction; /// Buffer creation is idempotent: an already-existing `buffer_pda` is left /// unchanged and the instruction still succeeds, so two parties racing to /// create the same buffer both succeed. +/// +/// The token_program supplied to this instruction must be the owner of all mints +/// supplied. Only one token program can be supplied to this instruction at a time. +/// If mints from two separate token programs are required, the client needs to +/// divide it into separate instructions. /// /// Wire format: `[discriminator=4]`, 1 byte. The tokens are implied by the /// `mint` accounts, so no further data is needed. diff --git a/interface/src/instruction/reclaim_buffer.rs b/interface/src/instruction/reclaim_buffer.rs index dc633e4..65f4018 100644 --- a/interface/src/instruction/reclaim_buffer.rs +++ b/interface/src/instruction/reclaim_buffer.rs @@ -6,6 +6,11 @@ //! [`crate::data::state::StateAccount`]) may authorize this. //! //! A buffer that still holds tokens is skipped, not closed. +//! +//! The token_program supplied to this instruction must be the owner of all mints +//! supplied. Only one token program can be supplied to this instruction at a time. +//! If mints from two separate token programs are required, the client needs to +//! divide it into separate instructions. //! //! Wire format: `[discriminator=6]`, 1 byte. //! Required accounts: From ce75edfe1cd690ba4871e6c577f8dd06cab70b45 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:22:15 +0900 Subject: [PATCH 11/27] update tests --- interface/src/instruction/create_buffer.rs | 80 +++++++++++----------- programs/settlement/src/token.rs | 40 ++++++----- 2 files changed, 63 insertions(+), 57 deletions(-) diff --git a/interface/src/instruction/create_buffer.rs b/interface/src/instruction/create_buffer.rs index 30a67e5..976d94d 100644 --- a/interface/src/instruction/create_buffer.rs +++ b/interface/src/instruction/create_buffer.rs @@ -25,10 +25,10 @@ use crate::SettlementInstruction; /// Buffer creation is idempotent: an already-existing `buffer_pda` is left /// unchanged and the instruction still succeeds, so two parties racing to /// create the same buffer both succeed. -/// +/// /// The token_program supplied to this instruction must be the owner of all mints -/// supplied. Only one token program can be supplied to this instruction at a time. -/// If mints from two separate token programs are required, the client needs to +/// supplied. Only one token program can be supplied to this instruction at a time. +/// If mints from two separate token programs are required, the client needs to /// divide it into separate instructions. /// /// Wire format: `[discriminator=4]`, 1 byte. The tokens are implied by the @@ -152,23 +152,21 @@ pub mod fixtures { mod tests { use super::fixtures::{create_buffer_data, NUM_SHARED_ACCOUNTS}; use super::*; - use crate::instruction::fixtures::{ - fake_account, fake_account_from_array, fake_sequential_accounts, - }; + use crate::fixtures::pubkey_from_seed; + use crate::instruction::fixtures::{fake_account, fake_sequential_accounts}; use crate::instruction::tests::{ assert_readonly_nonsigner, assert_writable_nonsigner, assert_writable_signer, }; use solana_account_view::AccountView; - use solana_address::Address; #[test] fn create_buffer_input_parses_valid_input() { - let program_id: Address = Address::new_from_array([1; 32]); - let payer: Address = Address::new_from_array([2; 32]); - let token_program = Address::new_from_array([3; 32]); - let system_program = fake_account_from_array([4; 32]); - let buffer_pda = Address::new_from_array([5; 32]); - let mint = Address::new_from_array([6; 32]); + let program_id = pubkey_from_seed("program id"); + let payer = pubkey_from_seed("payer"); + let token_program = pubkey_from_seed("token program"); + let system_program = fake_account(pubkey_from_seed("system program")); + let buffer_pda = pubkey_from_seed("buffer pda"); + let mint = pubkey_from_seed("mint"); let data = Instruction::from(CreateBuffers { program_id, @@ -197,13 +195,13 @@ mod tests { #[test] fn create_buffer_input_parses_multiple_buffers() { - let program_id = Address::new_from_array([1; 32]); - let payer = Address::new_from_array([2; 32]); - let token_program = Address::new_from_array([3; 32]); - let buffer_a = Address::new_from_array([4; 32]); - let mint_a = Address::new_from_array([5; 32]); - let buffer_b = Address::new_from_array([6; 32]); - let mint_b = Address::new_from_array([7; 32]); + let program_id = pubkey_from_seed("program id"); + let payer = pubkey_from_seed("payer"); + let token_program = pubkey_from_seed("token program"); + let buffer_a = pubkey_from_seed("buffer a"); + let mint_a = pubkey_from_seed("mint a"); + let buffer_b = pubkey_from_seed("buffer b"); + let mint_b = pubkey_from_seed("mint b"); let data = Instruction::from(CreateBuffers { program_id, @@ -214,7 +212,7 @@ mod tests { .data; let accounts = [ fake_account(payer), - fake_account_from_array([4; 32]), + fake_account(pubkey_from_seed("system program")), fake_account(token_program), fake_account(buffer_a), fake_account(mint_a), @@ -282,11 +280,11 @@ mod tests { #[test] fn instruction_data_has_expected_layout() { - let program_id = Pubkey::new_from_array([1; 32]); - let payer = Pubkey::new_from_array([2; 32]); - let token_program = Pubkey::new_from_array([3; 32]); - let buffer_pda = Pubkey::new_from_array([4; 32]); - let mint = Pubkey::new_from_array([5; 32]); + let program_id = pubkey_from_seed("program id"); + let payer = pubkey_from_seed("payer"); + let token_program = pubkey_from_seed("token program"); + let buffer_pda = pubkey_from_seed("buffer pda"); + let mint = pubkey_from_seed("mint"); let Instruction { data, .. } = CreateBuffers { program_id, payer, @@ -302,11 +300,11 @@ mod tests { #[test] fn single_buffer_has_expected_accounts() { - let program_id = Pubkey::new_from_array([1; 32]); - let payer = Pubkey::new_from_array([2; 32]); - let token_program = Pubkey::new_from_array([3; 32]); - let buffer_pda = Pubkey::new_from_array([4; 32]); - let mint = Pubkey::new_from_array([5; 32]); + let program_id = pubkey_from_seed("program id"); + let payer = pubkey_from_seed("payer"); + let token_program = pubkey_from_seed("token program"); + let buffer_pda = pubkey_from_seed("buffer pda"); + let mint = pubkey_from_seed("mint"); let Instruction { accounts, .. } = CreateBuffers { program_id, payer, @@ -328,13 +326,13 @@ mod tests { #[test] fn multiple_buffers_append_pairs_after_shared_accounts() { - let program_id = Pubkey::new_from_array([1; 32]); - let payer = Pubkey::new_from_array([2; 32]); - let token_program = Pubkey::new_from_array([3; 32]); - let buffer_a = Pubkey::new_from_array([4; 32]); - let mint_a = Pubkey::new_from_array([5; 32]); - let buffer_b = Pubkey::new_from_array([6; 32]); - let mint_b = Pubkey::new_from_array([7; 32]); + let program_id = pubkey_from_seed("program id"); + let payer = pubkey_from_seed("payer"); + let token_program = pubkey_from_seed("token program"); + let buffer_a = pubkey_from_seed("buffer a"); + let mint_a = pubkey_from_seed("mint a"); + let buffer_b = pubkey_from_seed("buffer b"); + let mint_b = pubkey_from_seed("mint b"); let Instruction { accounts, .. } = CreateBuffers { program_id, payer, @@ -353,9 +351,9 @@ mod tests { #[test] fn empty_buffers_has_only_shared_accounts() { - let program_id = Pubkey::new_from_array([1; 32]); - let payer = Pubkey::new_from_array([2; 32]); - let token_program = Pubkey::new_from_array([3; 32]); + let program_id = pubkey_from_seed("program id"); + let payer = pubkey_from_seed("payer"); + let token_program = pubkey_from_seed("token program"); let Instruction { accounts, .. } = CreateBuffers { program_id, payer, diff --git a/programs/settlement/src/token.rs b/programs/settlement/src/token.rs index a491ebe..f7afe1e 100644 --- a/programs/settlement/src/token.rs +++ b/programs/settlement/src/token.rs @@ -70,14 +70,14 @@ pub fn read_token_account( #[cfg(test)] mod tests { use super::*; - use cow_settlement_interface::instruction::fixtures::{fake_account, fake_account_owned_by}; + use cow_settlement_interface::{ + fixtures::pubkey_from_seed, + instruction::fixtures::{fake_account, fake_account_owned_by}, + }; use pinocchio::Address; use pinocchio_token::state::Mint; use pinocchio_token_2022::state::AccountType; - /// An address that is not a token program. - const UNRELATED: Address = Address::new_from_array([0x99; 32]); - /// The length of a token account holding nothing but the base layout. Both /// programs share it: it is Token-2022's `BASE_LEN` and the whole of a /// legacy account. @@ -112,7 +112,7 @@ mod tests { #[test] fn token_account_len_reports_unavailable_without_an_answer() { let mint = fake_account_owned_by( - UNRELATED, + pubkey_from_seed("mint"), TokenProgram::Token2022.address(), &[0u8; Mint::LEN + 1], ); @@ -132,7 +132,7 @@ mod tests { #[test] fn validate_token_program_rejects_unrelated_program() { - let account = fake_account(UNRELATED); + let account = fake_account(pubkey_from_seed("not a token program")); assert_eq!( validate_token_program(&account), Err(ProgramError::IncorrectProgramId), @@ -141,11 +141,11 @@ mod tests { #[test] fn read_token_account_reads_a_base_layout_account() { - let mint = Address::new_from_array([0x11; 32]); - let owner = Address::new_from_array([0x22; 32]); + let mint = pubkey_from_seed("mint"); + let owner = pubkey_from_seed("owner"); for program in TokenProgram::ALL { let account = fake_account_owned_by( - UNRELATED, + pubkey_from_seed("token account"), program.address(), &base_layout(mint, owner, 4_200), ); @@ -157,15 +157,19 @@ mod tests { #[test] fn read_token_account_reads_past_token_2022_extensions() { - let mint = Address::new_from_array([0x33; 32]); - let owner = Address::new_from_array([0x44; 32]); + let mint = pubkey_from_seed("extended mint"); + let owner = pubkey_from_seed("extended owner"); let mut data = base_layout(mint, owner, 7); // Extensions are preceded by the account-type marker, which is what // distinguishes a longer account from a mint of the same size. data.push(AccountType::Account as u8); data.extend_from_slice(&[0xab; 16]); - let account = fake_account_owned_by(UNRELATED, TokenProgram::Token2022.address(), &data); + let account = fake_account_owned_by( + pubkey_from_seed("token account"), + TokenProgram::Token2022.address(), + &data, + ); let read = read_token_account(TokenProgram::Token2022, &account) .expect("an extended Token-2022 account should read"); assert_eq!(read.amount, 7); @@ -173,10 +177,14 @@ mod tests { #[test] fn read_token_account_rejects_an_extended_mint() { - let mut data = base_layout(UNRELATED, UNRELATED, 7); + let mut data = base_layout(pubkey_from_seed("mint"), pubkey_from_seed("owner"), 7); data.push(AccountType::Mint as u8); - let account = fake_account_owned_by(UNRELATED, TokenProgram::Token2022.address(), &data); + let account = fake_account_owned_by( + pubkey_from_seed("mint account"), + TokenProgram::Token2022.address(), + &data, + ); assert_eq!( read_token_account(TokenProgram::Token2022, &account).err(), Some(ProgramError::InvalidAccountData), @@ -190,9 +198,9 @@ mod tests { [TokenProgram::Token2022, TokenProgram::SplToken], ] { let account = fake_account_owned_by( - UNRELATED, + pubkey_from_seed("token account"), other.address(), - &base_layout(UNRELATED, UNRELATED, 0), + &base_layout(pubkey_from_seed("mint"), pubkey_from_seed("owner"), 0), ); assert_eq!( read_token_account(program, &account).err(), From 88b1c485a049664793342ee3f2731457538f6e9f Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:35:27 +0900 Subject: [PATCH 12/27] fix undo changes and lint --- interface/src/instruction/reclaim_buffer.rs | 6 ++-- programs/settlement/src/token.rs | 33 +++++++++++++++++++-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/interface/src/instruction/reclaim_buffer.rs b/interface/src/instruction/reclaim_buffer.rs index 65f4018..ad400f4 100644 --- a/interface/src/instruction/reclaim_buffer.rs +++ b/interface/src/instruction/reclaim_buffer.rs @@ -6,10 +6,10 @@ //! [`crate::data::state::StateAccount`]) may authorize this. //! //! A buffer that still holds tokens is skipped, not closed. -//! +//! //! The token_program supplied to this instruction must be the owner of all mints -//! supplied. Only one token program can be supplied to this instruction at a time. -//! If mints from two separate token programs are required, the client needs to +//! supplied. Only one token program can be supplied to this instruction at a time. +//! If mints from two separate token programs are required, the client needs to //! divide it into separate instructions. //! //! Wire format: `[discriminator=6]`, 1 byte. diff --git a/programs/settlement/src/token.rs b/programs/settlement/src/token.rs index f7afe1e..545b882 100644 --- a/programs/settlement/src/token.rs +++ b/programs/settlement/src/token.rs @@ -2,7 +2,7 @@ use cow_settlement_interface::{token_program::TokenProgram, SettlementError}; use pinocchio::{cpi::get_return_data, error::ProgramError, AccountView}; -use pinocchio_token::instructions::GetAccountDataSize; +use pinocchio_token::{instructions::GetAccountDataSize, state::Mint}; /// The length of a SPL token program account. Token2022 extensions may make /// the actual token account longer than this. @@ -22,6 +22,11 @@ pub fn token_account_len( token_program: TokenProgram, mint: &AccountView, ) -> Result { + // If the mint is of base SPL Mint length, the token accounts must be of base length accordingly. + if mint.data_len() <= Mint::LEN { + return Ok(BASE_TOKEN_ACCOUNT_LEN); + } + let token_program = token_program.address(); // SPL token provides a function to get the actual required account data size GetAccountDataSize::new(mint).invoke_with_unverified_program(&token_program)?; @@ -75,7 +80,6 @@ mod tests { instruction::fixtures::{fake_account, fake_account_owned_by}, }; use pinocchio::Address; - use pinocchio_token::state::Mint; use pinocchio_token_2022::state::AccountType; /// The length of a token account holding nothing but the base layout. Both @@ -109,6 +113,31 @@ mod tests { assert_eq!(BASE_LEN, pinocchio_token_2022::state::Account::BASE_LEN); } + #[test] + fn token_account_len_is_the_base_layout_for_a_plain_mint() { + for program in TokenProgram::ALL { + let mint = fake_account_owned_by( + pubkey_from_seed("mint"), + program.address(), + &[0u8; Mint::LEN], + ); + assert_eq!( + token_account_len(program, &mint), + Ok(BASE_TOKEN_ACCOUNT_LEN), + "a base-layout mint should need a base-layout account under {program:?}", + ); + } + } + + #[test] + fn token_account_len_is_the_base_layout_for_a_too_short_account() { + let mint = fake_account(pubkey_from_seed("mint")); + assert_eq!( + token_account_len(TokenProgram::SplToken, &mint), + Ok(BASE_TOKEN_ACCOUNT_LEN), + ); + } + #[test] fn token_account_len_reports_unavailable_without_an_answer() { let mint = fake_account_owned_by( From 90a87834b79cdb919f66ab5e42298025265011d2 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:38:58 +0900 Subject: [PATCH 13/27] fix bench --- bench-report.json | 52 +++++++++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/bench-report.json b/bench-report.json index aeb8109..cb301ab 100644 --- a/bench-report.json +++ b/bench-report.json @@ -30,34 +30,34 @@ "transfer_authority/reclaim_authority_can_transfer_itself": 4 }, "compute_units": { - "add_solver/add_with_many_existing_solvers": 5066, - "add_solver/adds_a_solver": 4614, - "create_buffers/happy_path_creates_initialized_buffer_token_account": 7367, - "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 17305, - "create_buffers/max_buffers_in_one_instruction": 170142, - "create_order/happy_path_creates_order_pda_with_expected_body": 4981, - "initialize/happy_path_initializes_state_pda_with_expected_data": 4523, - "reclaim_buffer/funded_buffer_is_skipped": 4854, - "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 6003, - "reclaim_buffer/max_buffers_in_one_instruction": 125165, - "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 7618, - "reclaim_order/happy_path_expired_returns_lamports_and_closes_pda": 2196, - "reclaim_order/happy_path_on_chain_order_cancelled_is_reclaimable_before_expiry": 2066, - "reclaim_order/happy_path_on_chain_order_fully_filled_is_reclaimable_before_expiry": 2074, + "add_solver/add_with_many_existing_solvers": 5074, + "add_solver/adds_a_solver": 4622, + "create_buffers/happy_path_creates_initialized_buffer_token_account": 7376, + "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 17290, + "create_buffers/max_buffers_in_one_instruction": 169803, + "create_order/happy_path_creates_order_pda_with_expected_body": 4985, + "initialize/happy_path_initializes_state_pda_with_expected_data": 4529, + "reclaim_buffer/funded_buffer_is_skipped": 4859, + "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 6009, + "reclaim_buffer/max_buffers_in_one_instruction": 124823, + "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 7611, + "reclaim_order/happy_path_expired_returns_lamports_and_closes_pda": 2202, + "reclaim_order/happy_path_on_chain_order_cancelled_is_reclaimable_before_expiry": 2072, + "reclaim_order/happy_path_on_chain_order_fully_filled_is_reclaimable_before_expiry": 2079, "reclaim_order/off_chain_order_is_reclaimable_only_once_expired": null, "reclaim_order/on_chain_order_partially_filled_is_not_reclaimable_before_expiry": null, - "settle/finalizes_with_no_pushes": 7126, - "settle/pulls_from_multiple_orders": 19997, - "settle/pulls_funds_to_destination": 13595, - "settle/pulls_to_multiple_destinations": 14735, - "settle/pushes_a_single_order": 12451, - "settle/pushes_several_orders_from_different_buffers": 17707, - "settle/pushes_several_orders_from_one_buffer": 17706, - "settle/settles_a_single_order": 12469, - "settle/settles_multiple_orders": 23010, - "transfer_authority/manager_can_transfer_manager": 3169, - "transfer_authority/manager_can_transfer_reclaim_authority": 3171, - "transfer_authority/reclaim_authority_can_transfer_itself": 3175 + "settle/finalizes_with_no_pushes": 7171, + "settle/pulls_from_multiple_orders": 20076, + "settle/pulls_funds_to_destination": 13657, + "settle/pulls_to_multiple_destinations": 14798, + "settle/pushes_a_single_order": 12512, + "settle/pushes_several_orders_from_different_buffers": 17784, + "settle/pushes_several_orders_from_one_buffer": 17783, + "settle/settles_a_single_order": 12530, + "settle/settles_multiple_orders": 23103, + "transfer_authority/manager_can_transfer_manager": 3174, + "transfer_authority/manager_can_transfer_reclaim_authority": 3176, + "transfer_authority/reclaim_authority_can_transfer_itself": 3180 }, "transaction_bytes": { "add_solver/add_with_many_existing_solvers": 366, From 1d5ac4600d633222cfbe5174fdad32f9bc10b2a0 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:25:17 +0900 Subject: [PATCH 14/27] Update programs/settlement/src/token.rs Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com> --- programs/settlement/src/token.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/programs/settlement/src/token.rs b/programs/settlement/src/token.rs index 545b882..5758e25 100644 --- a/programs/settlement/src/token.rs +++ b/programs/settlement/src/token.rs @@ -102,8 +102,13 @@ mod tests { /// dependency, so this is what keeps them from drifting apart. #[test] fn interface_and_pinocchio_agree_on_the_program_ids() { - assert_eq!(TokenProgram::SplToken.address(), pinocchio_token::ID); - assert_eq!(TokenProgram::Token2022.address(), pinocchio_token_2022::ID); + for program in TokenProgram::ALL { + let pinocchio_id = match program { + TokenProgram::SplToken => pinocchio_token::ID, + TokenProgram::Token2022 => pinocchio_token_2022::ID, + }; + assert_eq!(program.address(), pinocchio_id); + } } /// The base layout is the same under both programs, so one reader's idea of From 08fc979b6bfcb20cf15a2fc910403b5931f09ed6 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:50:13 +0900 Subject: [PATCH 15/27] Update Cargo.toml Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com> --- Cargo.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index dfddf51..e691330 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,8 +60,6 @@ solana-system-interface = "3" spl-associated-token-account-interface = "2" spl-token = "9" spl-token-2022-interface = "3" -# Only the test CLI still builds against the legacy interface; it moves over -# together with its Token-2022 support. spl-token-interface = "3" syn = { version = "2", features = ["full"] } From b427f49adef40928e8222aa9d11a92c3c360ac08 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:50:20 +0900 Subject: [PATCH 16/27] Update programs/settlement/src/reclaim_buffer.rs Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com> --- programs/settlement/src/reclaim_buffer.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/programs/settlement/src/reclaim_buffer.rs b/programs/settlement/src/reclaim_buffer.rs index 9c5961e..b704669 100644 --- a/programs/settlement/src/reclaim_buffer.rs +++ b/programs/settlement/src/reclaim_buffer.rs @@ -93,7 +93,6 @@ mod tests { const AUTHORITY: Address = Address::new_from_array([101; 32]); const MANAGER: Address = Address::new_from_array([102; 32]); const UNRELATED: Address = Address::new_from_array([254; 32]); - /// The token program these fixtures build their buffers under. const SPL_TOKEN_PROGRAM_ID: Address = TokenProgram::SplToken.address(); /// Number of accounts in a one-buffer reclaim: the shared ones plus a From a0910475a9c3d1fff6cdf737cb982a433b89cf42 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:50:52 +0900 Subject: [PATCH 17/27] Update programs/settlement/tests/common/token.rs Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com> --- programs/settlement/tests/common/token.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/programs/settlement/tests/common/token.rs b/programs/settlement/tests/common/token.rs index 6889c46..6d50539 100644 --- a/programs/settlement/tests/common/token.rs +++ b/programs/settlement/tests/common/token.rs @@ -49,11 +49,6 @@ pub fn create_mint(svm: &mut LiteSVM, payer: &Keypair) -> Pubkey { } /// Seed the wrapped-SOL mint account, which `LiteSVM` does not create. -/// -/// On chain the native mint is a real, token-program-owned mint account, and -/// the program queries it with `GetAccountDataSize` to size a buffer. That -/// query rejects an account the token program does not own, so a test touching -/// the native mint has to put the account there first. pub fn create_native_mint(svm: &mut LiteSVM) { /// The native mint's fixed decimals, matching `spl_token::native_mint`. const DECIMALS: u8 = 9; From 641c08cb03461a80ee086d922387c474a5663a9c Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:51:24 +0900 Subject: [PATCH 18/27] Update interface/src/token_program.rs Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com> --- interface/src/token_program.rs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/interface/src/token_program.rs b/interface/src/token_program.rs index df70efd..c84b298 100644 --- a/interface/src/token_program.rs +++ b/interface/src/token_program.rs @@ -44,16 +44,11 @@ mod tests { use super::*; use crate::fixtures::pubkey_from_seed; - /// Adding a variant breaks this match, which is the reminder to add it to - /// [`TokenProgram::ALL`] as well - #[test] - fn all_lists_every_variant() { - for program in TokenProgram::ALL { - match program { - TokenProgram::SplToken | TokenProgram::Token2022 => {} - } - } - } + /// Adding a variant makes this match non-exhaustive, the compile-time reminder + /// to add it to [`TokenProgram::ALL`] as well. + const _: () = match TokenProgram::SplToken { + TokenProgram::SplToken | TokenProgram::Token2022 => (), + }; #[test] fn every_program_resolves_from_its_own_address() { From e3ccd22ee7465b50c5d70a32bb2ba1d8e03975ed Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:56:40 +0900 Subject: [PATCH 19/27] use new tokenprgoram enum in client instructions --- client/src/instructions.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/client/src/instructions.rs b/client/src/instructions.rs index 849c92c..7e0c40e 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -6,9 +6,7 @@ //! by the function, making it more suitable for off-chain use. use cow_settlement_interface::{ - data::intent::{EncodedOrderIntent, OrderIntent}, - pda::{buffer::find_buffer_pda, order::find_order_pda, state::find_state_pda}, - Instruction, Pubkey, Role, + Instruction, Pubkey, Role, data::intent::{EncodedOrderIntent, OrderIntent}, pda::{buffer::find_buffer_pda, order::find_order_pda, state::find_state_pda}, token_program::TokenProgram, }; // Reexport the instruction builders that don't change from the interface. @@ -148,7 +146,7 @@ impl From> for Instruction { pub struct CreateBuffers<'a> { pub program_id: Pubkey, pub payer: Pubkey, - pub token_program: Pubkey, + pub token_program: TokenProgram, pub mints: &'a [Pubkey], } @@ -162,7 +160,7 @@ impl From> for Instruction { cow_settlement_interface::instruction::create_buffer::CreateBuffers { program_id: builder.program_id, payer: builder.payer, - token_program: builder.token_program, + token_program: builder.token_program.address(), buffers: &buffers, } .into() @@ -201,7 +199,7 @@ pub struct ReclaimBuffer<'a> { pub program_id: Pubkey, pub reclaim_authority: Pubkey, pub reclaim_recipient: Pubkey, - pub token_program: Pubkey, + pub token_program: TokenProgram, pub mints: &'a [Pubkey], } @@ -221,7 +219,7 @@ impl From> for Instruction { state_pda, reclaim_authority: builder.reclaim_authority, reclaim_recipient: builder.reclaim_recipient, - token_program: builder.token_program, + token_program: builder.token_program.address(), buffers: &buffers, } .into() From 1d2106b3b77c16a9924a1724ad228e148f0d3c0e Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:35:48 +0900 Subject: [PATCH 20/27] switch to using tokenProgram in the client caused other changes --- client/src/parse.rs | 9 ++--- programs/settlement/tests/common/buffer.rs | 5 ++- programs/settlement/tests/create_buffer.rs | 44 ++++++++++----------- programs/settlement/tests/reclaim_buffer.rs | 22 +++++------ test-cli/src/cmd/settle.rs | 2 +- 5 files changed, 40 insertions(+), 42 deletions(-) diff --git a/client/src/parse.rs b/client/src/parse.rs index ce15100..45462d6 100644 --- a/client/src/parse.rs +++ b/client/src/parse.rs @@ -77,13 +77,10 @@ mod tests { InitializedIntent, }; use cow_settlement_interface::{ - data::intent::fixtures::sample_intent, - fixtures::pubkey_from_seed, - instruction::{ + Instruction, Role, data::intent::fixtures::sample_intent, fixtures::pubkey_from_seed, instruction::{ fixtures::fake_account_from_array, reclaim_buffer::ReclaimBuffer, reclaim_order::ReclaimOrder, transfer_authority::TransferAuthority, - }, - Instruction, Role, + }, token_program::TokenProgram, }; /// One buildable instruction per discriminator. The exhaustive match makes @@ -110,7 +107,7 @@ mod tests { SettlementInstruction::CreateBuffer => CreateBuffers { program_id, payer, - token_program: pubkey_from_seed("token program"), + token_program: TokenProgram::SplToken, mints: &[pubkey_from_seed("mint")], } .into(), diff --git a/programs/settlement/tests/common/buffer.rs b/programs/settlement/tests/common/buffer.rs index b663832..07b629d 100644 --- a/programs/settlement/tests/common/buffer.rs +++ b/programs/settlement/tests/common/buffer.rs @@ -3,6 +3,7 @@ use cow_settlement_client::cow_settlement_interface::pda::buffer::find_buffer_pda; use cow_settlement_client::cow_settlement_interface::Instruction; use cow_settlement_client::instructions::CreateBuffers; +use cow_settlement_interface::token_program::TokenProgram; use litesvm::LiteSVM; use solana_sdk::{ pubkey::Pubkey, @@ -10,7 +11,7 @@ use solana_sdk::{ transaction::Transaction, }; -use super::{token, SPL_TOKEN_PROGRAM_ID}; +use super::token; /// The canonical buffer PDA for `mint`. pub fn buffer_pda(program_id: &Pubkey, mint: &Pubkey) -> Pubkey { @@ -33,7 +34,7 @@ pub fn ensure_buffer_exists( let ix = Instruction::from(CreateBuffers { program_id: *program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[*mint], }); let tx = Transaction::new_signed_with_payer( diff --git a/programs/settlement/tests/create_buffer.rs b/programs/settlement/tests/create_buffer.rs index a826093..0dcede6 100644 --- a/programs/settlement/tests/create_buffer.rs +++ b/programs/settlement/tests/create_buffer.rs @@ -6,6 +6,7 @@ use cow_settlement_client::cow_settlement_interface::{ }, }; use cow_settlement_client::instructions::CreateBuffers; +use cow_settlement_interface::token_program::TokenProgram; use litesvm::LiteSVM; use litesvm_token::{ get_spl_account, @@ -28,7 +29,7 @@ use solana_sdk::{ use crate::common::{ benchmark::{send_transaction_metered, BenchLabel}, - unique_keypair, unique_pubkey, SPL_TOKEN_PROGRAM_ID, + unique_keypair, unique_pubkey, }; mod common; @@ -43,7 +44,7 @@ fn happy_path_creates_initialized_buffer_token_account() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[mint], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -54,7 +55,7 @@ fn happy_path_creates_initialized_buffer_token_account() { .get_account(&buffer_pda) .expect("buffer PDA should exist after create_buffer"); assert_eq!( - account.owner, SPL_TOKEN_PROGRAM_ID, + account.owner, TokenProgram::SplToken.address(), "buffer must be owned by the SPL Token program" ); assert_eq!( @@ -111,7 +112,7 @@ fn buffer_can_receive_tokens() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[mint], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -144,13 +145,12 @@ fn happy_path_creates_native_token_buffer() { // and the buffer is initialized as a wrapped-SOL account. Since we fund // exactly the rent-exempt minimum, the wrapped balance starts at zero. let (mut svm, program_id, payer) = common::setup(); - common::token::create_native_mint(&mut svm); let (buffer_pda, _bump) = find_buffer_pda(&program_id, &native_mint::ID); let ix = CreateBuffers { program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[native_mint::ID], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -186,7 +186,7 @@ fn happy_path_creates_multiple_buffers_in_one_instruction() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &mints, }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -199,7 +199,7 @@ fn happy_path_creates_multiple_buffers_in_one_instruction() { .get_account(&buffer_pda) .expect("each buffer PDA should exist after create_buffers"); assert_eq!( - account.owner, SPL_TOKEN_PROGRAM_ID, + account.owner, TokenProgram::SplToken.address(), "each buffer must be owned by the SPL Token program" ); assert_eq!( @@ -231,7 +231,7 @@ fn rejects_no_buffers() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -260,7 +260,7 @@ fn rejects_arbitrary_wrong_buffer_pda() { let ix = CreateBuffersRaw { program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken.address(), buffers: &[(wrong_pda, mint)], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -281,7 +281,7 @@ fn rejects_non_canonical_bump_pda() { let ix = CreateBuffersRaw { program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken.address(), buffers: &[(non_canonical_pda, mint)], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -298,13 +298,13 @@ fn rejects_non_spl_token_program() { let mut ix: Instruction = CreateBuffers { program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[mint], } .into(); let token_program_index = 2; assert_eq!( - ix.accounts[token_program_index].pubkey, SPL_TOKEN_PROGRAM_ID, + ix.accounts[token_program_index].pubkey, TokenProgram::SplToken.address(), "sanity: should replace token program" ); ix.accounts[token_program_index].pubkey = unique_pubkey(); @@ -342,7 +342,7 @@ fn rejects_invalid_mint() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[not_a_mint], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -376,7 +376,7 @@ fn creates_buffer_when_address_is_prefunded() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[mint], }; common::signed_tx(svm, &payer, &payer, ix) @@ -393,7 +393,7 @@ fn recreating_same_buffer_is_idempotent() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[mint], }; common::signed_tx(svm, &payer, &payer, ix) @@ -411,7 +411,7 @@ fn batch_with_existing_buffer_passes_with_no_changes() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[existing], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -426,7 +426,7 @@ fn batch_with_existing_buffer_passes_with_no_changes() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[fresh, existing], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -462,7 +462,7 @@ fn one_failing_buffer_reverts_the_whole_batch() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[fresh, not_a_mint], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -497,7 +497,7 @@ fn same_mint_twice_in_one_instruction_is_idempotent() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[mint, mint], }; let tx = common::signed_tx(&svm, &payer, &payer, ix); @@ -523,7 +523,7 @@ fn max_buffers_via_lookup_table(svm: &mut LiteSVM, program_id: &Pubkey, payer: & let ix = CreateBuffersRaw { program_id: *program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken.address(), buffers: &buffers, }; common::lookup_table::lookup_table_tx(svm, payer, ix) @@ -591,7 +591,7 @@ fn max_buffers_in_one_instruction() { let ix = CreateBuffers { program_id, payer: payer.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &mints, }; let tx = common::lookup_table::lookup_table_tx(&mut svm, &payer, ix); diff --git a/programs/settlement/tests/reclaim_buffer.rs b/programs/settlement/tests/reclaim_buffer.rs index 5231ed1..3bc58b3 100644 --- a/programs/settlement/tests/reclaim_buffer.rs +++ b/programs/settlement/tests/reclaim_buffer.rs @@ -1,5 +1,6 @@ use cow_settlement_client::instructions::ReclaimBuffer; use cow_settlement_interface::Instruction; +use cow_settlement_interface::token_program::TokenProgram; use cow_settlement_interface::{ instruction::reclaim_buffer::ReclaimBuffer as ReclaimBufferRaw, pda::buffer::find_buffer_pda, pda::state::find_state_pda, SettlementError, @@ -15,7 +16,6 @@ use crate::common::benchmark::{send_transaction_metered, BenchLabel}; use crate::common::buffer::ensure_buffer_exists; use crate::common::{ assert_instruction_error, to_instruction_error, unique_pubkey, InitializedParams, - SPL_TOKEN_PROGRAM_ID, }; mod common; @@ -47,7 +47,7 @@ fn happy_path_reclaims_to_a_recipient_chosen_by_the_authority() { program_id, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: recipient, - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[mint], }; let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); @@ -95,7 +95,7 @@ fn happy_path_reclaims_empty_buffer_to_the_authority_itself() { program_id, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: reclaim_authority.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[mint], }; let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); @@ -135,7 +135,7 @@ fn funded_buffer_is_skipped() { program_id, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: reclaim_authority.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[mint], }; let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); @@ -181,7 +181,7 @@ fn reclaims_to_the_settlements_own_state_pda() { program_id, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: recipient, - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[mint], }; let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); @@ -230,7 +230,7 @@ fn reclaims_multiple_buffers_skipping_funded() { program_id, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: reclaim_authority.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[mint_a, mint_b], }; let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); @@ -267,7 +267,7 @@ fn rejects_the_same_buffer_twice_in_one_instruction() { program_id, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: recipient, - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[mint, mint], }; let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); @@ -297,7 +297,7 @@ fn rejects_when_signer_is_not_the_configured_reclaim_authority() { program_id, reclaim_authority: impostor.pubkey(), reclaim_recipient: impostor.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[mint], }; let tx = common::signed_tx(&svm, &payer, &impostor, ix); @@ -329,7 +329,7 @@ fn rejects_when_the_reclaim_authority_does_not_sign() { program_id, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: recipient, - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &[mint], }); @@ -369,7 +369,7 @@ fn max_buffers_reclaim_via_lookup_table( state_pda, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: reclaim_authority.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken.address(), buffers: &buffers, }; common::lookup_table::lookup_table_tx(svm, reclaim_authority, ix) @@ -448,7 +448,7 @@ fn max_buffers_in_one_instruction() { program_id, reclaim_authority: reclaim_authority.pubkey(), reclaim_recipient: reclaim_authority.pubkey(), - token_program: SPL_TOKEN_PROGRAM_ID, + token_program: TokenProgram::SplToken, mints: &mints, }; let tx = common::lookup_table::lookup_table_tx(&mut svm, &reclaim_authority, ix); diff --git a/test-cli/src/cmd/settle.rs b/test-cli/src/cmd/settle.rs index e0a25f8..9efb0b8 100644 --- a/test-cli/src/cmd/settle.rs +++ b/test-cli/src/cmd/settle.rs @@ -303,7 +303,7 @@ fn prepare_setup_ixs( program_id: ctx.program_id, payer: ctx.payer.pubkey(), // The CLI only resolves tokens on the legacy program for now. - token_program: TokenProgram::SplToken.address(), + token_program: TokenProgram::SplToken, mints: &mint_buffers_to_create.into_iter().collect::>(), } .into(), From ada2e5a886f3e17d472f995fa222c61a32865827 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:09:32 +0900 Subject: [PATCH 21/27] remove the check --- client/src/instructions.rs | 5 ++++- client/src/parse.rs | 8 ++++++-- programs/settlement/src/token.rs | 4 ---- programs/settlement/tests/create_buffer.rs | 9 ++++++--- programs/settlement/tests/reclaim_buffer.rs | 2 +- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/client/src/instructions.rs b/client/src/instructions.rs index 7e0c40e..bd8f951 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -6,7 +6,10 @@ //! by the function, making it more suitable for off-chain use. use cow_settlement_interface::{ - Instruction, Pubkey, Role, data::intent::{EncodedOrderIntent, OrderIntent}, pda::{buffer::find_buffer_pda, order::find_order_pda, state::find_state_pda}, token_program::TokenProgram, + data::intent::{EncodedOrderIntent, OrderIntent}, + pda::{buffer::find_buffer_pda, order::find_order_pda, state::find_state_pda}, + token_program::TokenProgram, + Instruction, Pubkey, Role, }; // Reexport the instruction builders that don't change from the interface. diff --git a/client/src/parse.rs b/client/src/parse.rs index 45462d6..6341077 100644 --- a/client/src/parse.rs +++ b/client/src/parse.rs @@ -77,10 +77,14 @@ mod tests { InitializedIntent, }; use cow_settlement_interface::{ - Instruction, Role, data::intent::fixtures::sample_intent, fixtures::pubkey_from_seed, instruction::{ + data::intent::fixtures::sample_intent, + fixtures::pubkey_from_seed, + instruction::{ fixtures::fake_account_from_array, reclaim_buffer::ReclaimBuffer, reclaim_order::ReclaimOrder, transfer_authority::TransferAuthority, - }, token_program::TokenProgram, + }, + token_program::TokenProgram, + Instruction, Role, }; /// One buildable instruction per discriminator. The exhaustive match makes diff --git a/programs/settlement/src/token.rs b/programs/settlement/src/token.rs index 545b882..530856a 100644 --- a/programs/settlement/src/token.rs +++ b/programs/settlement/src/token.rs @@ -40,10 +40,6 @@ pub fn token_account_len( .try_into() .map_err(|_| SettlementError::BufferSizeUnavailable)?; let length = u64::from_le_bytes(length); - // A token account is at least its base layout, whatever its mint carries. - if length < BASE_TOKEN_ACCOUNT_LEN { - return Err(SettlementError::BufferSizeUnavailable.into()); - } Ok(length) } diff --git a/programs/settlement/tests/create_buffer.rs b/programs/settlement/tests/create_buffer.rs index 0dcede6..ee10636 100644 --- a/programs/settlement/tests/create_buffer.rs +++ b/programs/settlement/tests/create_buffer.rs @@ -55,7 +55,8 @@ fn happy_path_creates_initialized_buffer_token_account() { .get_account(&buffer_pda) .expect("buffer PDA should exist after create_buffer"); assert_eq!( - account.owner, TokenProgram::SplToken.address(), + account.owner, + TokenProgram::SplToken.address(), "buffer must be owned by the SPL Token program" ); assert_eq!( @@ -199,7 +200,8 @@ fn happy_path_creates_multiple_buffers_in_one_instruction() { .get_account(&buffer_pda) .expect("each buffer PDA should exist after create_buffers"); assert_eq!( - account.owner, TokenProgram::SplToken.address(), + account.owner, + TokenProgram::SplToken.address(), "each buffer must be owned by the SPL Token program" ); assert_eq!( @@ -304,7 +306,8 @@ fn rejects_non_spl_token_program() { .into(); let token_program_index = 2; assert_eq!( - ix.accounts[token_program_index].pubkey, TokenProgram::SplToken.address(), + ix.accounts[token_program_index].pubkey, + TokenProgram::SplToken.address(), "sanity: should replace token program" ); ix.accounts[token_program_index].pubkey = unique_pubkey(); diff --git a/programs/settlement/tests/reclaim_buffer.rs b/programs/settlement/tests/reclaim_buffer.rs index 3bc58b3..3267353 100644 --- a/programs/settlement/tests/reclaim_buffer.rs +++ b/programs/settlement/tests/reclaim_buffer.rs @@ -1,6 +1,6 @@ use cow_settlement_client::instructions::ReclaimBuffer; -use cow_settlement_interface::Instruction; use cow_settlement_interface::token_program::TokenProgram; +use cow_settlement_interface::Instruction; use cow_settlement_interface::{ instruction::reclaim_buffer::ReclaimBuffer as ReclaimBufferRaw, pda::buffer::find_buffer_pda, pda::state::find_state_pda, SettlementError, From 11947620f30fbfc0a5014a1a4c0c1002fa666091 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:04:30 +0900 Subject: [PATCH 22/27] remove superfluous test --- programs/settlement/src/token.rs | 9 --------- 1 file changed, 9 deletions(-) diff --git a/programs/settlement/src/token.rs b/programs/settlement/src/token.rs index 530856a..b3042cc 100644 --- a/programs/settlement/src/token.rs +++ b/programs/settlement/src/token.rs @@ -125,15 +125,6 @@ mod tests { } } - #[test] - fn token_account_len_is_the_base_layout_for_a_too_short_account() { - let mint = fake_account(pubkey_from_seed("mint")); - assert_eq!( - token_account_len(TokenProgram::SplToken, &mint), - Ok(BASE_TOKEN_ACCOUNT_LEN), - ); - } - #[test] fn token_account_len_reports_unavailable_without_an_answer() { let mint = fake_account_owned_by( From f74498bfb6a2d30773790942f79cab37ecef6773 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:08:18 +0900 Subject: [PATCH 23/27] refactor `base_layout` to use the actual encoder, and rename for clarity --- programs/settlement/src/token.rs | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/programs/settlement/src/token.rs b/programs/settlement/src/token.rs index 20aa89b..fff870d 100644 --- a/programs/settlement/src/token.rs +++ b/programs/settlement/src/token.rs @@ -75,8 +75,10 @@ mod tests { fixtures::pubkey_from_seed, instruction::fixtures::{fake_account, fake_account_owned_by}, }; + use litesvm_token::spl_token::state::{Account as SplTokenAccount, AccountState}; use pinocchio::Address; use pinocchio_token_2022::state::AccountType; + use solana_program_pack::Pack; /// The length of a token account holding nothing but the base layout. Both /// programs share it: it is Token-2022's `BASE_LEN` and the whole of a @@ -84,12 +86,18 @@ mod tests { const BASE_LEN: usize = pinocchio_token::state::Account::LEN; /// The base layout of a token account holding `amount` of `mint` for - /// `owner`, with every other field left zeroed. - fn base_layout(mint: Address, owner: Address, amount: u64) -> Vec { + /// `owner`, encoded by the SPL token program's own packer so the fixture + /// cannot drift from the layout the readers parse. + fn base_account_layout(mint: Address, owner: Address, amount: u64) -> Vec { let mut data = vec![0u8; BASE_LEN]; - data[..32].copy_from_slice(mint.as_array()); - data[32..64].copy_from_slice(owner.as_array()); - data[64..72].copy_from_slice(&amount.to_le_bytes()); + SplTokenAccount { + mint, + owner, + amount, + state: AccountState::Initialized, + ..Default::default() + } + .pack_into_slice(&mut data); data } @@ -168,7 +176,7 @@ mod tests { let account = fake_account_owned_by( pubkey_from_seed("token account"), program.address(), - &base_layout(mint, owner, 4_200), + &base_account_layout(mint, owner, 4_200), ); let read = read_token_account(program, &account) .unwrap_or_else(|error| panic!("{program:?} account should read: {error:?}")); @@ -180,7 +188,7 @@ mod tests { fn read_token_account_reads_past_token_2022_extensions() { let mint = pubkey_from_seed("extended mint"); let owner = pubkey_from_seed("extended owner"); - let mut data = base_layout(mint, owner, 7); + let mut data = base_account_layout(mint, owner, 7); // Extensions are preceded by the account-type marker, which is what // distinguishes a longer account from a mint of the same size. data.push(AccountType::Account as u8); @@ -198,7 +206,7 @@ mod tests { #[test] fn read_token_account_rejects_an_extended_mint() { - let mut data = base_layout(pubkey_from_seed("mint"), pubkey_from_seed("owner"), 7); + let mut data = base_account_layout(pubkey_from_seed("mint"), pubkey_from_seed("owner"), 7); data.push(AccountType::Mint as u8); let account = fake_account_owned_by( @@ -221,7 +229,7 @@ mod tests { let account = fake_account_owned_by( pubkey_from_seed("token account"), other.address(), - &base_layout(pubkey_from_seed("mint"), pubkey_from_seed("owner"), 0), + &base_account_layout(pubkey_from_seed("mint"), pubkey_from_seed("owner"), 0), ); assert_eq!( read_token_account(program, &account).err(), From 2907aaa50253348a156167a6224bd1be37b75c41 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:14:35 +0900 Subject: [PATCH 24/27] update test and spl token mint behavior --- programs/settlement/src/token.rs | 36 +++++++++++++++++--------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/programs/settlement/src/token.rs b/programs/settlement/src/token.rs index fff870d..5f284c7 100644 --- a/programs/settlement/src/token.rs +++ b/programs/settlement/src/token.rs @@ -1,8 +1,11 @@ //! Token-program validation and token-account reads -use cow_settlement_interface::{token_program::TokenProgram, SettlementError}; +use cow_settlement_interface::{ + token_program::TokenProgram::{self, SplToken}, + SettlementError, +}; use pinocchio::{cpi::get_return_data, error::ProgramError, AccountView}; -use pinocchio_token::{instructions::GetAccountDataSize, state::Mint}; +use pinocchio_token::instructions::GetAccountDataSize; /// The length of a SPL token program account. Token2022 extensions may make /// the actual token account longer than this. @@ -22,8 +25,8 @@ pub fn token_account_len( token_program: TokenProgram, mint: &AccountView, ) -> Result { - // If the mint is of base SPL Mint length, the token accounts must be of base length accordingly. - if mint.data_len() <= Mint::LEN { + // Early return for SPL token (saves the GetAccountDataSize CPI call) + if token_program == SplToken { return Ok(BASE_TOKEN_ACCOUNT_LEN); } @@ -77,6 +80,7 @@ mod tests { }; use litesvm_token::spl_token::state::{Account as SplTokenAccount, AccountState}; use pinocchio::Address; + use pinocchio_token::state::Mint; use pinocchio_token_2022::state::AccountType; use solana_program_pack::Pack; @@ -123,19 +127,17 @@ mod tests { } #[test] - fn token_account_len_is_the_base_layout_for_a_plain_mint() { - for program in TokenProgram::ALL { - let mint = fake_account_owned_by( - pubkey_from_seed("mint"), - program.address(), - &[0u8; Mint::LEN], - ); - assert_eq!( - token_account_len(program, &mint), - Ok(BASE_TOKEN_ACCOUNT_LEN), - "a base-layout mint should need a base-layout account under {program:?}", - ); - } + fn token_account_len_is_base_length_for_spl_program() { + let mint = fake_account_owned_by( + pubkey_from_seed("mint"), + SplToken.address(), + &[0u8; Mint::LEN], + ); + assert_eq!( + token_account_len(SplToken, &mint), + Ok(BASE_TOKEN_ACCOUNT_LEN), + "SPL owned mint should yield ase length", + ); } #[test] From 62776ff499ec4a89717c9552053509f8b74f6fa0 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:09:57 +0900 Subject: [PATCH 25/27] add some tests for wierd edge cases with recreating mints --- Cargo.lock | 1 + programs/settlement/Cargo.toml | 1 + programs/settlement/tests/common/buffer.rs | 14 +- programs/settlement/tests/common/mod.rs | 1 + programs/settlement/tests/common/token.rs | 10 +- .../settlement/tests/common/token_2022.rs | 187 ++++++++++++++++++ programs/settlement/tests/create_buffer.rs | 44 +++++ programs/settlement/tests/reclaim_buffer.rs | 136 +++++++++++++ 8 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 programs/settlement/tests/common/token_2022.rs diff --git a/Cargo.lock b/Cargo.lock index b4c5540..7428009 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3341,6 +3341,7 @@ dependencies = [ "solana-sdk", "solana-sha256-hasher", "solana-system-interface 3.2.0", + "spl-token-2022-interface 3.1.1", "syn 2.0.119", ] diff --git a/programs/settlement/Cargo.toml b/programs/settlement/Cargo.toml index 00a3ccd..9f9e01d 100644 --- a/programs/settlement/Cargo.toml +++ b/programs/settlement/Cargo.toml @@ -42,6 +42,7 @@ solana-program-pack.workspace = true solana-sdk.workspace = true solana-sha256-hasher.workspace = true solana-system-interface.workspace = true +spl-token-2022-interface.workspace = true syn.workspace = true [lints] diff --git a/programs/settlement/tests/common/buffer.rs b/programs/settlement/tests/common/buffer.rs index 07b629d..b8f9070 100644 --- a/programs/settlement/tests/common/buffer.rs +++ b/programs/settlement/tests/common/buffer.rs @@ -26,6 +26,18 @@ pub fn ensure_buffer_exists( program_id: &Pubkey, payer: &Keypair, mint: &Pubkey, +) -> Pubkey { + ensure_buffer_exists_for(svm, program_id, payer, mint, TokenProgram::SplToken) +} + +/// [`ensure_buffer_exists`] under a token program of the caller's choosing, for +/// the tests that need a buffer belonging to Token-2022. +pub fn ensure_buffer_exists_for( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + mint: &Pubkey, + token_program: TokenProgram, ) -> Pubkey { let pda = buffer_pda(program_id, mint); if svm.get_account(&pda).is_some() { @@ -34,7 +46,7 @@ pub fn ensure_buffer_exists( let ix = Instruction::from(CreateBuffers { program_id: *program_id, payer: payer.pubkey(), - token_program: TokenProgram::SplToken, + token_program, mints: &[*mint], }); let tx = Transaction::new_signed_with_payer( diff --git a/programs/settlement/tests/common/mod.rs b/programs/settlement/tests/common/mod.rs index be3d52a..995cc7b 100644 --- a/programs/settlement/tests/common/mod.rs +++ b/programs/settlement/tests/common/mod.rs @@ -13,6 +13,7 @@ pub mod pda; pub mod settlement; pub mod state; pub mod token; +pub mod token_2022; use cow_settlement_client::instructions::{AddSolver, Initialize}; use cow_settlement_interface::pda::state::find_state_pda; diff --git a/programs/settlement/tests/common/token.rs b/programs/settlement/tests/common/token.rs index 6d50539..709b229 100644 --- a/programs/settlement/tests/common/token.rs +++ b/programs/settlement/tests/common/token.rs @@ -24,10 +24,16 @@ use super::unique_keypair; /// buffer PDA, so a random one makes buffer bumps — and the compute cost of /// deriving them — vary between runs. See [`super::unique_pubkey`]. pub fn create_mint(svm: &mut LiteSVM, payer: &Keypair) -> Pubkey { + create_mint_at(svm, payer, &unique_keypair()) +} + +/// [`create_mint`] at `mint`'s address rather than a fresh one. Lets a test +/// reclaim an address a Token-2022 mint was just closed at, which is the only +/// way a legacy mint can end up where a Token-2022 one used to be. +pub fn create_mint_at(svm: &mut LiteSVM, payer: &Keypair, mint: &Keypair) -> Pubkey { /// `litesvm_token::CreateMint`'s default, kept so the two agree. const DECIMALS: u8 = 8; - let mint = unique_keypair(); let create = system_create_account( &payer.pubkey(), &mint.pubkey(), @@ -40,7 +46,7 @@ pub fn create_mint(svm: &mut LiteSVM, payer: &Keypair) -> Pubkey { let tx = Transaction::new_signed_with_payer( &[create, initialize], Some(&payer.pubkey()), - &[payer, &mint], + &[payer, mint], svm.latest_blockhash(), ); svm.send_transaction(tx) diff --git a/programs/settlement/tests/common/token_2022.rs b/programs/settlement/tests/common/token_2022.rs new file mode 100644 index 0000000..b13e52b --- /dev/null +++ b/programs/settlement/tests/common/token_2022.rs @@ -0,0 +1,187 @@ +//! Token-2022 helpers for the settlement integration tests. +//! +//! Unlike the legacy program, Token-2022 lets a mint be closed and its address +//! reused for something else entirely. A buffer PDA is derived from the mint +//! address alone, so a buffer outlives the mint it was created for. These +//! helpers drive that lifecycle: create a mint under a chosen extension set, +//! close it, and put a different mint at the same address. + +use cow_settlement_interface::token_program::TokenProgram; +use litesvm::LiteSVM; +use solana_sdk::{ + instruction::Instruction, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::Transaction, +}; +use solana_system_interface::instruction::create_account as system_create_account; +use spl_token_2022_interface::{ + extension::{transfer_fee::instruction::initialize_transfer_fee_config, ExtensionType}, + instruction::{ + close_account, initialize_mint2, initialize_mint_close_authority, + initialize_non_transferable_mint, + }, + state::{Account, Mint}, +}; + +/// The Token-2022 program, the counterpart of [`super::SPL_TOKEN_PROGRAM_ID`]. +const TOKEN_2022_PROGRAM_ID: Pubkey = TokenProgram::Token2022.address(); + +/// Decimals every test mint carries, matching [`super::token::create_mint`] so +/// a legacy and a Token-2022 mint differ only in their program. +const DECIMALS: u8 = 8; + +/// Transfer-fee parameters for [`Extensions::WithTransferFee`]. Arbitrary; +/// nothing reads them back, but `InitializeTransferFeeConfig` demands values. +const FEE_BASIS_POINTS: u16 = 50; +const MAXIMUM_FEE: u64 = 1_000; + +/// The extension set a test mint is created with. +#[derive(Clone, Copy, Debug)] +pub enum Extensions { + CloseAuthorityOnly, + /// Non-transferable + CloseAuthority + WithNonTransferable, + /// Transfer Fee + CloseAuthority + WithTransferFee, +} + +impl Extensions { + /// The extensions the mint itself is initialized with. + fn mint(self) -> &'static [ExtensionType] { + match self { + Self::CloseAuthorityOnly => &[ExtensionType::MintCloseAuthority], + Self::WithNonTransferable => &[ + ExtensionType::MintCloseAuthority, + ExtensionType::NonTransferable, + ], + Self::WithTransferFee => &[ + ExtensionType::MintCloseAuthority, + ExtensionType::TransferFeeConfig, + ], + } + } + + /// The extensions Token-2022 requires of a token account holding the mint. + /// Spelled out rather than derived from [`Self::mint`], so the length a test + /// expects is stated independently of the program's own bookkeeping. + fn token_account(self) -> &'static [ExtensionType] { + match self { + Self::CloseAuthorityOnly => &[], + Self::WithNonTransferable => &[ + ExtensionType::NonTransferableAccount, + ExtensionType::ImmutableOwner, + ], + Self::WithTransferFee => &[ExtensionType::TransferFeeAmount], + } + } + + /// The data length a token account holding the mint has to be allocated at, + /// which is what `create_buffer` asks the token program for. + pub fn token_account_len(self) -> usize { + ExtensionType::try_calculate_account_len::(self.token_account()) + .expect("every account extension used here has a fixed length") + } + + /// The instructions initializing the extensions on `mint`, with `authority` + /// filling every authority they ask for. Token-2022 requires all of them to + /// run before `InitializeMint`, and insists the mint be allocated at exactly + /// the length they need. + fn initializers(self, mint: &Pubkey, authority: &Pubkey) -> Vec { + self.mint() + .iter() + .map(|extension| { + match extension { + ExtensionType::MintCloseAuthority => initialize_mint_close_authority( + &TOKEN_2022_PROGRAM_ID, + mint, + Some(authority), + ), + ExtensionType::NonTransferable => { + initialize_non_transferable_mint(&TOKEN_2022_PROGRAM_ID, mint) + } + ExtensionType::TransferFeeConfig => initialize_transfer_fee_config( + &TOKEN_2022_PROGRAM_ID, + mint, + Some(authority), + Some(authority), + FEE_BASIS_POINTS, + MAXIMUM_FEE, + ), + other => panic!("no initializer is wired up for {other:?}"), + } + .expect("extension initializer should build") + }) + .collect() + } +} + +/// Create a Token-2022 mint at `mint`'s address carrying `extensions`, with +/// `payer` as both its mint authority and its close authority, and return the +/// address. Taking the keypair rather than generating one lets a test close the +/// mint and put something else back at the same address. +pub fn create_mint( + svm: &mut LiteSVM, + payer: &Keypair, + mint: &Keypair, + extensions: Extensions, +) -> Pubkey { + let space = ExtensionType::try_calculate_account_len::(extensions.mint()) + .expect("every mint extension used here has a fixed length"); + let mut instructions = vec![system_create_account( + &payer.pubkey(), + &mint.pubkey(), + svm.minimum_balance_for_rent_exemption(space), + space as u64, + &TOKEN_2022_PROGRAM_ID, + )]; + instructions.extend(extensions.initializers(&mint.pubkey(), &payer.pubkey())); + instructions.push( + initialize_mint2( + &TOKEN_2022_PROGRAM_ID, + &mint.pubkey(), + &payer.pubkey(), + None, + DECIMALS, + ) + .expect("initialize_mint2 should build"), + ); + + let tx = Transaction::new_signed_with_payer( + &instructions, + Some(&payer.pubkey()), + &[payer, mint], + svm.latest_blockhash(), + ); + svm.send_transaction(tx) + .expect("Token-2022 mint creation should succeed"); + mint.pubkey() +} + +/// Close `mint`, whose close authority must be `payer`, refunding its rent to +/// `payer`. Token-2022 hands the emptied account back to the System program, so +/// the address is free for [`create_mint`] or [`super::token::create_mint_at`] +/// to claim again. +pub fn close_mint(svm: &mut LiteSVM, payer: &Keypair, mint: &Pubkey) { + let ix = close_account( + &TOKEN_2022_PROGRAM_ID, + mint, + &payer.pubkey(), + &payer.pubkey(), + &[], + ) + .expect("close_account should build"); + let tx = Transaction::new_signed_with_payer( + &[ix], + Some(&payer.pubkey()), + &[payer], + svm.latest_blockhash(), + ); + svm.send_transaction(tx) + .expect("closing the mint should succeed"); + assert!( + svm.get_account(mint) + .is_none_or(|account| account.data.is_empty()), + "a closed mint must leave no data behind at its address", + ); +} diff --git a/programs/settlement/tests/create_buffer.rs b/programs/settlement/tests/create_buffer.rs index ee10636..0c7546e 100644 --- a/programs/settlement/tests/create_buffer.rs +++ b/programs/settlement/tests/create_buffer.rs @@ -29,6 +29,7 @@ use solana_sdk::{ use crate::common::{ benchmark::{send_transaction_metered, BenchLabel}, + token_2022::Extensions, unique_keypair, unique_pubkey, }; @@ -513,6 +514,49 @@ fn same_mint_twice_in_one_instruction_is_idempotent() { ); } +#[test] +fn sizes_a_token_2022_buffer_to_the_extensions_its_mint_forces() { + let (mut svm, program_id, payer) = common::setup(); + + for extensions in [ + Extensions::CloseAuthorityOnly, + Extensions::WithNonTransferable, + Extensions::WithTransferFee, + ] { + let mint = common::token_2022::create_mint(&mut svm, &payer, &unique_keypair(), extensions); + let (buffer_pda, _bump) = find_buffer_pda(&program_id, &mint); + + let ix = CreateBuffers { + program_id, + payer: payer.pubkey(), + token_program: TokenProgram::Token2022, + mints: &[mint], + }; + let tx = common::signed_tx(&svm, &payer, &payer, ix); + svm.send_transaction(tx).unwrap_or_else(|err| { + panic!( + "create_buffer should succeed for {extensions:?}: {:?}", + err.err + ) + }); + + let account = svm + .get_account(&buffer_pda) + .expect("buffer PDA should exist after create_buffer"); + assert_eq!( + account.owner, + TokenProgram::Token2022.address(), + "a {extensions:?} buffer must be owned by the Token-2022 program" + ); + assert_eq!( + account.data.len(), + extensions.token_account_len(), + "a {extensions:?} buffer must be allocated at the length its extensions imply" + ); + common::assert_rent_exempt(&svm, &account); + } +} + /// Largest number of buffers a single ALT-backed `create_buffers` transaction /// can carry, bounded by the transaction account-lock limit (litesvm and current /// mainnet both cap this at 64). diff --git a/programs/settlement/tests/reclaim_buffer.rs b/programs/settlement/tests/reclaim_buffer.rs index 3267353..13c5413 100644 --- a/programs/settlement/tests/reclaim_buffer.rs +++ b/programs/settlement/tests/reclaim_buffer.rs @@ -14,6 +14,7 @@ use solana_sdk::{ use crate::common::benchmark::{send_transaction_metered, BenchLabel}; use crate::common::buffer::ensure_buffer_exists; +use crate::common::token_2022::Extensions; use crate::common::{ assert_instruction_error, to_instruction_error, unique_pubkey, InitializedParams, }; @@ -355,6 +356,141 @@ fn rejects_when_the_reclaim_authority_does_not_sign() { ); } +/// Set up a Token-2022 mint with a close authority and its buffer, then close +/// the mint so `reopen` can claim the address. Returns the mint and its buffer. +fn buffer_whose_mint_was_reopened( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + reopen: impl FnOnce(&mut LiteSVM, &Keypair, &Keypair), +) -> (Pubkey, Pubkey) { + let mint_keypair = common::unique_keypair(); + let mint = + common::token_2022::create_mint(svm, payer, &mint_keypair, Extensions::CloseAuthorityOnly); + let buffer_pda = common::buffer::ensure_buffer_exists_for( + svm, + program_id, + payer, + &mint, + TokenProgram::Token2022, + ); + + common::token_2022::close_mint(svm, payer, &mint); + reopen(svm, payer, &mint_keypair); + + (mint, buffer_pda) +} + +#[test] +fn reclaims_a_buffer_whose_mint_was_reopened_with_another_extension() { + let ( + mut svm, + InitializedParams { + program_id, + payer, + reclaim: reclaim_authority, + .. + }, + ) = common::setup_init(); + + let (mint, buffer_pda) = buffer_whose_mint_was_reopened( + &mut svm, + &program_id, + &payer, + |svm, payer, mint_keypair| { + common::token_2022::create_mint( + svm, + payer, + mint_keypair, + Extensions::WithNonTransferable, + ); + }, + ); + + let buffer_lamports_before = svm + .get_account(&buffer_pda) + .expect("buffer must exist before reclaim") + .lamports; + let recipient_before = common::lamports(&svm, &reclaim_authority.pubkey()); + + let ix = ReclaimBuffer { + program_id, + reclaim_authority: reclaim_authority.pubkey(), + reclaim_recipient: reclaim_authority.pubkey(), + token_program: TokenProgram::Token2022, + mints: &[mint], + }; + let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); + svm.send_transaction(tx) + .expect("reclaim_buffer should succeed for a reopened mint"); + + assert!( + svm.get_account(&buffer_pda).is_none(), + "buffer PDA must be closed after reclaim" + ); + assert_eq!( + common::lamports(&svm, &reclaim_authority.pubkey()) - recipient_before, + buffer_lamports_before, + "the rent of a buffer stranded by a reopened mint must still be recoverable" + ); +} + +#[test] +fn reclaims_a_buffer_whose_mint_was_reopened_as_a_legacy_mint() { + let ( + mut svm, + InitializedParams { + program_id, + payer, + reclaim: reclaim_authority, + .. + }, + ) = common::setup_init(); + + let (mint, buffer_pda) = buffer_whose_mint_was_reopened( + &mut svm, + &program_id, + &payer, + |svm, payer, mint_keypair| { + common::token::create_mint_at(svm, payer, mint_keypair); + }, + ); + assert_eq!( + svm.get_account(&mint) + .expect("the reopened mint should exist") + .owner, + common::SPL_TOKEN_PROGRAM_ID, + "sanity: the mint must now belong to the legacy program" + ); + + let buffer_lamports_before = svm + .get_account(&buffer_pda) + .expect("buffer must exist before reclaim") + .lamports; + let recipient_before = common::lamports(&svm, &reclaim_authority.pubkey()); + + let ix = ReclaimBuffer { + program_id, + reclaim_authority: reclaim_authority.pubkey(), + reclaim_recipient: reclaim_authority.pubkey(), + token_program: TokenProgram::Token2022, + mints: &[mint], + }; + let tx = common::signed_tx(&svm, &payer, &reclaim_authority, ix); + svm.send_transaction(tx) + .expect("reclaim_buffer should succeed for a mint reopened as legacy"); + + assert!( + svm.get_account(&buffer_pda).is_none(), + "buffer PDA must be closed after reclaim" + ); + assert_eq!( + common::lamports(&svm, &reclaim_authority.pubkey()) - recipient_before, + buffer_lamports_before, + "the rent of a buffer whose mint turned legacy must still be recoverable" + ); +} + fn max_buffers_reclaim_via_lookup_table( svm: &mut LiteSVM, program_id: &Pubkey, From 00312dff7ab880a8be4f126629c3f9e1b47ae72f Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:19:23 +0900 Subject: [PATCH 26/27] fix idl --- programs/settlement/idl/cow_settlement.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/programs/settlement/idl/cow_settlement.json b/programs/settlement/idl/cow_settlement.json index ab5fce5..48c19b6 100644 --- a/programs/settlement/idl/cow_settlement.json +++ b/programs/settlement/idl/cow_settlement.json @@ -1049,7 +1049,7 @@ "msg": "A created order's intent isn't set with the created_on_chain flag corresponding to the behavior of the invoked order creation instruction." }, { - "code": 39, + "code": 40, "name": "BufferSizeUnavailable", "msg": "CreateBuffer asked the token program how long a token account for a mint has to be and couldn't read the answer, so it can't size the buffer." } From 3c8a10c0b07d4429817afd02d2d9d892490e1fdd Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:00:31 +0900 Subject: [PATCH 27/27] Update programs/settlement/tests/common/token_2022.rs Co-authored-by: Federico Giacon <58218759+fedgiac@users.noreply.github.com> --- programs/settlement/tests/common/token_2022.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/programs/settlement/tests/common/token_2022.rs b/programs/settlement/tests/common/token_2022.rs index b13e52b..5706523 100644 --- a/programs/settlement/tests/common/token_2022.rs +++ b/programs/settlement/tests/common/token_2022.rs @@ -40,10 +40,8 @@ const MAXIMUM_FEE: u64 = 1_000; #[derive(Clone, Copy, Debug)] pub enum Extensions { CloseAuthorityOnly, - /// Non-transferable + CloseAuthority - WithNonTransferable, - /// Transfer Fee + CloseAuthority - WithTransferFee, + CloseAuthorityAndNonTransferable, + CloseAuthorityAndTransferFee, } impl Extensions {