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 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 d758670db3e5d209a0495cf16a2d850091f7ad77 Mon Sep 17 00:00:00 2001 From: Kaze <230549489+kaze-cow@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:36:03 +0900 Subject: [PATCH 7/7] Support Token-2022 in the test CLI Token resolution now reports which token program owns the mint it resolved, and every instruction the CLI builds against a resolved token is issued against that program instead of assuming the legacy one. 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, 246 insertions(+), 88 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 a974d63..127d192 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,9 +58,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" [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 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/cmd/settle.rs b/test-cli/src/cmd/settle.rs index ead67e0..23b6e42 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, - token_program, Pubkey, + Pubkey, }, instructions::{ BeginSettle, CreateBuffers, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, @@ -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,28 +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(), - // 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::>(), + token_program, + mints: &mints.into_iter().collect::>(), } .into(), ); 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}" + ); } }