diff --git a/Cargo.lock b/Cargo.lock index 260ca88..7c2a46f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -910,7 +910,7 @@ dependencies = [ "solana-sdk-ids", "solana-sha256-hasher", "solana-system-interface 3.2.0", - "spl-token-interface 3.0.0", + "spl-token-2022-interface 3.1.1", ] [[package]] @@ -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]] @@ -2005,6 +2005,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 +2344,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2489,9 +2496,9 @@ dependencies = [ [[package]] name = "pinocchio-token" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "825f59c8348e5c2d3fd56432ef927f5819542b3b05fae4f5b6869801113e775e" +checksum = "217e3259f93a1520e4692b18653dad4d29af54ffc8b3a09819808be85dada074" dependencies = [ "solana-account-view", "solana-address 2.6.1", @@ -2499,6 +2506,21 @@ dependencies = [ "solana-program-error", ] +[[package]] +name = "pinocchio-token-2022" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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]] name = "pkcs8" version = "0.10.2" @@ -3145,6 +3167,7 @@ dependencies = [ "pinocchio", "pinocchio-system", "pinocchio-token", + "pinocchio-token-2022", "proptest", "solana-address-lookup-table-interface", "solana-compute-budget", @@ -3321,10 +3344,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 +4179,7 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "889194d8c5faec648f2f6fadddb60566249921ebb074e2707e7095458d5864e2" dependencies = [ + "borsh", "bytemuck", ] @@ -5199,6 +5223,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 +5324,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 +5480,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 +5538,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 +5647,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 8416e73..127d192 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.3.0" } cow-settlement-interface = { path = "interface", version = "0.3.0" } @@ -56,7 +57,7 @@ solana-sha256-hasher = { version = "3", features = ["sha2"] } solana-system-interface = "3" spl-associated-token-account-interface = "2" spl-token = "9" -spl-token-interface = "3" +spl-token-2022-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/bench-report.json b/bench-report.json index 8b69481..aeb8109 100644 --- a/bench-report.json +++ b/bench-report.json @@ -30,34 +30,34 @@ "transfer_authority/reclaim_authority_can_transfer_itself": 4 }, "compute_units": { - "add_solver/add_with_many_existing_solvers": 5074, - "add_solver/adds_a_solver": 4622, - "create_buffers/happy_path_creates_initialized_buffer_token_account": 7345, - "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 17243, - "create_buffers/max_buffers_in_one_instruction": 169540, - "create_order/happy_path_creates_order_pda_with_expected_body": 4985, - "initialize/happy_path_initializes_state_pda_with_expected_data": 4529, - "reclaim_buffer/funded_buffer_is_skipped": 4835, - "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 5983, - "reclaim_buffer/max_buffers_in_one_instruction": 124652, - "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 7582, - "reclaim_order/happy_path_expired_returns_lamports_and_closes_pda": 2202, - "reclaim_order/happy_path_on_chain_order_cancelled_is_reclaimable_before_expiry": 2071, - "reclaim_order/happy_path_on_chain_order_fully_filled_is_reclaimable_before_expiry": 2079, + "add_solver/add_with_many_existing_solvers": 5066, + "add_solver/adds_a_solver": 4614, + "create_buffers/happy_path_creates_initialized_buffer_token_account": 7367, + "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 17305, + "create_buffers/max_buffers_in_one_instruction": 170142, + "create_order/happy_path_creates_order_pda_with_expected_body": 4981, + "initialize/happy_path_initializes_state_pda_with_expected_data": 4523, + "reclaim_buffer/funded_buffer_is_skipped": 4854, + "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 6003, + "reclaim_buffer/max_buffers_in_one_instruction": 125165, + "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 7618, + "reclaim_order/happy_path_expired_returns_lamports_and_closes_pda": 2196, + "reclaim_order/happy_path_on_chain_order_cancelled_is_reclaimable_before_expiry": 2066, + "reclaim_order/happy_path_on_chain_order_fully_filled_is_reclaimable_before_expiry": 2074, "reclaim_order/off_chain_order_is_reclaimable_only_once_expired": null, "reclaim_order/on_chain_order_partially_filled_is_not_reclaimable_before_expiry": null, - "settle/finalizes_with_no_pushes": 7154, - "settle/pulls_from_multiple_orders": 20059, - "settle/pulls_funds_to_destination": 13640, - "settle/pulls_to_multiple_destinations": 14781, - "settle/pushes_a_single_order": 12495, - "settle/pushes_several_orders_from_different_buffers": 17767, - "settle/pushes_several_orders_from_one_buffer": 17766, - "settle/settles_a_single_order": 12513, - "settle/settles_multiple_orders": 23086, - "transfer_authority/manager_can_transfer_manager": 3174, - "transfer_authority/manager_can_transfer_reclaim_authority": 3176, - "transfer_authority/reclaim_authority_can_transfer_itself": 3180 + "settle/finalizes_with_no_pushes": 7126, + "settle/pulls_from_multiple_orders": 19997, + "settle/pulls_funds_to_destination": 13595, + "settle/pulls_to_multiple_destinations": 14735, + "settle/pushes_a_single_order": 12451, + "settle/pushes_several_orders_from_different_buffers": 17707, + "settle/pushes_several_orders_from_one_buffer": 17706, + "settle/settles_a_single_order": 12469, + "settle/settles_multiple_orders": 23010, + "transfer_authority/manager_can_transfer_manager": 3169, + "transfer_authority/manager_can_transfer_reclaim_authority": 3171, + "transfer_authority/reclaim_authority_can_transfer_itself": 3175 }, "transaction_bytes": { "add_solver/add_with_many_existing_solvers": 366, 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/Cargo.toml b/interface/Cargo.toml index 348ef7b..da903ed 100644 --- a/interface/Cargo.toml +++ b/interface/Cargo.toml @@ -30,7 +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-interface.workspace = true +spl-token-2022-interface.workspace = true [dev-dependencies] hex-literal.workspace = true diff --git a/interface/src/instruction/create_buffer.rs b/interface/src/instruction/create_buffer.rs index 0d3b25e..e6f0173 100644 --- a/interface/src/instruction/create_buffer.rs +++ b/interface/src/instruction/create_buffer.rs @@ -14,10 +14,6 @@ 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; - /// Builder for a `CreateBuffer` instruction that creates one buffer per /// `(buffer_pda, mint)` pair in `buffers`. /// @@ -40,6 +36,10 @@ pub use spl_token_interface::ID as 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)], } @@ -48,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)); @@ -136,6 +136,7 @@ pub mod fixtures { Instruction::from(CreateBuffers { program_id: zero, payer: zero, + token_program: zero, buffers: &[(zero, zero)], }) .data @@ -159,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; @@ -193,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; @@ -276,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(); @@ -294,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(); @@ -309,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); } @@ -318,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(); @@ -341,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/interface/src/instruction/settle/mod.rs b/interface/src/instruction/settle/mod.rs index bc6d37b..5443341 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/lib.rs b/interface/src/lib.rs index 5952479..ca3606b 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -9,6 +9,7 @@ solana_pubkey::declare_id!("FYp8R5K4B3B1Kfr7QuWzMz4TwoT7wptjYtxgCrY5sRXb"); pub mod data; pub mod instruction; pub mod pda; +pub mod token_program; #[derive(Clone, Copy, Debug, Eq, PartialEq, num_enum::TryFromPrimitive)] #[repr(u8)] @@ -233,6 +234,10 @@ pub enum SettlementError { /// A created order's intent isn't set with the `created_on_chain` flag corresponding /// to the behavior of the invoked order creation instruction. 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. + BufferSizeUnavailable = 39, } impl From for u32 { diff --git a/interface/src/pda/buffer.rs b/interface/src/pda/buffer.rs index e3a25d1..98a22ff 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_address::Address; use solana_program_error::ProgramError; diff --git a/interface/src/token_program.rs b/interface/src/token_program.rs new file mode 100644 index 0000000..1e7ee58 --- /dev/null +++ b/interface/src/token_program.rs @@ -0,0 +1,41 @@ +//! Utilities related to the token programs supported by the settlement program. + +use crate::Pubkey; + +/// The legacy SPL Token program. +pub use spl_token_2022_interface::inline_spl_token::ID as SPL_TOKEN_PROGRAM_ID; + +/// 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 supported token program +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"))); + } + + #[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 723eeb5..154212d 100644 --- a/programs/settlement/Cargo.toml +++ b/programs/settlement/Cargo.toml @@ -21,6 +21,7 @@ crate-type = ["cdylib", "lib"] pinocchio = { workspace = true, features = ["cpi", "account-resize"] } 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..e39d32c 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,8 @@ 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. // // We don't validate `mint` here. `InitializeAccount3` requires a real, // token-program-owned mint (and special-cases the native mint), so a @@ -45,8 +46,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 +55,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 +70,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 d60fa23..ef41bc2 100644 --- a/programs/settlement/src/lib.rs +++ b/programs/settlement/src/lib.rs @@ -8,6 +8,7 @@ mod processor; mod reclaim_buffer; mod reclaim_order; mod settle; +mod token; mod transfer_authority; use add_solver::process_add_solver; diff --git a/programs/settlement/src/reclaim_buffer.rs b/programs/settlement/src/reclaim_buffer.rs index 8508891..11d128c 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::StateAccount, - 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::{AccountView, Address, ProgramResult}; +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 = @@ -52,9 +50,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. @@ -64,7 +60,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(()) @@ -82,7 +81,9 @@ 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 pinocchio::error::ProgramError; use solana_program_pack::Pack; use super::*; 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/src/token.rs b/programs/settlement/src/token.rs new file mode 100644 index 0000000..1458fe1 --- /dev/null +++ b/programs/settlement/src/token.rs @@ -0,0 +1,245 @@ +//! Token-program validation and token-account reads + +use cow_settlement_interface::{ + token_program::{is_supported, SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID}, + SettlementError, +}; +use pinocchio::{cpi::get_return_data, error::ProgramError, AccountView, Address}; +use pinocchio_token::{instructions::GetAccountDataSize, state::Mint}; + +/// The length of a SPL token program account. Token2022 extensions may make +/// the actual token account longer than this. +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. +#[must_use = "not consuming skips validation"] +pub fn validate_token_program( + token_program_account: &AccountView, +) -> Result<&Address, ProgramError> { + let address = token_program_account.address(); + if !is_supported(address) { + return Err(ProgramError::IncorrectProgramId); + } + Ok(address) +} + +/// 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); + } + + // SPL token provides a function to get the actual required account data size + GetAccountDataSize::new(mint).invoke_with_unverified_program(token_program)?; + + 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`]. +/// 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`. +/// 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, +) -> 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); + } + + #[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}", + ); + } + } + + #[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), + ); + } + + #[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()), + ); + } + + #[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), + ); + } + + #[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); + } + } + + #[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); + } + + #[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), + ); + } + + #[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}", + ); + } + } +} 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/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 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(), ); 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}" + ); } }