diff --git a/DESIGN.md b/DESIGN.md index 57bc1f22..ea94f120 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -122,6 +122,8 @@ struct OrderIntent { // Either Buy or Sell kind: OrderKind partially_fillable: bool + // Receives the sell token account's rent when a settlement closes it. + sell_account_rent_recipient: Pubkey // Usual app data field, it isn't directly used in the program. app_data: [u8; 32] } @@ -188,6 +190,12 @@ Creating the order in advance is _not_ needed: if the order wasn’t created bef Note that deleting the order PDA is _not_ enough to invalidate an order. In fact, if an order signature is available, the same order could always be created again until it expires. +### Sell Token Account clearing + +Upon settlement, if an order whose `sell_token_account` is left with 0 funds *and* the settlement account's state account has been granted close authority, the sell token account will be automatically closed and the rent proceeds sent to `sell_account_rent_recipient`. + +If the `sell_token_account` has not granted close authority or has any remaining funds, the account will not be closed and `sell_account_rent_recipient` is ignored. + ### Order clearing Allocating an order PDA requires paying rent. diff --git a/bench-report.json b/bench-report.json index 96500a5f..b7d4a883 100644 --- a/bench-report.json +++ b/bench-report.json @@ -11,41 +11,41 @@ "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 9, "reclaim_order/happy_path_returns_lamports_and_closes_pda": 4, "settle/finalizes_with_no_pushes": 5, - "settle/pulls_from_multiple_orders": 15, - "settle/pulls_funds_to_destination": 10, - "settle/pulls_to_multiple_destinations": 11, - "settle/pushes_a_single_order": 9, - "settle/pushes_several_orders_from_different_buffers": 13, - "settle/pushes_several_orders_from_one_buffer": 12, - "settle/settles_a_single_order": 9, - "settle/settles_multiple_orders": 17 + "settle/pulls_from_multiple_orders": 16, + "settle/pulls_funds_to_destination": 11, + "settle/pulls_to_multiple_destinations": 12, + "settle/pushes_a_single_order": 10, + "settle/pushes_several_orders_from_different_buffers": 14, + "settle/pushes_several_orders_from_one_buffer": 13, + "settle/settles_a_single_order": 10, + "settle/settles_multiple_orders": 18 }, "compute_units": { "create_buffers/happy_path_creates_initialized_buffer_token_account": 10338, "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21727, "create_buffers/max_buffers_in_one_instruction": 176916, - "create_order/happy_path_creates_order_pda_with_expected_body": 7916, + "create_order/happy_path_creates_order_pda_with_expected_body": 4942, "initialize/happy_path_initializes_state_pda_with_expected_data": 4525, "reclaim_buffer/funded_buffer_is_skipped": 6376, "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7523, "reclaim_buffer/max_buffers_in_one_instruction": 136548, "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18119, - "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2131, - "settle/finalizes_with_no_pushes": 7042, - "settle/pulls_from_multiple_orders": 19743, - "settle/pulls_funds_to_destination": 13413, - "settle/pulls_to_multiple_destinations": 14559, - "settle/pushes_a_single_order": 12263, - "settle/pushes_several_orders_from_different_buffers": 17444, - "settle/pushes_several_orders_from_one_buffer": 17445, - "settle/settles_a_single_order": 12281, - "settle/settles_multiple_orders": 22669 + "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2157, + "settle/finalizes_with_no_pushes": 7041, + "settle/pulls_from_multiple_orders": 19919, + "settle/pulls_funds_to_destination": 13501, + "settle/pulls_to_multiple_destinations": 14647, + "settle/pushes_a_single_order": 12354, + "settle/pushes_several_orders_from_different_buffers": 17623, + "settle/pushes_several_orders_from_one_buffer": 17624, + "settle/settles_a_single_order": 12372, + "settle/settles_multiple_orders": 22940 }, "transaction_bytes": { "create_buffers/happy_path_creates_initialized_buffer_token_account": 303, "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 435, "create_buffers/max_buffers_in_one_instruction": 331, - "create_order/happy_path_creates_order_pda_with_expected_body": 389, + "create_order/happy_path_creates_order_pda_with_expected_body": 421, "initialize/happy_path_initializes_state_pda_with_expected_data": 301, "reclaim_buffer/funded_buffer_is_skipped": 400, "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 400, @@ -53,13 +53,13 @@ "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 466, "reclaim_order/happy_path_returns_lamports_and_closes_pda": 236, "settle/finalizes_with_no_pushes": 289, - "settle/pulls_from_multiple_orders": 655, - "settle/pulls_funds_to_destination": 472, - "settle/pulls_to_multiple_destinations": 513, - "settle/pushes_a_single_order": 431, - "settle/pushes_several_orders_from_different_buffers": 573, - "settle/pushes_several_orders_from_one_buffer": 541, - "settle/settles_a_single_order": 431, - "settle/settles_multiple_orders": 715 + "settle/pulls_from_multiple_orders": 689, + "settle/pulls_funds_to_destination": 505, + "settle/pulls_to_multiple_destinations": 546, + "settle/pushes_a_single_order": 464, + "settle/pushes_several_orders_from_different_buffers": 607, + "settle/pushes_several_orders_from_one_buffer": 575, + "settle/settles_a_single_order": 464, + "settle/settles_multiple_orders": 750 } } diff --git a/client/src/instructions.rs b/client/src/instructions.rs index e7cec88d..3b142dc0 100644 --- a/client/src/instructions.rs +++ b/client/src/instructions.rs @@ -37,11 +37,13 @@ impl From> for Instruction { fn from(builder: BeginSettle<'_>) -> Self { let mut order_pdas = Vec::with_capacity(builder.orders.len()); let mut sell_token_accounts = Vec::with_capacity(builder.orders.len()); + let mut sell_account_rent_recipients = Vec::with_capacity(builder.orders.len()); let mut pull_lists: Vec<&[Pull]> = Vec::with_capacity(builder.orders.len()); for order in builder.orders { let (order_pda, _bump) = find_order_pda(&builder.program_id, &order.intent.uid()); order_pdas.push(order_pda); sell_token_accounts.push(order.intent.sell_token_account); + sell_account_rent_recipients.push(order.intent.sell_account_rent_recipient); pull_lists.push(order.pulls); } let (state_pda, _bump) = find_state_pda(&builder.program_id); @@ -52,6 +54,7 @@ impl From> for Instruction { auction_id: builder.auction_id, order_pdas: &order_pdas, sell_token_accounts: &sell_token_accounts, + sell_account_rent_recipients: &sell_account_rent_recipients, pulls: &pull_lists, } .into() diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index 491f5310..92237798 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -77,13 +77,20 @@ pub struct OrderIntent { /// must consume the full sell amount (fill-or-kill). pub partially_fillable: bool, + /// Account that receives the lamports reclaimed from + /// `sell_token_account` when a settlement closes it. Closing only + /// happens if the account is left empty by the settlement and its SPL + /// close authority is the settlement state PDA, which is the owner's + /// opt-in. + pub sell_account_rent_recipient: Pubkey, + /// Opaque 32 bytes set by the order creator. Not interpreted by the /// settlement program; used off-chain for metadata such as the /// frontend version, slippage hints, or attribution. pub app_data: [u8; 32], } -/// Canonical 150-byte representation of an [`OrderIntent`]. The wire format and +/// Canonical 182-byte representation of an [`OrderIntent`]. The wire format and /// the order UID preimage. /// /// Layout: one character per byte, cell widths proportional to field size, @@ -93,12 +100,12 @@ pub struct OrderIntent { /// ```text /// partially_fillable ─────┐ /// kind ────┐│ -/// ┌───────────────────────────────┬───────────────────────────────┬───────────────────────────────┬───────┬───────┬───┬┬┬───────────────────────────────┐ -/// │ │ │ │sell_ │buy_ │val│││ │ -/// │ owner │ buy_token_account │ sell_token_account │ │ │id_│││ app_data │ -/// │ │ │ │amount │amount │to │││ │ -/// └───────────────────────────────┴───────────────────────────────┴───────────────────────────────┴───────┴───────┴───┴┴┴───────────────────────────────┘ -/// 0 32 64 96 104 112 116 118 150 +/// ┌───────────────────────────────┬───────────────────────────────┬───────────────────────────────┬───────┬───────┬───┬┬┬───────────────────────────────┬───────────────────────────────┐ +/// │ │ │ │sell_ │buy_ │val│││ sell_account_ │ │ +/// │ owner │ buy_token_account │ sell_token_account │ │ │id_│││ rent_recipient │ app_data │ +/// │ │ │ │amount │amount │to │││ │ │ +/// └───────────────────────────────┴───────────────────────────────┴───────────────────────────────┴───────┴───────┴───┴┴┴───────────────────────────────┴───────────────────────────────┘ +/// 0 32 64 96 104 112 116 118 150 182 /// 117 /// ``` #[derive(Clone, Debug, Deref, Eq, PartialEq)] @@ -114,9 +121,10 @@ impl EncodedOrderIntent { const WIDTH_VALID_TO: usize = size_of::(); const WIDTH_KIND: usize = size_of::(); const WIDTH_PARTIALLY_FILLABLE: usize = size_of::(); + const WIDTH_SELL_ACCOUNT_RENT_RECIPIENT: usize = size_of::(); const WIDTH_APP_DATA: usize = size_of::<[u8; 32]>(); - pub const SIZE: usize = 150; + pub const SIZE: usize = 182; /// Canonical hash of the bytes. pub fn hash(&self) -> Hash { @@ -163,6 +171,7 @@ impl From<&OrderIntent> for EncodedOrderIntent { valid_to, kind, partially_fillable, + sell_account_rent_recipient, app_data, ) = mut_array_refs![ &mut out, @@ -174,6 +183,7 @@ impl From<&OrderIntent> for EncodedOrderIntent { EncodedOrderIntent::WIDTH_VALID_TO, EncodedOrderIntent::WIDTH_KIND, EncodedOrderIntent::WIDTH_PARTIALLY_FILLABLE, + EncodedOrderIntent::WIDTH_SELL_ACCOUNT_RENT_RECIPIENT, EncodedOrderIntent::WIDTH_APP_DATA ]; *owner = intent.owner.to_bytes(); @@ -184,6 +194,7 @@ impl From<&OrderIntent> for EncodedOrderIntent { *valid_to = intent.valid_to.to_le_bytes(); *kind = [intent.kind as u8]; *partially_fillable = [intent.partially_fillable as u8]; + *sell_account_rent_recipient = intent.sell_account_rent_recipient.to_bytes(); *app_data = intent.app_data; Self(out) } @@ -209,6 +220,7 @@ impl TryFrom<&[u8; EncodedOrderIntent::SIZE]> for OrderIntent { valid_to, kind, partially_fillable, + sell_account_rent_recipient, app_data, ) = array_refs![ bytes, @@ -220,6 +232,7 @@ impl TryFrom<&[u8; EncodedOrderIntent::SIZE]> for OrderIntent { EncodedOrderIntent::WIDTH_VALID_TO, EncodedOrderIntent::WIDTH_KIND, EncodedOrderIntent::WIDTH_PARTIALLY_FILLABLE, + EncodedOrderIntent::WIDTH_SELL_ACCOUNT_RENT_RECIPIENT, EncodedOrderIntent::WIDTH_APP_DATA ]; @@ -240,6 +253,7 @@ impl TryFrom<&[u8; EncodedOrderIntent::SIZE]> for OrderIntent { [1] => true, _ => return Err(ProgramError::InvalidInstructionData), }, + sell_account_rent_recipient: Pubkey::new_from_array(*sell_account_rent_recipient), app_data: *app_data, }) } @@ -285,6 +299,7 @@ pub mod fixtures { valid_to: 0xdead_beef, kind, partially_fillable, + sell_account_rent_recipient: Pubkey::new_from_array([0x55; 32]), app_data: [0x44; 32], } } @@ -306,9 +321,21 @@ pub mod fixtures { arb_order_kind(), any::(), any::<[u8; 32]>(), + any::<[u8; 32]>(), ) .prop_map( - |(owner, buy_tok, sell_tok, sell_amount, buy_amount, valid_to, kind, pf, app)| { + |( + owner, + buy_tok, + sell_tok, + sell_amount, + buy_amount, + valid_to, + kind, + pf, + rent_recipient, + app, + )| { OrderIntent { owner: Pubkey::new_from_array(owner), buy_token_account: Pubkey::new_from_array(buy_tok), @@ -318,6 +345,7 @@ pub mod fixtures { valid_to, kind, partially_fillable: pf, + sell_account_rent_recipient: Pubkey::new_from_array(rent_recipient), app_data: app, } }, @@ -377,6 +405,10 @@ mod tests { EncodedOrderIntent::WIDTH_PARTIALLY_FILLABLE, size_of_val(&intent.partially_fillable) ); + assert_eq!( + EncodedOrderIntent::WIDTH_SELL_ACCOUNT_RENT_RECIPIENT, + size_of_val(&intent.sell_account_rent_recipient) + ); assert_eq!( EncodedOrderIntent::WIDTH_APP_DATA, size_of_val(&intent.app_data) @@ -458,7 +490,7 @@ mod tests { #[test] fn uid_digest_regression() { let intent = sample_intent(OrderKind::Buy, true); - let expected = hex!("7ce7c6a74671090771fa33851387444064aca759ce55b80708723076722f5e00"); + let expected = hex!("7634777e7f671c95c082d21eb1e3d685d764d54f8716115c9baabdcb68ea5f61"); assert_eq!(intent.uid(), Hash::from(expected)); } @@ -493,6 +525,11 @@ mod tests { 0x01, // partially_fillable (true = 1) 0x01, + // sell_account_rent_recipient ([0x55; 32]) + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, // app_data ([0x44; 32]) 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, 0x44, diff --git a/interface/src/data/order.rs b/interface/src/data/order.rs index 0bc56c49..9efc05fe 100644 --- a/interface/src/data/order.rs +++ b/interface/src/data/order.rs @@ -88,7 +88,7 @@ impl OrderAccount { } } -/// Canonical 201-byte representation of an [`OrderAccount`]. The bytes +/// Canonical 233-byte representation of an [`OrderAccount`]. The bytes /// written to/read from the order PDA's data area. /// /// Layout: one character per byte, cell widths proportional to field size, @@ -105,7 +105,7 @@ impl OrderAccount { /// ││││with- │re- │ created_by │ intent (EncodedOrderIntent) │ /// ││││drawn │ceived │ │ │ /// └┴┴┴───────┴───────┴───────────────────────────────┴─────────────────...─────────────────┘ -/// 0 1 2 3 11 19 51 ... 201 +/// 0 1 2 3 11 19 51 ... 233 /// ``` #[derive(Clone, Debug, Deref, Eq, PartialEq)] pub struct EncodedOrderAccount([u8; Self::SIZE]); @@ -120,7 +120,7 @@ impl EncodedOrderAccount { const W_CREATED_BY: usize = size_of::(); const W_INTENT: usize = EncodedOrderIntent::SIZE; - pub const SIZE: usize = 201; + pub const SIZE: usize = 233; /// Single-byte account discriminator. See [`crate::SettlementAccount`]. pub const DISCRIMINATOR: u8 = crate::SettlementAccount::OrderAccount.discriminator(); diff --git a/interface/src/instruction/create_order.rs b/interface/src/instruction/create_order.rs index c3172560..04a49dae 100644 --- a/interface/src/instruction/create_order.rs +++ b/interface/src/instruction/create_order.rs @@ -40,7 +40,7 @@ use crate::{data::intent::EncodedOrderIntent, SettlementInstruction}; /// instruction reverts with `AccountAlreadyInitialized`. Recreating the same /// order is only possible after its PDA has been closed. /// -/// Wire format: `[discriminator=2, ..150 intent bytes]`, 151 bytes. +/// Wire format: `[discriminator=2, ..intent bytes]` /// Required accounts: /// `[owner (S), created_by (W,S), order_pda (W), system_program (R)]`. /// The system program needs to be available but doesn't need to be at that @@ -84,7 +84,7 @@ impl<'a, A> InstructionInputParsing<'a, A> for CreateOrderInput<'a, A> { const DISCRIMINATOR: SettlementInstruction = SettlementInstruction::CreateOrder; fn parse_body(instruction_data: &'a [u8], accounts: &'a [A]) -> Result { - // Body (discriminator already stripped): exactly the 150 intent bytes. + // Body (discriminator already stripped): exactly the 182 intent bytes. if instruction_data.len() != EncodedOrderIntent::SIZE { return Err(ProgramError::InvalidInstructionData); } @@ -126,7 +126,7 @@ pub mod fixtures { /// and the system program. pub const NUM_ACCOUNTS: usize = 4; - /// Canonical 150-byte intent payload for a valid sell order owned by + /// Canonical 182-byte intent payload for a valid sell order owned by /// [`DEFAULT_OWNER`]. pub fn valid_intent_bytes() -> [u8; EncodedOrderIntent::SIZE] { (&EncodedOrderIntent::from(&OrderIntent { diff --git a/interface/src/instruction/settle/begin.rs b/interface/src/instruction/settle/begin.rs index 4a9f5251..dc29e56e 100644 --- a/interface/src/instruction/settle/begin.rs +++ b/interface/src/instruction/settle/begin.rs @@ -23,6 +23,11 @@ pub struct Pull { /// parallel lists: /// - `order_pdas[i]` is the canonical order PDA (see [`crate::pda::order`]) /// - `sell_token_accounts[i]` is the order's sell token account, +/// - `sell_account_rent_recipients[i]` is the order's +/// `sell_account_rent_recipient`, which receives the sell token account's +/// rent if it's closed once empty. The program only requires it to match the +/// intent on a settlement that closes the account, so any address works for +/// an order whose sell token account stays open, /// - `pulls[i]` the list of [`Pull`]s to perform from that order's sell token /// account, each sending an amount from the `i`-th order sell token account /// to a destination. @@ -35,12 +40,12 @@ pub struct Pull { /// [transfer_count×n][amount: u64 LE ×T]`. /// Required accounts: `[instructions_sysvar (R), state_pda (R), token_program /// (R)]` followed, per order, by `[order_pda (W), sell_token_account (W), -/// destination (W)...]`. +/// sell_account_rent_recipient (W), destination (W)...]`. /// /// The program requires the order PDAs to be strictly increasing by address. /// This builder establishes that ordering for the caller: it sorts the orders by -/// PDA address, carrying each order's sell token account, transfer count, -/// amounts, and destination metas before emitting them. +/// PDA address, carrying each order's sell token account, rent recipient, +/// transfer count, amounts, and destination metas before emitting them. pub struct BeginSettle<'a> { pub program_id: Pubkey, pub state_pda: Pubkey, @@ -51,6 +56,7 @@ pub struct BeginSettle<'a> { pub auction_id: i64, pub order_pdas: &'a [Pubkey], pub sell_token_accounts: &'a [Pubkey], + pub sell_account_rent_recipients: &'a [Pubkey], pub pulls: &'a [&'a [Pull]], } @@ -63,6 +69,7 @@ impl From> for Instruction { auction_id, order_pdas, sell_token_accounts, + sell_account_rent_recipients, pulls, } = builder; @@ -99,9 +106,9 @@ impl From> for Instruction { // Writable account for the order: `BeginSettle` updates its filled // amounts (`amount_withdrawn`/`amount_received`). accounts.push(AccountMeta::new(order_pdas[i], false)); - // Writable accounts settling the order: its sell token account and the - // recipient of each transfer. + // Writable accounts settling the order: its sell token account, recipient of the pulls, and designated order rent recipient accounts.push(AccountMeta::new(sell_token_accounts[i], false)); + accounts.push(AccountMeta::new(sell_account_rent_recipients[i], false)); for pull in pulls[i] { accounts.push(AccountMeta::new(pull.destination, false)); } @@ -120,6 +127,7 @@ impl From> for Instruction { pub struct SettledOrder<'a, A> { pub order_pda: &'a A, pub sell_token_account: &'a A, + pub sell_account_rent_recipient: &'a A, /// Destination accounts for this order's transfers. pub destinations: &'a [A], /// Transfer amounts (little-endian `u64`), one per destination. @@ -133,7 +141,7 @@ pub struct SettledOrders<'a, A> { /// Order accounts, laid out per order as /// [order_accounts_1, order_accounts_2, ...] where /// - each order_accounts is a series of accounts: - /// `order_pda_N, sell_token_account_N, destination_N_1, destination_N_2, ..., destination_N_M` + /// `order_pda_N, sell_token_account_N, sell_account_rent_recipient_N, destination_N_1, destination_N_2, ..., destination_N_M` /// - and M is `counts[N]` order_accounts: &'a [A], /// One transfer count per order. @@ -152,7 +160,8 @@ impl<'a, A> SettledOrders<'a, A> { pub fn iter(&self) -> impl Iterator> + '_ { let (counts, amounts) = (self.counts, self.amounts); // Cursor over the remaining order accounts; each step splits one order's - // `[order_pda, sell_token_account, destinations..count]` off the front. + // `[order_pda, sell_token_account, sell_account_rent_recipient, + // destinations..count]` off the front. let mut rest: &'a [A] = self.order_accounts; let mut i = 0usize; let mut amount_offset = 0usize; @@ -163,8 +172,10 @@ impl<'a, A> SettledOrders<'a, A> { let count = usize::from(counts[i]); i += 1; - let (order_pda, tail) = rest.split_first()?; + let taken = core::mem::take(&mut rest); + let (order_pda, tail) = taken.split_first()?; let (sell_token_account, tail) = tail.split_first()?; + let (sell_account_rent_recipient, tail) = tail.split_first()?; let (destinations, remainder) = tail.split_at(count); rest = remainder; @@ -175,6 +186,7 @@ impl<'a, A> SettledOrders<'a, A> { Some(SettledOrder { order_pda, sell_token_account, + sell_account_rent_recipient, destinations, amounts: order_amounts, }) @@ -240,11 +252,12 @@ impl<'a, A> InstructionInputParsing<'a, A> for BeginSettleInput<'a, A> { }; let transfer_count = amounts.len(); - // Each order contributes its order PDA, sell token account, and one - // destination per transfer, so the order accounts count is `2n + T`. + // Each order contributes its order PDA, sell token account, rent + // recipient, and one destination per transfer, so the order accounts + // count is `3n + T`. let expected_accounts = order_count - .checked_mul(2) - .and_then(|two_n| two_n.checked_add(transfer_count)) + .checked_mul(3) + .and_then(|three_n| three_n.checked_add(transfer_count)) .ok_or(ProgramError::InvalidInstructionData)?; if order_accounts.len() != expected_accounts { return Err(SettlementError::AccountCountNotMatchingOrderCount.into()); @@ -308,6 +321,7 @@ mod tests { auction_id: 0x0102_0304_0506_0708, order_pdas: &[], sell_token_accounts: &[], + sell_account_rent_recipients: &[], pulls: &[], } .into(); @@ -339,8 +353,10 @@ mod tests { // are chosen to sort in the opposite order. let high_order_pda = Pubkey::new_from_array([0xbb; 32]); let high_sell_token_account = Pubkey::new_from_array([0xa0; 32]); + let high_rent_recipient = Pubkey::new_from_array([0xa2; 32]); let low_order_pda = Pubkey::new_from_array([0xaa; 32]); let low_sell_token_account = Pubkey::new_from_array([0xb0; 32]); + let low_rent_recipient = Pubkey::new_from_array([0xb2; 32]); let Instruction { data, accounts, .. } = BeginSettle { program_id, state_pda, @@ -348,6 +364,7 @@ mod tests { auction_id: AUCTION_ID, order_pdas: &[high_order_pda, low_order_pda], sell_token_accounts: &[high_sell_token_account, low_sell_token_account], + sell_account_rent_recipients: &[high_rent_recipient, low_rent_recipient], pulls: &[&[], &[]], } .into(); @@ -369,8 +386,10 @@ mod tests { SPL_TOKEN_PROGRAM_ID, low_order_pda, low_sell_token_account, + low_rent_recipient, high_order_pda, high_sell_token_account, + high_rent_recipient, ]; let actual: Vec = accounts.iter().map(|account| account.pubkey).collect(); assert_eq!(actual, expected); @@ -385,8 +404,10 @@ mod tests { vec![ low_order_pda, low_sell_token_account, + low_rent_recipient, high_order_pda, high_sell_token_account, + high_rent_recipient, ], ); assert!(accounts.iter().all(|account| !account.is_signer)); @@ -398,8 +419,10 @@ mod tests { let state_pda = Pubkey::new_unique(); let order_a = Pubkey::new_from_array([0x01; 32]); let sell_a = Pubkey::new_from_array([0x02; 32]); + let rent_a = Pubkey::new_from_array([0x08; 32]); let order_b = Pubkey::new_from_array([0x03; 32]); let sell_b = Pubkey::new_from_array([0x04; 32]); + let rent_b = Pubkey::new_from_array([0x09; 32]); let dest_a0 = Pubkey::new_from_array([0x05; 32]); let dest_a1 = Pubkey::new_from_array([0x06; 32]); let dest_b0 = Pubkey::new_from_array([0x07; 32]); @@ -412,6 +435,7 @@ mod tests { auction_id: AUCTION_ID, order_pdas: &[order_a, order_b], sell_token_accounts: &[sell_a, sell_b], + sell_account_rent_recipients: &[rent_a, rent_b], pulls: &[ &[ Pull { @@ -452,10 +476,12 @@ mod tests { SPL_TOKEN_PROGRAM_ID, order_a, sell_a, + rent_a, dest_a0, dest_a1, order_b, sell_b, + rent_b, dest_b0, ]; let actual: Vec = accounts.iter().map(|account| account.pubkey).collect(); @@ -468,7 +494,7 @@ mod tests { .collect(); assert_eq!( writable, - vec![order_a, sell_a, dest_a0, dest_a1, order_b, sell_b, dest_b0], + vec![order_a, sell_a, rent_a, dest_a0, dest_a1, order_b, sell_b, rent_b, dest_b0], ); assert!(accounts.iter().all(|account| !account.is_signer)); } @@ -555,12 +581,14 @@ mod tests { let token_program = Address::new_from_array([0xa2u8; 32]); let order_pda = Address::new_from_array([2u8; 32]); let sell_token = Address::new_from_array([3u8; 32]); + let rent_recipient = Address::new_from_array([4u8; 32]); let accounts = [ fake_account(sysvar), fake_account(state), fake_account(token_program), fake_account(order_pda), fake_account(sell_token), + fake_account(rent_recipient), ]; let data = ix_data![ [SettlementInstruction::BeginSettle.discriminator()], @@ -587,6 +615,7 @@ mod tests { let order = orders.next().expect("one settled order"); assert_eq!(order.order_pda.address(), &order_pda); assert_eq!(order.sell_token_account.address(), &sell_token); + assert_eq!(order.sell_account_rent_recipient.address(), &rent_recipient); assert_eq!(order.destinations.len(), 0); assert!(orders.next().is_none()); } @@ -598,6 +627,7 @@ mod tests { let token_program = Address::new_from_array([0xa2u8; 32]); let order_pda = Address::new_from_array([2u8; 32]); let sell_token = Address::new_from_array([3u8; 32]); + let rent_recipient = Address::new_from_array([6u8; 32]); let dest0 = Address::new_from_array([4u8; 32]); let dest1 = Address::new_from_array([5u8; 32]); let accounts = [ @@ -606,6 +636,7 @@ mod tests { fake_account(token_program), fake_account(order_pda), fake_account(sell_token), + fake_account(rent_recipient), fake_account(dest0), fake_account(dest1), ]; @@ -626,6 +657,7 @@ mod tests { let order = orders.next().expect("one settled order"); assert_eq!(order.order_pda.address(), &order_pda); assert_eq!(order.sell_token_account.address(), &sell_token); + assert_eq!(order.sell_account_rent_recipient.address(), &rent_recipient); let transfers: Vec<(&Address, u64)> = order .destinations .iter() @@ -640,11 +672,12 @@ mod tests { fn begin_settle_input_pairs_every_order_with_its_sell_token_account() { const ORDER_COUNT: usize = 16; - let mut expected: Vec<(Address, Address)> = Vec::new(); + let mut expected: Vec<(Address, Address, Address)> = Vec::new(); for i in 0..ORDER_COUNT { let order_pda = Address::new_from_array([i as u8; 32]); let sell_token = Address::new_from_array([(i + ORDER_COUNT) as u8; 32]); - expected.push((order_pda, sell_token)); + let rent_recipient = Address::new_from_array([(i + 2 * ORDER_COUNT) as u8; 32]); + expected.push((order_pda, sell_token, rent_recipient)); } // The three fixed accounts (`[0xff..]`, `[0xfe..]`, `[0xfd..]`) differ @@ -654,9 +687,10 @@ mod tests { fake_account_from_array([0xfe; 32]), fake_account_from_array([0xfd; 32]), ]; - for &(order_pda, sell_token) in &expected { + for &(order_pda, sell_token, rent_recipient) in &expected { accounts.push(fake_account(order_pda)); accounts.push(fake_account(sell_token)); + accounts.push(fake_account(rent_recipient)); } // Grouped data: discriminator, finalize index, auction id, order count, // then all transfer counts (every order has zero transfers). @@ -670,7 +704,7 @@ mod tests { let parsed = BeginSettleInput::parse(&data, &accounts).expect("parse should succeed"); - let actual: Vec<(Address, Address)> = parsed + let actual: Vec<(Address, Address, Address)> = parsed .orders .iter() .map(|order| { @@ -678,6 +712,7 @@ mod tests { ( *order.order_pda.address(), *order.sell_token_account.address(), + *order.sell_account_rent_recipient.address(), ) }) .collect(); @@ -686,10 +721,11 @@ mod tests { #[test] fn begin_settle_input_rejects_account_count_mismatch() { - // The body declares one order with no transfers, which needs exactly two - // order accounts (its order PDA and sell token account). Only one order - // account is supplied after the fixed accounts, so the number of accounts - // doesn't match the `2n + T` the body implies. + // The body declares one order with no transfers, which needs exactly + // three order accounts (its order PDA, sell token account, and rent + // recipient). Only one order account is supplied after the fixed + // accounts, so the number of accounts doesn't match the `3n + T` the + // body implies. let accounts = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 1 }>(); let data = ix_data![ [SettlementInstruction::BeginSettle.discriminator()], @@ -706,10 +742,10 @@ mod tests { #[test] fn begin_settle_input_rejects_counts_not_summing_to_destinations() { - // One order whose two destination accounts (plus its order PDA and sell - // token account) make the lengths recover T = 2 transfers, but the - // transfer-count byte claims only one. - let accounts = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 4 }>(); + // One order whose two destination accounts (plus its order PDA, sell + // token account, and rent recipient) make the lengths recover T = 2 + // transfers, but the transfer-count byte claims only one. + let accounts = fake_sequential_accounts::<{ FIXED_ACCOUNTS + 5 }>(); let data = ix_data![ [SettlementInstruction::BeginSettle.discriminator()], [0, 0], // finalize index diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 1735fc04..60c2cc86 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -187,6 +187,10 @@ pub enum SettlementError { /// A `ReclaimBuffer` `buffer_pda` doesn't sit at the canonical buffer PDA /// derived from its paired `mint`. ReclaimBufferNotCanonical = 33, + /// `BeginSettle`: a settlement closing an order's sell token account + /// supplied a rent recipient account that doesn't match the + /// `sell_account_rent_recipient` recorded in the order's intent. + SellAccountRentRecipientMismatch = 34, } impl From for u32 { diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index e9aad481..755bdbc9 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -26,7 +26,10 @@ use pinocchio::{ }, AccountView, Address, ProgramResult, }; -use pinocchio_token::{instructions::Transfer, state::Account as TokenAccount}; +use pinocchio_token::{ + instructions::{CloseAccount, Transfer}, + state::Account as TokenAccount, +}; use crate::processor::{is_cpi_call, with_state_pda_signer}; @@ -223,6 +226,9 @@ fn settle_orders( /// This checks that the order is valid, settleable, and that `push_destination` /// matches the buy token account. Once the order passes those checks, its pulls /// are executed and its settlement limit price is validated against the intent. +/// Finally, a sell token account left empty by the pulls is closed if the state +/// PDA holds its SPL close authority, sending the reclaimed lamports to the +/// intent's `sell_account_rent_recipient`. #[must_use = "ignoring the output may lead to an unintended on-chain state"] fn process_order( program_id: &Address, @@ -235,6 +241,7 @@ fn process_order( let SettledOrder { order_pda, sell_token_account, + sell_account_rent_recipient, destinations, amounts, } = order; @@ -297,6 +304,9 @@ fn process_order( push_amount, )?; + // Copied out before `account` is consumed below; the close check needs it. + let expected_rent_recipient = intent.sell_account_rent_recipient; + let updated: [u8; EncodedOrderAccount::SIZE] = EncodedOrderAccount::from(OrderAccount { amount_withdrawn, amount_received, @@ -307,6 +317,31 @@ fn process_order( let mut order_pda = *order_pda; order_pda.try_borrow_mut()?.copy_from_slice(&updated); + // Once all pulls are done, reclaim the sell token account's rent if it's + // left empty and the owner authorized us to close it. + let should_close = { + let token_account = TokenAccount::from_account_view(sell_token_account) + .map_err(|_| SettlementError::SellTokenAccountInvalid)?; + token_account.amount() == 0 + && token_account.close_authority() == Some(state_account.address()) + }; + if should_close { + // Confirm the rent recipient account given by the solver is the + // one intended for the user. We explicitly only check this here + // so that the solver can specify a different/duplicated account + // if the account will not be closed. + if sell_account_rent_recipient.address() != &expected_rent_recipient { + return Err(SettlementError::SellAccountRentRecipientMismatch.into()); + } + + CloseAccount::new( + sell_token_account, + sell_account_rent_recipient, + state_account, + ) + .invoke_signed(core::slice::from_ref(state_pda_signer))?; + } + Ok(()) } diff --git a/programs/settlement/tests/begin_settle_orders.rs b/programs/settlement/tests/begin_settle_orders.rs index 77736d31..2b30d9ee 100644 --- a/programs/settlement/tests/begin_settle_orders.rs +++ b/programs/settlement/tests/begin_settle_orders.rs @@ -241,6 +241,7 @@ fn rejects_fabricated_program_owned_account() { auction_id: 0, order_pdas: &[fake_order], sell_token_accounts: &[sell_token], + sell_account_rent_recipients: &[intent.sell_account_rent_recipient], pulls: &no_pulls(1), }; // Mostly placeholder values: the transaction will reject before reaching @@ -280,6 +281,7 @@ fn rejects_non_order_account_in_order_slot() { auction_id: 0, order_pdas: &[sell_token], sell_token_accounts: &[sell_token], + sell_account_rent_recipients: &[unique_pubkey()], pulls: &no_pulls(1), }; // The finalize just carries a placeholder push matching the order in count. @@ -434,14 +436,21 @@ fn rejects_orders_in_wrong_address_order() { // instructions by hand in the current wire format. Begin data is // `[discriminator, finalize_ix_index (LE), order_count, transfer_count×n]` // (no transfers here) and begin accounts are `[instructions_sysvar, state_pda, - // token_program, (order_pda, sell_token_account)...]`. The finalize's push - // destinations are laid out in the same decreasing order, so the first order's - // destination check passes and the second order trips the ordering check. + // token_program, (order_pda, sell_token_account, sell_account_rent_recipient)...]`. + // The finalize's push destinations are laid out in the same decreasing order, so + // the first order's destination check passes and the second order trips the + // ordering check. let mut orders = [ - (first_pda, first.sell_token_account, first.buy_token_account), + ( + first_pda, + first.sell_token_account, + first.sell_account_rent_recipient, + first.buy_token_account, + ), ( second_pda, second.sell_token_account, + second.sell_account_rent_recipient, second.buy_token_account, ), ]; @@ -459,9 +468,10 @@ fn rejects_orders_in_wrong_address_order() { AccountMeta::new_readonly(find_state_pda(&program_id).0, false), AccountMeta::new_readonly(SPL_TOKEN_PROGRAM_ID, false), ]; - for (order_pda, sell_token_account, _) in orders { + for (order_pda, sell_token_account, rent_recipient, _) in orders { accounts.push(AccountMeta::new_readonly(order_pda, false)); accounts.push(AccountMeta::new(sell_token_account, false)); + accounts.push(AccountMeta::new(rent_recipient, false)); } let begin = Instruction { program_id, @@ -474,7 +484,7 @@ fn rejects_orders_in_wrong_address_order() { // the ordering before the pushes execute, so only the destinations and their // count matter, not the source buffers they'd draw from. let source_buffers: Vec = orders.iter().map(|_| unique_pubkey()).collect(); - let destinations: Vec = orders.iter().map(|&(_, _, buy)| buy).collect(); + let destinations: Vec = orders.iter().map(|&(_, _, _, buy)| buy).collect(); let bumps = vec![0u8; orders.len()]; let amounts = vec![0u64; orders.len()]; let finalize = FinalizeSettleRaw { @@ -622,6 +632,344 @@ fn pulls_funds_to_destination() { ); } +/// A settlement that closes the sell token account must send the reclaimed rent +/// to the recipient the intent names, so a substituted account is rejected. The +/// order's sell token account is already empty and has the state PDA as its +/// close authority, which is what makes this settlement close it. +#[test] +fn rejects_sell_account_rent_recipient_mismatch_when_closing() { + let (mut svm, program_id, payer) = setup(); + let state_pda = find_state_pda(&program_id).0; + + let rent_recipient = unique_pubkey(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_account_rent_recipient(&rent_recipient) + .build(); + token::set_close_authority(&mut svm, &payer, &intent.sell_token_account, &state_pda); + + let mut instructions = settle_and_pay( + &mut svm, + &program_id, + &payer, + &[InitializedIntent { + intent: &intent, + pulls: &[], + }], + ); + replace_first_matching_account( + &mut instructions[usize::from(BEGIN_INDEX)], + &rent_recipient, + unique_pubkey(), + ); + + assert_begin_error( + send(&mut svm, &payer, instructions), + SettlementError::SellAccountRentRecipientMismatch, + ); +} + +/// The other reason a settlement doesn't close the sell token account: the +/// order authorizes closing (the state PDA is the close authority) but the pull +/// leaves funds behind. The rent recipient goes unread there too, so a +/// throwaway address is accepted. +#[test] +fn accepts_mismatched_rent_recipient_when_sell_account_retains_funds() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let state_pda = find_state_pda(&program_id).0; + + // Pull half the balance: the account is authorized to be closed but isn't + // empty afterwards. + let initial_amount = 42_000_000; + let pulled = initial_amount / 2; + let paid = 84_000_000; + let rent_recipient = unique_pubkey(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_mint(&mint) + .sell_amount(initial_amount) + .buy_amount(paid) + .sell_account_rent_recipient(&rent_recipient) + .build(); + let sell_token = intent.sell_token_account; + token::fund_and_delegate(&mut svm, &program_id, &payer, &sell_token, initial_amount); + token::set_close_authority(&mut svm, &payer, &sell_token, &state_pda); + let destination = token::create_token_account(&mut svm, &payer, &mint, &unique_pubkey()); + + let mut instructions = settle_and_pay_amounts( + &mut svm, + &program_id, + &payer, + &[InitializedIntent { + intent: &intent, + pulls: &[Pull { + destination, + amount: pulled, + }], + }], + &[paid], + ); + let throwaway = unique_pubkey(); + replace_first_matching_account( + &mut instructions[usize::from(BEGIN_INDEX)], + &rent_recipient, + throwaway, + ); + + send(&mut svm, &payer, instructions) + .expect("a partial pull leaves the sell account open and its rent recipient unread"); + + assert_eq!(token::balance(&svm, &destination), pulled); + assert_eq!( + token::balance(&svm, &sell_token), + initial_amount - pulled, + "the sell token account should still hold the funds that weren't pulled" + ); + assert!( + svm.get_account(&throwaway) + .is_none_or(|account| account.lamports == 0), + "the throwaway account should not have received anything" + ); +} + +/// The rent recipient slot is only read when the settlement closes the sell +/// token account. An order that doesn't authorize closing never touches it, so +/// a settlement may put a throwaway address there. +#[test] +fn accepts_mismatched_rent_recipient_when_not_closing() { + let (mut svm, program_id, payer) = setup(); + + // No close authority is set on the sell token account, so this settlement + // can't close it however empty it is. + let rent_recipient = unique_pubkey(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_account_rent_recipient(&rent_recipient) + .build(); + let mut instructions = settle_and_pay( + &mut svm, + &program_id, + &payer, + &[InitializedIntent { + intent: &intent, + pulls: &[], + }], + ); + let throwaway = unique_pubkey(); + replace_first_matching_account( + &mut instructions[usize::from(BEGIN_INDEX)], + &rent_recipient, + throwaway, + ); + + send(&mut svm, &payer, instructions) + .expect("a settlement that doesn't close the sell account ignores the rent recipient"); + // Nothing was closed, so no lamports moved to either address. + assert!( + svm.get_account(&throwaway) + .is_none_or(|account| account.lamports == 0), + "the throwaway account should not have received anything" + ); + assert!( + svm.get_account(&rent_recipient) + .is_none_or(|account| account.lamports == 0), + "the intent's rent recipient should not have received anything" + ); + assert!( + svm.get_account(&intent.sell_token_account).is_some(), + "the sell token account should still be open" + ); +} + +#[test] +fn closes_sell_token_account_once_emptied_with_matching_close_authority() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let state_pda = find_state_pda(&program_id).0; + + // The settlement pulls the whole balance, so the order is priced for it: + // sell 42_000_000 for at least 84_000_000 at limit price. + let initial_amount = 42_000_000; + let paid = 84_000_000; + let rent_recipient = unique_pubkey(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_mint(&mint) + .sell_amount(initial_amount) + .buy_amount(paid) + .sell_account_rent_recipient(&rent_recipient) + .build(); + let sell_token = intent.sell_token_account; + let buy_token = intent.buy_token_account; + token::fund_and_delegate(&mut svm, &program_id, &payer, &sell_token, initial_amount); + token::set_close_authority(&mut svm, &payer, &sell_token, &state_pda); + let destination = token::create_token_account(&mut svm, &payer, &mint, &unique_pubkey()); + + let sell_token_rent = svm + .get_account(&sell_token) + .expect("sell token account should exist before settlement") + .lamports; + let buy_token_lamports = svm + .get_account(&buy_token) + .expect("buy token account should exist before settlement") + .lamports; + + let instructions = settle_and_pay_amounts( + &mut svm, + &program_id, + &payer, + &[InitializedIntent { + intent: &intent, + pulls: &[Pull { + destination, + amount: initial_amount, + }], + }], + &[paid], + ); + send(&mut svm, &payer, instructions) + .expect("closing sell token account as part of settlement should succeed"); + + assert_eq!(token::balance(&svm, &destination), initial_amount); + // The now-empty sell token account is closed, and its rent goes to the rent + // recipient the intent names. + assert!( + svm.get_account(&sell_token).is_none_or(|account| { + // Some SVM backends keep a zeroed record for a closed account. + account.lamports == 0 + }), + "the emptied sell token account should have been closed" + ); + assert_eq!( + svm.get_account(&rent_recipient) + .expect("the rent recipient should hold the reclaimed rent") + .lamports, + sell_token_rent, + ); + // The buy token account is not the rent recipient, so only its pushed + // proceeds change, never its lamports. + assert_eq!( + svm.get_account(&buy_token) + .expect("buy token account should exist after settlement") + .lamports, + buy_token_lamports, + ); +} + +/// A sell token account emptied by an early pull is only closed once every pull +/// for that order has run: closing mid-loop would make the remaining pulls fail +/// on a closed account. The delegation here exceeds the balance so the delegate +/// survives the emptying pull and the trailing pull is reached at all. +#[test] +fn closes_sell_token_account_only_after_all_pulls() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + let state_pda = find_state_pda(&program_id).0; + + let balance = 30_000_000; + let delegated = 42_000_000; + let paid = 60_000_000; + let rent_recipient = unique_pubkey(); + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_mint(&mint) + .sell_amount(balance) + .buy_amount(paid) + .sell_account_rent_recipient(&rent_recipient) + .build(); + let sell_token = intent.sell_token_account; + token::mint_to(&mut svm, &payer, &mint, &sell_token, balance); + token::delegate(&mut svm, &payer, &sell_token, &state_pda, delegated); + token::set_close_authority(&mut svm, &payer, &sell_token, &state_pda); + + let sell_token_rent = svm + .get_account(&sell_token) + .expect("sell token account should exist before settlement") + .lamports; + + // The first pull empties the account; the second one is a zero-amount pull + // that would fail if the account had already been closed. + let emptying_destination = + token::create_token_account(&mut svm, &payer, &mint, &unique_pubkey()); + let trailing_destination = + token::create_token_account(&mut svm, &payer, &mint, &unique_pubkey()); + let instructions = settle_and_pay_amounts( + &mut svm, + &program_id, + &payer, + &[InitializedIntent { + intent: &intent, + pulls: &[ + Pull { + destination: emptying_destination, + amount: balance, + }, + Pull { + destination: trailing_destination, + amount: 0, + }, + ], + }], + &[paid], + ); + send(&mut svm, &payer, instructions) + .expect("a pull following the one that empties the sell account should succeed"); + + assert_eq!(token::balance(&svm, &emptying_destination), balance); + assert_eq!(token::balance(&svm, &trailing_destination), 0); + assert!( + svm.get_account(&sell_token) + .is_none_or(|account| account.lamports == 0), + "the emptied sell token account should have been closed" + ); + assert_eq!( + svm.get_account(&rent_recipient) + .expect("the rent recipient should hold the reclaimed rent") + .lamports, + sell_token_rent, + ); +} + +#[test] +fn leaves_sell_token_account_open_without_matching_close_authority() { + let (mut svm, program_id, payer) = setup(); + let mint = token::create_mint(&mut svm, &payer); + + // No close authority is set on the sell token account, unlike + // `closes_sell_token_account_once_emptied_with_matching_close_authority`. + let initial_amount = 42_000_000; + let paid = 84_000_000; + let intent = OrderBuilder::new(&mut svm, &program_id, &payer) + .sell_mint(&mint) + .sell_amount(initial_amount) + .buy_amount(paid) + .build(); + let sell_token = intent.sell_token_account; + token::fund_and_delegate(&mut svm, &program_id, &payer, &sell_token, initial_amount); + let destination = token::create_token_account(&mut svm, &payer, &mint, &unique_pubkey()); + + let instructions = settle_and_pay_amounts( + &mut svm, + &program_id, + &payer, + &[InitializedIntent { + intent: &intent, + pulls: &[Pull { + destination, + amount: initial_amount, + }], + }], + &[paid], + ); + send(&mut svm, &payer, instructions) + .expect("leaving sell token account open without authority should succeed"); + + assert_eq!(token::balance(&svm, &destination), initial_amount); + // The sell token account is empty but wasn't closed, since the state PDA + // was never authorized as its close authority. + assert_eq!(token::balance(&svm, &sell_token), 0); + assert!( + svm.get_account(&sell_token).is_some(), + "the sell token account should still be open" + ); +} + #[test] fn pulls_to_multiple_destinations() { let (mut svm, program_id, payer) = setup(); @@ -992,7 +1340,7 @@ fn rejects_extra_account() { ); // Append one extra account to `BeginSettle`, so the account count no longer - // matches the `2n + T` the instruction data implies. + // matches the `3n + T` the instruction data implies. instructions[usize::from(BEGIN_INDEX)] .accounts .push(AccountMeta::new_readonly(unique_pubkey(), false)); diff --git a/programs/settlement/tests/common/order.rs b/programs/settlement/tests/common/order.rs index 0b2235eb..795ba166 100644 --- a/programs/settlement/tests/common/order.rs +++ b/programs/settlement/tests/common/order.rs @@ -23,6 +23,7 @@ pub fn sample_intent(owner: Pubkey, sell_token_account: Pubkey, salt: u8) -> Ord valid_to: 0xdead_beef, kind: OrderKind::Sell, partially_fillable: true, + sell_account_rent_recipient: Pubkey::new_from_array([0x44; 32]), app_data: [salt; 32], } } @@ -124,6 +125,14 @@ impl<'a> OrderBuilder<'a> { self } + /// Set the account receiving the sell token account's rent when a + /// settlement closes it. Defaults to [`sample_intent`]'s placeholder + /// address, which no test asserts on. + pub fn sell_account_rent_recipient(mut self, recipient: &Pubkey) -> Self { + self.intent.sell_account_rent_recipient = *recipient; + self + } + pub fn build(self) -> OrderIntent { let Self { svm, diff --git a/programs/settlement/tests/common/token.rs b/programs/settlement/tests/common/token.rs index 4a21e814..54105cfe 100644 --- a/programs/settlement/tests/common/token.rs +++ b/programs/settlement/tests/common/token.rs @@ -3,8 +3,11 @@ use cow_settlement_client::cow_settlement_interface::pda::state::find_state_pda; use litesvm::{types::TransactionMetadata, LiteSVM}; use litesvm_token::{ - spl_token::{instruction::initialize_mint2, state::Mint}, - Approve, CreateAccount, CreateAssociatedTokenAccount, MintTo, Transfer, TOKEN_ID, + spl_token::{ + instruction::{initialize_mint2, AuthorityType}, + state::Mint, + }, + Approve, CreateAccount, CreateAssociatedTokenAccount, MintTo, SetAuthority, Transfer, TOKEN_ID, }; use solana_program_pack::Pack; use solana_sdk::{ @@ -189,6 +192,22 @@ pub fn assert_no_token_instruction_touching( } } +/// Set `account`'s SPL close authority to `new_authority`, signed by `owner` +/// (the account's current SPL owner, which may set the close authority as long +/// as none is set yet). +pub fn set_close_authority( + svm: &mut LiteSVM, + owner: &Keypair, + account: &Pubkey, + new_authority: &Pubkey, +) { + SetAuthority::new(svm, owner, account, AuthorityType::CloseAccount) + .owner(owner) + .new_authority(new_authority) + .send() + .expect("setting close authority should succeed"); +} + /// Read the mint that `account` holds tokens of. pub fn mint_of(svm: &LiteSVM, account: &Pubkey) -> Pubkey { litesvm_token::get_spl_account::(svm, account) diff --git a/test-cli/src/cmd/create_order.rs b/test-cli/src/cmd/create_order.rs index 3285ecf0..c28089ad 100644 --- a/test-cli/src/cmd/create_order.rs +++ b/test-cli/src/cmd/create_order.rs @@ -3,11 +3,13 @@ use clap::{Args as ClapArgs, Parser}; use cow_settlement_client::{ cow_settlement_interface::{ data::intent::{OrderIntent, OrderKind}, - pda::order::find_order_pda, + pda::{order::find_order_pda, state::find_state_pda}, }, instructions::CreateOrder, }; -use solana_sdk::{signature::Signer, transaction::Transaction}; +use solana_sdk::{ + program_option::COption, pubkey::Pubkey, signature::Signer, transaction::Transaction, +}; use std::time::{SystemTime, UNIX_EPOCH}; use super::Context; @@ -22,6 +24,17 @@ struct CommonArgs { /// Allow partial fills across multiple settlements #[arg(long)] partially_fillable: bool, + + /// Address receiving the sell token account's rent if a settlement closes + /// the account once it's empty (defaults to the payer). If specified, will also + /// set close authority to the settlement program unless otherwise specified. + #[arg(long)] + sell_account_rent_recipient: Option, + + /// Explicitly control. If unset, will grant close authority if `--sell-account-rent-recipient` is set + /// and the close authority is not already granted. + #[arg(long)] + set_close_authority: Option, } #[derive(Parser)] @@ -134,7 +147,6 @@ fn execute(ctx: Context, parsed: ParsedOrder, common: CommonArgs) -> anyhow::Res } = parsed; // If the sell token is SOL, wrap it into the payer's WSOL ATA first. - // NOTE: later this will be swapped for the solflow program. let mut ixs = Vec::new(); if sell_is_sol { @@ -147,11 +159,36 @@ fn execute(ctx: Context, parsed: ParsedOrder, common: CommonArgs) -> anyhow::Res // Create the account on the buy side if necessary ixs.extend(buy.create_ata_ix(&ctx.payer.pubkey())); + let (state_pda, _bump) = find_state_pda(&ctx.program_id); + + // Grant CloseAuthority permission to the settlement program state account if + // rent recipient was set. + if common + .set_close_authority + .unwrap_or(common.sell_account_rent_recipient.is_some()) + { + // A sell account that doesn't exist yet (`None`) is created earlier in + // this same transaction, so it's still fine to set the authority on it. + if let Some(COption::Some(close_authority)) = sell.ta_data.map(|ta| ta.close_authority) { + if close_authority != state_pda { + println!("WARN: Skipping set of close authority: already set to non-settlement account {close_authority}"); + } + } else { + ixs.push(crate::instructions::set_close_authority( + &spl_token_interface::ID, + &sell.ta, + &ctx.payer.pubkey(), + &state_pda, + )?); + } + } + // Approve the settlement state PDA to pull sell tokens on the user's behalf. ixs.push(crate::instructions::approve( - &ctx.program_id, + &spl_token_interface::ID, &sell.ta, &ctx.payer.pubkey(), + &state_pda, sell_amount, )?); @@ -164,6 +201,9 @@ fn execute(ctx: Context, parsed: ParsedOrder, common: CommonArgs) -> anyhow::Res valid_to: common.valid_to, kind, partially_fillable: common.partially_fillable, + sell_account_rent_recipient: common + .sell_account_rent_recipient + .unwrap_or_else(|| ctx.payer.pubkey()), app_data: [0u8; 32], }; diff --git a/test-cli/src/instructions.rs b/test-cli/src/instructions.rs index 665b373a..99935639 100644 --- a/test-cli/src/instructions.rs +++ b/test-cli/src/instructions.rs @@ -2,7 +2,7 @@ use crate::token; use anyhow::Context as _; -use cow_settlement_client::cow_settlement_interface::{pda::state::find_state_pda, Pubkey}; +use cow_settlement_client::cow_settlement_interface::Pubkey; use solana_instruction::Instruction; use solana_rpc_client::rpc_client::RpcClient; use spl_token_interface::instruction::{self as token_ix}; @@ -35,22 +35,40 @@ pub fn wrap_sol( } /// Build an `Approve` instruction delegating `amount` tokens on `token_account` -/// to the PDA derived from `program_id`. +/// to `delegate`. pub fn approve( - program_id: &Pubkey, + token_program_id: &Pubkey, token_account: &Pubkey, owner: &Pubkey, + delegate: &Pubkey, amount: u64, ) -> anyhow::Result { - let (settlement_pda, _) = find_state_pda(program_id); - token_ix::approve( - &spl_token_interface::id(), + token_program_id, token_account, - &settlement_pda, + delegate, owner, &[], amount, ) .context("failed to build Approve instruction") } + +/// Build `SetAuthority` instruction which modifies the current CloseAuthority +/// for an account. +pub fn set_close_authority( + token_program_id: &Pubkey, + token_account: &Pubkey, + owner: &Pubkey, + new_close_authority: &Pubkey, +) -> anyhow::Result { + token_ix::set_authority( + token_program_id, + token_account, + Some(new_close_authority), + token_ix::AuthorityType::CloseAccount, + owner, + &[], + ) + .context("failed to build SetAuthority instruction") +} diff --git a/test-cli/src/token.rs b/test-cli/src/token.rs index 89505100..1c014c1c 100644 --- a/test-cli/src/token.rs +++ b/test-cli/src/token.rs @@ -46,6 +46,8 @@ fn known_token(genesis_hash: &str, symbol: &str) -> Option<&'static KnownToken> pub struct ResolvedToken { /// SPL token account to use in the order (ATA if supplied program argument was a mint). pub ta: Pubkey, + /// The actual token account data, or `None` when `ta` does not exist yet. + pub ta_data: Option, /// Mint address for the token. pub mint: Pubkey, /// The actual mint data @@ -83,6 +85,7 @@ pub fn resolve(rpc: &RpcClient, owner: &Pubkey, token_str: &str) -> anyhow::Resu ); return Ok(ResolvedToken { ta: wsol_ata, + ta_data: fetch_ta_data(rpc, &wsol_ata)?, mint: wsol_mint, create_ata: determine_create_ata(rpc, &wsol_mint, owner)?, mint_data: fetch_mint_data(rpc, &wsol_mint)?, @@ -107,6 +110,7 @@ pub fn resolve(rpc: &RpcClient, owner: &Pubkey, token_str: &str) -> anyhow::Resu ); return Ok(ResolvedToken { ta: ata, + ta_data: fetch_ta_data(rpc, &ata)?, create_ata: determine_create_ata(rpc, &known.mint, owner)?, mint: known.mint, mint_data: fetch_mint_data(rpc, &known.mint)?, @@ -137,6 +141,7 @@ pub fn resolve_from_token_account( Ok(ResolvedToken { ta: *token_account, + ta_data: Some(decoded_account), mint: decoded_account.mint, mint_data: fetch_mint_data(rpc, &decoded_account.mint)?, // The account was just fetched and unpacked above, so it already exists. @@ -166,6 +171,7 @@ pub fn interpret_token_from_user_input( if let Ok(token_account) = TokenAccount::unpack(&account.data) { Ok(ResolvedToken { ta: *token_account_or_mint, + ta_data: Some(token_account), mint: token_account.mint, mint_data: fetch_mint_data(rpc, &token_account.mint)?, // The account was just fetched and unpacked above, so it already exists. @@ -179,6 +185,7 @@ pub fn interpret_token_from_user_input( ); Ok(ResolvedToken { ta: ata, + ta_data: fetch_ta_data(rpc, &ata)?, mint_data: mint, mint: *token_account_or_mint, create_ata: determine_create_ata(rpc, token_account_or_mint, owner)?, @@ -220,3 +227,18 @@ fn fetch_mint_data(rpc: &RpcClient, mint: &Pubkey) -> anyhow::Result { Err(anyhow::anyhow!("account {mint} is not a mint")) } } + +/// Read `ta`'s current state, or `None` if it doesn't exist on-chain yet — the +/// callers resolve accounts they may still have to create, so a missing account +/// isn't an error here. +fn fetch_ta_data(rpc: &RpcClient, ta: &Pubkey) -> anyhow::Result> { + let Ok(data) = rpc.get_account_data(ta) else { + return Ok(None); + }; + + if let Ok(ta_data) = TokenAccount::unpack(&data) { + Ok(Some(ta_data)) + } else { + Err(anyhow::anyhow!("account {ta} is not a SPL token account")) + } +}