diff --git a/DESIGN.md b/DESIGN.md index b83322c..bd79007 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -132,6 +132,8 @@ struct OrderIntent { } struct Flags { + // Indicates the path by which the order was created. Important for reclaim. + created_on_chain: bool // Either Buy or Sell kind: OrderKind partially_fillable: bool @@ -208,6 +210,8 @@ Allocating an order PDA requires paying rent. If the order is expired, anyone can close the order account through the `ReclaimOrder` instruction. On account closure, the rent is sent to the order's `created_by` account, i.e., the original creator of the order. +If an order created on-chain (`created_on_chain`), it can safely be closed earlier. In this case, `ReclaimOrder` will additionally allow reclaiming of orders that are cancelled or completely filled. + This is useful for solvers who need to allocate the order for executing it, but the allocation itself would be orders of magnitude more expensive than the compute cost for executing an instruction. This is particularly relevant to make small orders economically viable. ## Authenticating an order @@ -235,6 +239,8 @@ Raw Ed25519 signatures are supported by all native Solana accounts. The data to be signed is encoded as an off-chain message and signed with raw Ed25519 signatures. +Orders that are created by this path must specify the flag `created_on_chain = false`. + Differences with Ethereum: - Unlike ECDSA signatures in Ethereum, the owner account address cannot be recovered from the Ed25519 signature. This means that the address needs to be included as part of the signed data. @@ -248,6 +254,8 @@ The order owner executes the `CreateOrder` instruction. The settlement program c In this authentication flow, the user needs to pay for the rent in SOL necessary to create the PDA. Note that the rent may be significantly higher than the expected trading fee. The rent can be recovered by the user once the order has expired by [clearing the order](#order-clearing). +Orders that are created by this path must specify the flag `created_on_chain = true`. + This flow supports both standard ("on-curve") accounts and PDA signatures. This flow is the only one allowing on-chain programs to trade through the settlement program. diff --git a/bench-report.json b/bench-report.json index 738f6ee..961ca5d 100644 --- a/bench-report.json +++ b/bench-report.json @@ -11,7 +11,11 @@ "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7, "reclaim_buffer/max_buffers_in_one_instruction": 64, "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 9, - "reclaim_order/happy_path_returns_lamports_and_closes_pda": 4, + "reclaim_order/happy_path_expired_returns_lamports_and_closes_pda": 4, + "reclaim_order/happy_path_on_chain_order_cancelled_is_reclaimable_before_expiry": 3, + "reclaim_order/happy_path_on_chain_order_fully_filled_is_reclaimable_before_expiry": 3, + "reclaim_order/off_chain_order_is_reclaimable_only_once_expired": 3, + "reclaim_order/on_chain_order_partially_filled_is_not_reclaimable_before_expiry": 3, "settle/finalizes_with_no_pushes": 5, "settle/pulls_from_multiple_orders": 15, "settle/pulls_funds_to_destination": 10, @@ -31,22 +35,26 @@ "create_buffers/happy_path_creates_initialized_buffer_token_account": 10345, "create_buffers/happy_path_creates_multiple_buffers_in_one_instruction": 21743, "create_buffers/max_buffers_in_one_instruction": 177040, - "create_order/happy_path_creates_order_pda_with_expected_body": 4978, + "create_order/happy_path_creates_order_pda_with_expected_body": 9485, "initialize/happy_path_initializes_state_pda_with_expected_data": 4529, "reclaim_buffer/funded_buffer_is_skipped": 6335, "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 7483, "reclaim_buffer/max_buffers_in_one_instruction": 136652, "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 18082, - "reclaim_order/happy_path_returns_lamports_and_closes_pda": 2183, + "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, + "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": 20043, - "settle/pulls_funds_to_destination": 13632, - "settle/pulls_to_multiple_destinations": 14773, - "settle/pushes_a_single_order": 12487, - "settle/pushes_several_orders_from_different_buffers": 17751, - "settle/pushes_several_orders_from_one_buffer": 17750, - "settle/settles_a_single_order": 12505, - "settle/settles_multiple_orders": 23062, + "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 @@ -63,7 +71,11 @@ "reclaim_buffer/happy_path_reclaims_empty_buffer_to_the_authority_itself": 400, "reclaim_buffer/max_buffers_in_one_instruction": 332, "reclaim_buffer/reclaims_multiple_buffers_skipping_funded": 466, - "reclaim_order/happy_path_returns_lamports_and_closes_pda": 236, + "reclaim_order/happy_path_expired_returns_lamports_and_closes_pda": 236, + "reclaim_order/happy_path_on_chain_order_cancelled_is_reclaimable_before_expiry": 204, + "reclaim_order/happy_path_on_chain_order_fully_filled_is_reclaimable_before_expiry": 204, + "reclaim_order/off_chain_order_is_reclaimable_only_once_expired": 204, + "reclaim_order/on_chain_order_partially_filled_is_not_reclaimable_before_expiry": 204, "settle/finalizes_with_no_pushes": 290, "settle/pulls_from_multiple_orders": 656, "settle/pulls_funds_to_destination": 473, diff --git a/interface/src/data/intent.rs b/interface/src/data/intent.rs index e38408a..3967ed6 100644 --- a/interface/src/data/intent.rs +++ b/interface/src/data/intent.rs @@ -5,7 +5,8 @@ //! - [`OrderIntent`] is the idiomatic Rust representation. //! - [`EncodedOrderIntent`] is its canonical byte representation: the only //! thing sent on the wire and also the data encoding used to generate the -//! order UID. +//! order UID. There, `kind` and the intent's booleans share a single flags +//! byte. //! //! Conversion is asymmetric: [`EncodedOrderIntent`]`::from(OrderIntent)` is //! infallible, but decoding raw bytes via [`OrderIntent`]`::try_from` returns @@ -33,6 +34,12 @@ pub enum OrderKind { /// Collection of [`OrderIntent`] fields that can be represented as a single bit. #[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] pub struct Flags { + /// How the order is authenticated: `true` if the owner creates it + /// themselves with a `CreateOrder` instruction they sign; `false` if it's + /// authenticated off-chain by an Ed25519 signature, which lets anyone + /// holding that signature create the order. + pub created_on_chain: bool, + /// Whether `sell_amount` or `buy_amount` is the exact figure; the /// other side is treated as the limit (minimum to receive for `Sell`, /// maximum to spend for `Buy`). @@ -47,23 +54,27 @@ pub struct Flags { impl Flags { // The bit each field occupies - const PARTIALLY_FILLABLE: u8 = 1 << 1; - const KIND: u8 = 1 << 0; + const CREATED_ON_CHAIN: u8 = 1 << 0; + const KIND: u8 = 1 << 1; + const PARTIALLY_FILLABLE: u8 = 1 << 2; /// Every bit the encoding defines; the others are reserved. - const DEFINED: u8 = Self::PARTIALLY_FILLABLE | Self::KIND; + const DEFINED: u8 = Self::CREATED_ON_CHAIN | Self::KIND | Self::PARTIALLY_FILLABLE; } impl From for [u8; 1] { /// The canonical flags byte. Reserved bits are left clear. fn from(flags: Flags) -> Self { let mut byte = 0; - if flags.partially_fillable { - byte |= Flags::PARTIALLY_FILLABLE; + if flags.created_on_chain { + byte |= Flags::CREATED_ON_CHAIN; } if flags.kind == OrderKind::Buy { byte |= Flags::KIND; } + if flags.partially_fillable { + byte |= Flags::PARTIALLY_FILLABLE; + } [byte] } } @@ -81,6 +92,7 @@ impl TryFrom<[u8; 1]> for Flags { return Err(ProgramError::InvalidInstructionData); } Ok(Flags { + created_on_chain: byte & Self::CREATED_ON_CHAIN != 0, kind: if byte & Self::KIND == 0 { OrderKind::Sell } else { @@ -350,10 +362,13 @@ pub mod fixtures { /// Any valid [`Flags`]. pub fn arb_flags() -> impl Strategy { - (arb_order_kind(), any::()).prop_map(|(kind, partially_fillable)| Flags { - kind, - partially_fillable, - }) + (any::(), arb_order_kind(), any::()).prop_map( + |(created_on_chain, kind, partially_fillable)| Flags { + created_on_chain, + kind, + partially_fillable, + }, + ) } /// Any flags byte the decoder accepts. @@ -419,14 +434,18 @@ mod tests { use super::fixtures::sample_intent; use super::*; - // Every shape an `OrderIntent` can take on its validated axes: the `kind` - // enum and the `partially_fillable` flag bit. + // Every shape an `OrderIntent` can take on its validated axes: the + // `created_on_chain` flag bit, the `kind` enum, and the + // `partially_fillable` flag bit. fn all_flag_shapes() -> impl Iterator { - fixtures::ALL_ORDER_KINDS.into_iter().flat_map(|kind| { - [false, true].into_iter().map(move |partially_fillable| { - sample_intent(Flags { - kind, - partially_fillable, + [false, true].into_iter().flat_map(|created_on_chain| { + fixtures::ALL_ORDER_KINDS.into_iter().flat_map(move |kind| { + [false, true].into_iter().map(move |partially_fillable| { + sample_intent(Flags { + created_on_chain, + kind, + partially_fillable, + }) }) }) }) @@ -489,6 +508,7 @@ mod tests { fn every_flag_owns_a_distinct_bit() { let byte = |flags: Flags| <[u8; 1]>::from(flags)[0]; let cleared = Flags { + created_on_chain: false, kind: OrderKind::Sell, partially_fillable: false, }; @@ -496,9 +516,9 @@ mod tests { let set_one_by_one = [ ( - Flags::PARTIALLY_FILLABLE, + Flags::CREATED_ON_CHAIN, Flags { - partially_fillable: true, + created_on_chain: true, ..cleared }, ), @@ -509,14 +529,21 @@ mod tests { ..cleared }, ), + ( + Flags::PARTIALLY_FILLABLE, + Flags { + partially_fillable: true, + ..cleared + }, + ), ]; let mut seen = 0u8; for (bit, flags) in set_one_by_one { assert_eq!(bit.count_ones(), 1, "a flag must occupy a single bit"); assert_eq!(seen & bit, 0, "two flags must not share a bit"); assert!( - seen == 0 || bit < seen, - "each flag must be less significant than the ones before it" + bit > seen, + "each flag must be more significant than the ones before it" ); seen |= bit; assert_eq!(byte(flags), bit); @@ -548,39 +575,6 @@ mod tests { } } - #[test] - fn sanity_check_offsets() { - fn first_differing_byte(lhs: &[u8], rhs: &[u8]) -> Option { - lhs.iter().zip(rhs).position(|(l, r)| l != r) - } - let sell_false: EncodedOrderIntent = (&sample_intent(Flags { - kind: OrderKind::Sell, - partially_fillable: false, - })) - .into(); - let sell_true: EncodedOrderIntent = (&sample_intent(Flags { - kind: OrderKind::Sell, - partially_fillable: true, - })) - .into(); - let buy_true: EncodedOrderIntent = (&sample_intent(Flags { - kind: OrderKind::Buy, - partially_fillable: true, - })) - .into(); - - assert_eq!( - first_differing_byte(sell_false.as_slice(), sell_true.as_slice()) - .expect("should have different flags byte"), - FLAGS_OFFSET - ); - assert_eq!( - first_differing_byte(buy_true.as_slice(), sell_true.as_slice()) - .expect("should have different flags byte"), - FLAGS_OFFSET - ); - } - #[test] fn decode_accepts_defined_flag_bits_only() { let encoded = EncodedOrderIntent::from(&sample_intent(Default::default())); @@ -606,18 +600,20 @@ mod tests { bytes.iter().map(|b| format!("{b:02x}")).collect() } let intent = sample_intent(Flags { + created_on_chain: true, kind: OrderKind::Buy, partially_fillable: true, }); assert_eq!( hex(intent.uid().as_ref()), - "eddfac5ab968e8c8843c913f58f0ecb5061948a8558d8073dafe53f6f28d398a", + "de4096c6c100056f1e4636ea4fafefad40fc1d0b37692fe3ca1e0db3644b86bd", ); } #[test] fn encoding_regression() { let encoded = EncodedOrderIntent::from(&sample_intent(Flags { + created_on_chain: true, kind: OrderKind::Buy, partially_fillable: true, })); @@ -655,8 +651,8 @@ mod tests { 0x10, 0x32, 0x54, 0x76, 0x98, 0xba, 0xdc, 0xfe, // valid_to (0xdead_beef, LE u32) 0xef, 0xbe, 0xad, 0xde, - // flags (partially_fillable | kind (Buy = 1)) - 0b00000011, + // flags (created_on_chain | kind (Buy = 1) | partially_fillable) + 0b00000111, // app_data ([0x66; 32]) 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, diff --git a/interface/src/data/order.rs b/interface/src/data/order.rs index 52567c2..5c88b43 100644 --- a/interface/src/data/order.rs +++ b/interface/src/data/order.rs @@ -28,7 +28,7 @@ use solana_hash::Hash; use solana_program_error::ProgramError; use solana_pubkey::Pubkey; -use crate::data::intent::{self, EncodedOrderIntent, OrderIntent}; +use crate::data::intent::{self, EncodedOrderIntent, OrderIntent, OrderKind}; use crate::pda::is_pda_with_signer_seeds; use crate::pda::order::order_pda_signer_seeds; use crate::{SettlementAccount, SettlementError}; @@ -86,6 +86,28 @@ impl OrderAccount { Ok(account) } + + /// Whether the order has been filled to the full amount of its exact side, + /// so no settlement can ever fill it again. + pub fn is_fully_filled(&self) -> bool { + let (filled, order_amount) = + fill_progress(&self.intent, self.amount_withdrawn, self.amount_received); + filled >= order_amount + } +} + +/// Extract the values relevant for understanding the fill of an order. +/// Returns a tuple. First return value is the amount currently filled, and the second return +/// value is the amount that has been requested to be filled by the intent. +pub fn fill_progress( + intent: &OrderIntent, + amount_withdrawn: u64, + amount_received: u64, +) -> (u64, u64) { + match intent.flags.kind { + OrderKind::Sell => (amount_withdrawn, intent.sell_amount), + OrderKind::Buy => (amount_received, intent.buy_amount), + } } /// Canonical 264-byte representation of an [`OrderAccount`]. The bytes @@ -314,6 +336,7 @@ mod tests { use super::fixtures::{sample_account, CANCELLED_OFFSET, DISCRIMINATOR_OFFSET, INTENT_OFFSET}; use super::*; use crate::data::intent::fixtures::{sample_intent, FLAGS_OFFSET}; + use crate::data::intent::Flags; // Pin each width to the size of the `OrderAccount` field it encodes. The // widths summing to `SIZE` is enforced separately, at compile time, by the @@ -363,6 +386,46 @@ mod tests { } } + #[test] + fn is_fully_filled_tracks_the_exact_side_only() { + const SELL_AMOUNT: u64 = 1_000; + const BUY_AMOUNT: u64 = 2_000; + + let account = |kind, amount_withdrawn, amount_received| OrderAccount { + amount_withdrawn, + amount_received, + intent: OrderIntent { + sell_amount: SELL_AMOUNT, + buy_amount: BUY_AMOUNT, + ..sample_intent(Flags { + kind, + ..Default::default() + }) + }, + ..sample_account(false) + }; + + // (kind, withdrawn, received, expected) + let cases = [ + (OrderKind::Sell, SELL_AMOUNT, 0, true), // fully filled SELL order (stolen money, generally impossible) + (OrderKind::Buy, 0, BUY_AMOUNT, true), // fully filled BUY order (free money) + (OrderKind::Sell, u64::MAX, 0, true), // sell fill past the intent amount (should be impossible) + (OrderKind::Sell, u64::MAX, u64::MAX, true), // sell fill past the intent amount (should be impossible) + (OrderKind::Buy, 0, u64::MAX, true), // buy fill past the intent amount + (OrderKind::Buy, u64::MAX, u64::MAX, true), // buy fill past the intent amount + (OrderKind::Sell, 0, 0, false), // unfilled order + (OrderKind::Sell, SELL_AMOUNT - 1, BUY_AMOUNT, false), // not fully filled SELL order + (OrderKind::Buy, SELL_AMOUNT, BUY_AMOUNT - 1, false), // not fully filled BUY order with fully filled sell side (generally should be impossible) + ]; + for (kind, withdrawn, received, expected) in cases { + assert_eq!( + account(kind, withdrawn, received).is_fully_filled(), + expected, + "{kind:?} order withdrawn={withdrawn} received={received}", + ); + } + } + #[test] fn sanity_check_offsets() { fn first_differing_byte(lhs: &[u8], rhs: &[u8]) -> Option { @@ -386,7 +449,7 @@ mod tests { // In general, it isn't guaranteed that the result encodes to a // valid intent, but in this case we know it because the only byte // that may fail decoding is the flags byte, and `^0x01` only flips - // its `kind` bit, never a reserved one. + // its `created_on_chain` flag bits, never a reserved one. let bitwise_different_encoded_intent: [u8; EncodedOrderIntent::SIZE] = encoded_intent.map(|b| b ^ 0x01); sample_account_base.intent = diff --git a/interface/src/instruction/create_order.rs b/interface/src/instruction/create_order.rs index 1b49af7..7182eaa 100644 --- a/interface/src/instruction/create_order.rs +++ b/interface/src/instruction/create_order.rs @@ -21,7 +21,9 @@ use crate::{data::intent::EncodedOrderIntent, SettlementInstruction}; /// derives the bump itself and rejects any other address. /// /// `owner` signs the instruction and must match the intent owner; this is -/// what authenticates the order. It may be a normal user account or a PDA, +/// what authenticates the order. The intent must also be flagged +/// `created_on_chain`, the authentication scheme this instruction implements, +/// or the program rejects it. It may be a normal user account or a PDA, /// the program does not check `is_on_curve`. A parent program that wants to /// create orders on behalf of its own PDA can `invoke_signed` into the /// settlement program using this instruction directly. diff --git a/interface/src/instruction/reclaim_order.rs b/interface/src/instruction/reclaim_order.rs index 26ef9a5..0824198 100644 --- a/interface/src/instruction/reclaim_order.rs +++ b/interface/src/instruction/reclaim_order.rs @@ -1,8 +1,10 @@ //! `ReclaimOrder` instruction builder. //! -//! Closes an expired order PDA and returns its rent lamports to the -//! `created_by` account recorded in the order body. The instruction may only be -//! executed after the order's `valid_to` timestamp has elapsed. +//! Closes an order PDA and returns its rent lamports to the `created_by` +//! account recorded in the order body. The instruction may only be executed +//! once the order's `valid_to` timestamp has elapsed, or, for an order created +//! on-chain (see `OrderIntent::created_on_chain`), as soon as it's cancelled or +//! completely filled. //! //! Wire format: `[discriminator=5]`, 1 byte. //! Required accounts: @@ -20,8 +22,8 @@ use crate::SettlementInstruction; /// `order_pda` is the order PDA to close. `reclaim_recipient` must be the /// account recorded as `created_by` in the order PDA; it receives the recovered /// rent lamports. -/// The instruction enforces no signature requirement: anyone may reclaim an -/// expired order on behalf of its reclaim_recipient. +/// The instruction enforces no signature requirement: anyone may reclaim a +/// reclaimable order on behalf of its reclaim_recipient. pub struct ReclaimOrder { pub program_id: Pubkey, pub order_pda: Pubkey, diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 9a294c8..5a2658b 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -207,8 +207,8 @@ pub enum SettlementError { /// `BeginSettle`: the order's cumulative `amount_received` would exceed /// `u64::MAX` once this settlement's push is added. AmountReceivedOverflow = 29, - /// `ReclaimOrder` was called before the order's `valid_to` has elapsed. - OrderNotExpired = 30, + /// `ReclaimOrder` was called on an order that has is not yet eligible for reclaim. + OrderNotReclaimable = 30, /// `ReclaimOrder`'s `reclaim_recipient` account doesn't match the /// `created_by` address recorded in the order. ReclaimRecipientMismatch = 31, @@ -230,6 +230,9 @@ pub enum SettlementError { /// `BeginSettle`/`FinalizeSettle`'s solver account isn't a signer or isn't /// in the state PDA's solver list, so it may not settle. UnauthorizedSolver = 37, + /// 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, } impl From for u32 { diff --git a/programs/settlement/src/create_order.rs b/programs/settlement/src/create_order.rs index b7d6c59..0899344 100644 --- a/programs/settlement/src/create_order.rs +++ b/programs/settlement/src/create_order.rs @@ -34,6 +34,11 @@ pub fn process_create_order( if owner.address() != &intent.owner { return Err(SettlementError::OwnerMismatch.into()); } + // The intent commits to how it's authenticated, and this is the on-chain + // creation flow. + if !intent.flags.created_on_chain { + return Err(SettlementError::OrderCreatedOnChainMismatch.into()); + } // We want a single order per uid; `CanonicalPda::create_new` derives the // canonical bump and, by signing the creation with the order seeds, rejects @@ -179,4 +184,25 @@ mod tests { Err(SettlementError::OwnerMismatch.into()), ); } + + #[test] + fn process_create_order_rejects_intent_not_created_on_chain() { + let intent: OrderIntent = (&valid_intent_bytes()).try_into().expect("should be valid"); + let intent_bytes: [u8; EncodedOrderIntent::SIZE] = + (&EncodedOrderIntent::from(&OrderIntent { ..intent })).into(); + let data = default_order_data(&intent_bytes); + let owner_runtime_account = RuntimeAccount { + address: DEFAULT_OWNER, + is_signer: 1, + ..Default::default() + }; + + let mut accounts = fake_sequential_accounts::(); + accounts[0] = fake_account_from(owner_runtime_account); + + assert_eq!( + process_create_order(&PROGRAM_ID, &mut accounts, &data), + Err(SettlementError::OrderCreatedOnChainMismatch.into()), + ); + } } diff --git a/programs/settlement/src/reclaim_order.rs b/programs/settlement/src/reclaim_order.rs index 1a9c9de..7a52190 100644 --- a/programs/settlement/src/reclaim_order.rs +++ b/programs/settlement/src/reclaim_order.rs @@ -27,9 +27,11 @@ pub fn process_reclaim_order( return Err(SettlementError::ReclaimRecipientMismatch.into()); } - let now = Clock::get()?.unix_timestamp; - if now <= i64::from(account.intent.valid_to) { - return Err(SettlementError::OrderNotExpired.into()); + if !is_reclaimable_before_expiry(&account) { + let now = Clock::get()?.unix_timestamp; + if now <= i64::from(account.intent.valid_to) { + return Err(SettlementError::OrderNotReclaimable.into()); + } } // Transfer the rent lamports to the reclaim_recipient account, then close the PDA. @@ -49,8 +51,15 @@ pub fn process_reclaim_order( Ok(()) } +/// Determines whether the order may be reclaimed despite being unexpired +fn is_reclaimable_before_expiry(account: &OrderAccount) -> bool { + account.intent.flags.created_on_chain && (account.cancelled || account.is_fully_filled()) +} + #[cfg(test)] mod tests { + use cow_settlement_interface::data::intent::Flags; + use cow_settlement_interface::data::intent::{fixtures::sample_intent, OrderIntent, OrderKind}; use cow_settlement_interface::data::order::EncodedOrderAccount; use cow_settlement_interface::instruction::{ fixtures::{fake_account, fake_account_with_data, fake_sequential_accounts}, @@ -99,4 +108,51 @@ mod tests { Err(SettlementError::ReclaimRecipientMismatch.into()), ); } + + #[test] + fn early_reclaim_conditions() { + const SELL_AMOUNT: u64 = 1_000; + + let account = |created_on_chain, cancelled, amount_withdrawn| OrderAccount { + cancelled, + amount_withdrawn, + intent: OrderIntent { + sell_amount: SELL_AMOUNT, + ..sample_intent(Flags { + created_on_chain, + kind: OrderKind::Sell, + partially_fillable: true, + }) + }, + ..Default::default() + }; + + // (created_on_chain, cancelled, amount_withdrawn, expected) + let cases = [ + // Created on-chain and either cancelled or fully settled. + (true, true, 0, true), + (true, false, SELL_AMOUNT, true), + (true, true, SELL_AMOUNT, true), + // Authenticated by signature: prior cancelled or fully settled cases no longer apply + (false, true, 0, false), + (false, false, SELL_AMOUNT, false), + (false, true, SELL_AMOUNT, false), + (false, false, 0, false), + // Created on-chain and not fully filled. + (true, false, 0, false), + (true, false, SELL_AMOUNT - 1, false), + ]; + for (created_on_chain, cancelled, amount_withdrawn, expected) in cases { + assert_eq!( + is_reclaimable_before_expiry(&account( + created_on_chain, + cancelled, + amount_withdrawn + )), + expected, + "created_on_chain={created_on_chain} cancelled={cancelled} \ + amount_withdrawn={amount_withdrawn}", + ); + } + } } diff --git a/programs/settlement/src/settle/begin.rs b/programs/settlement/src/settle/begin.rs index 9fa067d..4100403 100644 --- a/programs/settlement/src/settle/begin.rs +++ b/programs/settlement/src/settle/begin.rs @@ -442,6 +442,7 @@ mod tests { sell_amount: self.sell, buy_amount: self.buy, ..sample_intent(Flags { + created_on_chain: true, kind: self.kind, partially_fillable: self.partially_fillable, }) diff --git a/programs/settlement/tests/common/order.rs b/programs/settlement/tests/common/order.rs index 778a13c..dc0f831 100644 --- a/programs/settlement/tests/common/order.rs +++ b/programs/settlement/tests/common/order.rs @@ -27,6 +27,7 @@ pub fn sample_intent(owner: Pubkey, salt: u8) -> OrderIntent { buy_amount: 2_000_000, valid_to: 0xdead_beef, flags: Flags { + created_on_chain: true, kind: OrderKind::Sell, partially_fillable: true, }, diff --git a/programs/settlement/tests/common/settlement.rs b/programs/settlement/tests/common/settlement.rs index 0d10eb9..f596d2a 100644 --- a/programs/settlement/tests/common/settlement.rs +++ b/programs/settlement/tests/common/settlement.rs @@ -1,8 +1,13 @@ //! Scaffolding for building `[BeginSettle, FinalizeSettle]` settlement pairs. -use cow_settlement_client::instructions::{BeginSettle, FinalizedIntent, InitializedIntent}; -use cow_settlement_interface::Instruction; -use solana_sdk::pubkey::Pubkey; +use cow_settlement_client::instructions::{ + BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, +}; +use cow_settlement_interface::{data::intent::OrderIntent, Instruction}; +use litesvm::LiteSVM; +use solana_sdk::{pubkey::Pubkey, signature::Keypair}; + +use super::{buffer, token, unique_pubkey}; /// Positions of the two instructions in the `[BeginSettle, FinalizeSettle]` pair /// the settlement tests build: begin first, finalize right after it. Each @@ -14,6 +19,9 @@ pub const FINALIZE_INDEX: u8 = 1; /// `FinalizeSettle` at [`FINALIZE_INDEX`] and `begin` settles `orders` (with no /// pulls) at [`BEGIN_INDEX`], the same orders the finalize is expected to push /// to. Submit the result with [`send`](super::send). +/// +/// Use this when the test needs to hand-build the finalize (to corrupt it, say); +/// [`build_staged_settlement`] builds both halves from staged orders instead. pub fn build_settlement( program_id: &Pubkey, solver: &Pubkey, @@ -36,3 +44,112 @@ pub fn build_settlement( }; vec![begin.into(), finalize.into()] } + +/// An order staged for settlement by [`stage_order`]: the intent, the [`Pull`]s +/// to draw from its sell token account, and the amount to push to its buy token +/// account. It owns its intent, so a helper that mints an order can stage it and +/// hand back the result in one piece. +pub struct StagedOrder { + pub intent: OrderIntent, + pub pulls: Vec, + pub amount_out: u64, +} + +/// Stage the token side of settling `intent`, so a settlement of the result can +/// actually move the funds: fund and delegate the sell token account for the +/// total of `pulls`, give each pull its own throwaway destination account of the +/// sell mint, and fund the buy mint's canonical buffer with `amount_out` for the +/// push to draw on. `payer` funds all of it. +/// +/// Buffers are shared per mint, so staging several orders that buy the same mint +/// accumulates their `amount_out` in the one buffer. +pub fn stage_order( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + intent: &OrderIntent, + pulls: &[u64], + amount_out: u64, +) -> StagedOrder { + let amount_in: u64 = pulls.iter().sum(); + token::fund_and_delegate( + svm, + program_id, + payer, + &intent.sell_token_account, + amount_in, + ); + let pulls = pulls + .iter() + .map(|&amount| Pull { + destination: token::create_token_account( + svm, + payer, + &intent.sell_mint, + &unique_pubkey(), + ), + amount, + }) + .collect(); + buffer::ensure_funded(svm, program_id, payer, &intent.buy_mint, amount_out); + + StagedOrder { + intent: intent.clone(), + pulls, + amount_out, + } +} + +/// Build the instructions settling `orders`: a `BeginSettle` at [`BEGIN_INDEX`] +/// carrying each order's pulls, `between` right after it, and the matching +/// `FinalizeSettle` last, pushing each order's `amount_out`. Submit the result +/// with [`send`](super::send). +pub fn build_staged_settlement( + program_id: &Pubkey, + solver: &Pubkey, + orders: &[StagedOrder], + between: Vec, +) -> Vec { + let begin_orders: Vec = orders + .iter() + .map(|order| InitializedIntent { + intent: &order.intent, + pulls: &order.pulls, + }) + .collect(); + let finalize_orders: Vec = orders + .iter() + .map(|order| FinalizedIntent { + intent: &order.intent, + amount: order.amount_out, + }) + .collect(); + + let begin = BeginSettle { + program_id: *program_id, + solver: *solver, + finalize_ix_index: finalize_index(between.len()), + auction_id: 0, + orders: &begin_orders, + }; + let finalize = FinalizeSettle { + program_id: *program_id, + begin_ix_index: BEGIN_INDEX.into(), + orders: &finalize_orders, + }; + + let mut instructions = vec![begin.into()]; + instructions.extend(between); + instructions.push(finalize.into()); + instructions +} + +/// Where the `FinalizeSettle` lands in a settlement with `interposed` +/// instructions sitting between +fn finalize_index(interposed: usize) -> u16 { + u16::try_from(interposed) + .ok() + .and_then(|shift| shift.checked_add(BEGIN_INDEX.into())) + .and_then(|shift| shift.checked_add(1)) + .expect("a test transaction holds far fewer than u16::MAX instructions") +} diff --git a/programs/settlement/tests/create_order.rs b/programs/settlement/tests/create_order.rs index ba5196d..e30ae51 100644 --- a/programs/settlement/tests/create_order.rs +++ b/programs/settlement/tests/create_order.rs @@ -7,6 +7,7 @@ use cow_settlement_client::cow_settlement_interface::{ pda::order::{find_order_pda, order_pda_seeds}, SettlementError, }; +use cow_settlement_interface::data::intent::{Flags, OrderKind}; use solana_sdk::{ instruction::InstructionError, pubkey::Pubkey, @@ -24,7 +25,11 @@ mod common; fn sample_intent(owner: Pubkey) -> OrderIntent { OrderIntent { owner, - ..fixtures::sample_intent(Default::default()) + ..fixtures::sample_intent(Flags { + created_on_chain: true, + kind: OrderKind::Sell, + partially_fillable: false, + }) } } @@ -326,3 +331,37 @@ fn rejects_when_intent_owner_differs_from_signer() { "expected MismatchingSettlePair at instruction {expected_failing_instruction_index}" ); } + +#[test] +fn rejects_intent_authenticated_off_chain() { + let (mut svm, program_id, owner) = common::setup(); + + let intent = OrderIntent { + flags: Flags { + created_on_chain: false, + ..Default::default() + }, + ..sample_intent(owner.pubkey()) + }; + let (encoded, pda, _bump) = encode_and_derive(&intent, &program_id); + + let ix = CreateOrder { + program_id, + owner: owner.pubkey(), + created_by: owner.pubkey(), + order_pda: pda, + intent_bytes: encoded, + }; + let tx = signed_tx(&svm, &owner, &owner, ix); + assert_eq!( + svm.send_transaction(tx).map_err(|e| e.err).err(), + Some(TransactionError::InstructionError( + 0, + to_instruction_error(SettlementError::OrderCreatedOnChainMismatch), + )), + ); + assert!( + svm.get_account(&pda).is_none(), + "no order PDA may be left behind by a rejected creation" + ); +} diff --git a/programs/settlement/tests/reclaim_order.rs b/programs/settlement/tests/reclaim_order.rs index 2d4c347..30ca4e1 100644 --- a/programs/settlement/tests/reclaim_order.rs +++ b/programs/settlement/tests/reclaim_order.rs @@ -4,7 +4,13 @@ use cow_settlement_client::cow_settlement_interface::{ pda::order::find_order_pda, SettlementError, }; +use cow_settlement_interface::data::{ + intent::Flags, + order::{EncodedOrderAccount, OrderAccount}, +}; +use litesvm::LiteSVM; use solana_sdk::{ + clock::Clock, pubkey::Pubkey, signature::{Keypair, Signer}, }; @@ -12,7 +18,11 @@ use solana_sdk::{ use crate::common::{ assert_instruction_error, benchmark::{send_transaction_metered, BenchLabel}, - signed_tx, to_instruction_error, unique_keypair, unique_pubkey, + buffer, create_account_at, + order::OrderBuilder, + send, + settlement::{build_staged_settlement, stage_order, StagedOrder}, + signed_tx, to_instruction_error, token, unique_keypair, unique_pubkey, }; mod common; @@ -23,7 +33,10 @@ fn reclaim_sample_intent(owner: Pubkey) -> OrderIntent { OrderIntent { owner, valid_to: VALID_TO, - ..sample_intent(Default::default()) + ..sample_intent(Flags { + created_on_chain: true, + ..Default::default() + }) } } @@ -37,9 +50,44 @@ fn encode_and_derive( (bytes, pda) } +/// Decode the order stored in an order PDA. +fn read_order(svm: &LiteSVM, pda: &Pubkey) -> OrderAccount { + let account = svm.get_account(pda).expect("order PDA must exist"); + OrderAccount::try_from(&account.data[..]).expect("order PDA must decode") +} + +/// Directly overwrite the body stored in an order PDA. +fn patch_order(svm: &mut LiteSVM, pda: &Pubkey, patch: impl FnOnce(OrderAccount) -> OrderAccount) { + let mut account = svm.get_account(pda).expect("order PDA must exist"); + account.data = EncodedOrderAccount::from(patch(read_order(svm, pda))).to_vec(); + svm.set_account(*pda, account) + .expect("set_account should succeed"); +} + +/// Put an order PDA on-chain directly, bypassing `CreateOrder`, which only +/// accepts intents declaring on-chain authentication. This is how an order +/// authenticated by an off-chain signature is staged. +fn hack_write_order( + svm: &mut LiteSVM, + program_id: &Pubkey, + intent: &OrderIntent, + created_by: &Pubkey, + patch: impl FnOnce(OrderAccount) -> OrderAccount, +) -> Pubkey { + let (pda, bump) = find_order_pda(program_id, &intent.uid()); + let order = patch(OrderAccount { + bump, + created_by: *created_by, + intent: intent.clone(), + ..Default::default() + }); + create_account_at(svm, pda, program_id, &EncodedOrderAccount::from(order)[..]); + pda +} + /// Create an order PDA owned by `owner` (who also pays rent), return the PDA. fn create_order( - svm: &mut litesvm::LiteSVM, + svm: &mut LiteSVM, program_id: &Pubkey, owner: &Keypair, intent: &OrderIntent, @@ -59,7 +107,7 @@ fn create_order( } #[test] -fn happy_path_returns_lamports_and_closes_pda() { +fn happy_path_expired_returns_lamports_and_closes_pda() { let (mut svm, program_id, fee_payer) = common::setup(); // `reclaim_recipient` is the `created_by` funder; it's separate from the fee @@ -128,6 +176,76 @@ fn happy_path_returns_lamports_and_closes_pda() { ); } +/// Reclaim `pda` before its `valid_to`, crediting `owner`, and return the +/// transaction result. +fn perform_reclaim_while_unexpired( + svm: &mut LiteSVM, + program_id: &Pubkey, + owner: &Keypair, + pda: &Pubkey, +) -> Result<(), solana_sdk::transaction::TransactionError> { + // Taken from the order itself rather than from `VALID_TO`, so the clock the + // transaction runs at can't drift from the order it's reclaiming. + let valid_to = i64::from(read_order(svm, pda).intent.valid_to); + common::set_unix_timestamp(svm, valid_to); + + let ix = ReclaimOrder { + program_id: *program_id, + order_pda: *pda, + reclaim_recipient: owner.pubkey(), + } + .instruction(); + let tx = signed_tx(svm, owner, owner, ix); + + let result = send_transaction_metered(svm, tx, BenchLabel::ReclaimOrder); + + let executed_at = svm.get_sysvar::().unix_timestamp; + assert!( + executed_at <= valid_to, + "reclaim must run while the order is unexpired, ran at {executed_at} with valid_to {valid_to}" + ); + + result.map_err(|e| e.err)?; + + assert!( + svm.get_account(pda).is_none(), + "order PDA must be closed after reclaim" + ); + + Ok(()) +} + +#[test] +fn happy_path_on_chain_order_fully_filled_is_reclaimable_before_expiry() { + let (mut svm, program_id, owner) = common::setup(); + + let intent = reclaim_sample_intent(owner.pubkey()); + let pda = create_order(&mut svm, &program_id, &owner, &intent); + // A sell order is full once its whole sell amount has been withdrawn. + patch_order(&mut svm, &pda, |order| OrderAccount { + amount_withdrawn: order.intent.sell_amount, + ..order + }); + + perform_reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda) + .expect("a filled on-chain order should be reclaimable before it expires"); +} + +#[test] +fn happy_path_on_chain_order_cancelled_is_reclaimable_before_expiry() { + let (mut svm, program_id, owner) = common::setup(); + + let intent = reclaim_sample_intent(owner.pubkey()); + let pda = create_order(&mut svm, &program_id, &owner, &intent); + patch_order(&mut svm, &pda, |order| OrderAccount { + cancelled: true, + ..order + }); + + perform_reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda) + .expect("a cancelled on-chain order should be reclaimable before it expires"); +} + #[test] fn rejects_when_order_not_yet_expired() { let (mut svm, program_id, owner) = common::setup(); @@ -146,7 +264,77 @@ fn rejects_when_order_not_yet_expired() { let tx = signed_tx(&svm, &owner, &owner, ix); assert_instruction_error( svm.send_transaction(tx).map_err(|e| e.err), - to_instruction_error(SettlementError::OrderNotExpired), + to_instruction_error(SettlementError::OrderNotReclaimable), + ); +} + +#[test] +fn on_chain_order_partially_filled_is_not_reclaimable_before_expiry() { + let (mut svm, program_id, owner) = common::setup(); + + let intent = reclaim_sample_intent(owner.pubkey()); + let pda = create_order(&mut svm, &program_id, &owner, &intent); + // One token short of a full fill: the order can still be settled, so its + // PDA has to stay. + patch_order(&mut svm, &pda, |order| OrderAccount { + amount_withdrawn: order.intent.sell_amount - 1, + ..order + }); + + assert_instruction_error( + perform_reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda), + to_instruction_error(SettlementError::OrderNotReclaimable), + ); +} + +/// An order authenticated by an off-chain signature can be recreated by anyone +/// holding that signature, which would reset its fills and its cancellation. +/// Being unfillable doesn't make it reclaimable, then: only expiry does, and +/// expiry does so regardless of how the order was authenticated. +#[test] +fn off_chain_order_is_reclaimable_only_once_expired() { + let (mut svm, program_id, owner) = common::setup(); + + let intent = OrderIntent { + flags: Flags { + created_on_chain: false, + ..Default::default() + }, + ..reclaim_sample_intent(owner.pubkey()) + }; + // Cancelled *and* completely filled: the strongest case for early reclaim, + // and it still has to wait. + let pda = hack_write_order(&mut svm, &program_id, &intent, &owner.pubkey(), |order| { + OrderAccount { + cancelled: true, + amount_withdrawn: order.intent.sell_amount, + ..order + } + }); + + assert_instruction_error( + perform_reclaim_while_unexpired(&mut svm, &program_id, &owner, &pda), + to_instruction_error(SettlementError::OrderNotReclaimable), + ); + assert!( + svm.get_account(&pda).is_some(), + "order PDA must survive a rejected reclaim" + ); + + common::set_unix_timestamp(&mut svm, (VALID_TO + 1).into()); + svm.expire_blockhash(); + let ix = ReclaimOrder { + program_id, + order_pda: pda, + reclaim_recipient: owner.pubkey(), + } + .instruction(); + let tx = signed_tx(&svm, &owner, &owner, ix); + svm.send_transaction(tx) + .expect("an expired order should be reclaimable however it was authenticated"); + assert!( + svm.get_account(&pda).is_none(), + "order PDA must be closed after reclaim" ); } @@ -232,3 +420,118 @@ fn rejects_when_reclaim_recipient_mismatch() { to_instruction_error(SettlementError::ReclaimRecipientMismatch), ); } + +const SETTLED_SELL_AMOUNT: u64 = 1_000_000; +const SETTLED_BUY_AMOUNT: u64 = 2_000_000; + +/// Mint a partially fillable order selling [`SETTLED_SELL_AMOUNT`] for +/// [`SETTLED_BUY_AMOUNT`], and stage a settlement selling `sell_amount` of it at +/// exactly the order's limit price (so any fraction of it settles). Passing +/// [`SETTLED_SELL_AMOUNT`] stages a full fill. +fn settleable_order( + svm: &mut LiteSVM, + program_id: &Pubkey, + payer: &Keypair, + sell_amount: u64, +) -> (StagedOrder, Pubkey) { + let intent = OrderBuilder::new(svm, program_id, payer) + .sell_amount(SETTLED_SELL_AMOUNT) + .buy_amount(SETTLED_BUY_AMOUNT) + .partially_fillable(true) + .build(); + let (order_pda, _bump) = find_order_pda(program_id, &intent.uid()); + let staged = stage_order( + svm, + program_id, + payer, + &intent, + &[sell_amount], + sell_amount + .checked_mul(SETTLED_BUY_AMOUNT) + .expect("order math should work") + .checked_div(SETTLED_SELL_AMOUNT) + .expect("order math should work"), + ); + (staged, order_pda) +} + +/// A settlement that fills only part of an order leaves it fillable again, so +/// the order PDA has to stay until the order expires. +#[test] +fn rejects_reclaim_of_a_partially_filled_order() { + let (mut svm, program_id, payer, solver) = common::setup_settle_ready(); + const PARTIAL_FILL: u64 = SETTLED_SELL_AMOUNT / 3; + let (staged, order_pda) = settleable_order(&mut svm, &program_id, &payer, PARTIAL_FILL); + + let instructions = build_staged_settlement(&program_id, &solver.pubkey(), &[staged], vec![]); + send(&mut svm, &solver, instructions).expect("a partial settlement should succeed"); + assert_eq!( + read_order(&svm, &order_pda).amount_withdrawn, + PARTIAL_FILL, + "the settlement must have recorded a partial fill" + ); + + let ix = ReclaimOrder { + program_id, + order_pda, + reclaim_recipient: payer.pubkey(), + } + .instruction(); + let tx = signed_tx(&svm, &payer, &payer, ix); + assert_instruction_error( + svm.send_transaction(tx).map_err(|e| e.err), + to_instruction_error(SettlementError::OrderNotReclaimable), + ); + assert!( + svm.get_account(&order_pda).is_some(), + "order PDA must survive a rejected reclaim" + ); +} + +/// A reclaim placed between `BeginSettle` and `FinalizeSettle` closes the order +/// PDA without breaking the settlement around it. +/// +/// `BeginSettle` does all of the settlement's order validation and records the +/// fill, and it's the only instruction of the pair that takes the order PDA as +/// an account. So reclaim is free to happen after that point. +#[test] +fn reclaim_mid_settlement_succeeds() { + let (mut svm, program_id, payer, solver) = common::setup_settle_ready(); + let (staged, order_pda) = settleable_order(&mut svm, &program_id, &payer, SETTLED_SELL_AMOUNT); + let pull_destination = staged.pulls[0].destination; + let buy_token_account = staged.intent.buy_token_account; + let buffer_pda = buffer::buffer_pda(&program_id, &staged.intent.buy_mint); + let pda_rent = svm.minimum_balance_for_rent_exemption(EncodedOrderAccount::SIZE); + + let reclaim = ReclaimOrder { + program_id, + order_pda, + reclaim_recipient: payer.pubkey(), + } + .instruction(); + let instructions = + build_staged_settlement(&program_id, &solver.pubkey(), &[staged], vec![reclaim]); + + // The `payer` that created the order signs nothing here and pays no fee (the + // solver does), so its balance moves by the returned rent alone. + let payer_before = common::lamports(&svm, &payer.pubkey()); + send(&mut svm, &solver, instructions) + .expect("reclaiming a just-filled order mid-settlement should succeed"); + + assert!( + svm.get_account(&order_pda).is_none(), + "order PDA must be closed by the mid-settlement reclaim" + ); + assert_eq!( + common::lamports(&svm, &payer.pubkey()) - payer_before, + pda_rent, + "the order's creator must receive the closed PDA's rent" + ); + + // Both legs of the settlement went through around the reclaim: the pull in + // `BeginSettle`, before the order PDA was closed, and the push in + // `FinalizeSettle`, after. + assert_eq!(token::balance(&svm, &pull_destination), SETTLED_SELL_AMOUNT); + assert_eq!(token::balance(&svm, &buy_token_account), SETTLED_BUY_AMOUNT); + assert_eq!(token::balance(&svm, &buffer_pda), 0); +} diff --git a/programs/settlement/tests/settle_limit_prices.rs b/programs/settlement/tests/settle_limit_prices.rs index bda5827..bd8928e 100644 --- a/programs/settlement/tests/settle_limit_prices.rs +++ b/programs/settlement/tests/settle_limit_prices.rs @@ -7,10 +7,11 @@ //! succeeds or is rejected with the expected error. use crate::common::{ - assert_instruction_error_at, buffer, + assert_instruction_error_at, order::OrderBuilder, - settlement::{BEGIN_INDEX, FINALIZE_INDEX}, - setup_settle_ready, to_instruction_error, token, unique_pubkey, + send, + settlement::{build_staged_settlement, stage_order, StagedOrder, BEGIN_INDEX}, + setup_settle_ready, to_instruction_error, token, }; use cow_settlement_client::cow_settlement_interface::{ data::intent::{OrderIntent, OrderKind}, @@ -18,14 +19,11 @@ use cow_settlement_client::cow_settlement_interface::{ pda::order::find_order_pda, SettlementError, }; -use cow_settlement_client::instructions::{ - BeginSettle, FinalizeSettle, FinalizedIntent, InitializedIntent, Pull, -}; use litesvm::LiteSVM; use solana_sdk::{ pubkey::Pubkey, signature::{Keypair, Signer}, - transaction::{Transaction, TransactionError}, + transaction::TransactionError, }; mod common; @@ -83,64 +81,17 @@ fn settle_all( solver: &Keypair, orders: &[(&OrderIntent, &[u64], u64)], ) -> Result<(), TransactionError> { - let mut initialized: Vec = vec![]; - let mut finalized: Vec = vec![]; - for &(intent, pulls, amount_out) in orders { - // Sell side: fund and delegate the total pulled, and give each pull its - // own throwaway destination of the sell mint to pull into. - let amount_in: u64 = pulls.iter().sum(); - token::fund_and_delegate( - svm, - program_id, - payer, - &intent.sell_token_account, - amount_in, - ); - let mut pull_list: Vec = vec![]; - for &amount in pulls { - let destination = - token::create_token_account(svm, payer, &intent.sell_mint, &unique_pubkey()); - pull_list.push(Pull { - destination, - amount, - }); - } - // Leak the pulls so the `InitializedIntent` can borrow them until the - // builder consumes every order's pulls at once, below. - let pulls: &[Pull] = Box::leak(pull_list.into_boxed_slice()); - initialized.push(InitializedIntent { intent, pulls }); - - // Buy side: fund the buffer so the push can draw `amount_out`. - buffer::ensure_funded(svm, program_id, payer, &intent.buy_mint, amount_out); - - finalized.push(FinalizedIntent { - intent, - amount: amount_out, - }); - } - - let begin = BeginSettle { - program_id: *program_id, - solver: solver.pubkey(), - finalize_ix_index: FINALIZE_INDEX.into(), - auction_id: 0, - orders: &initialized, - }; - let finalize = FinalizeSettle { - program_id: *program_id, - begin_ix_index: BEGIN_INDEX.into(), - orders: &finalized, - }; + let staged: Vec = orders + .iter() + .map(|&(intent, pulls, amount_out)| { + stage_order(svm, program_id, payer, intent, pulls, amount_out) + }) + .collect(); + let instructions = build_staged_settlement(program_id, &solver.pubkey(), &staged, vec![]); // The solver settles and pays: it's the fee payer and the only signer the // pair needs (`BeginSettle` names it as its solver-signer). `payer` above // only funds the order/buffer setup. - let tx = Transaction::new_signed_with_payer( - &[begin.into(), finalize.into()], - Some(&solver.pubkey()), - &[solver], - svm.latest_blockhash(), - ); - svm.send_transaction(tx).map(|_| ()).map_err(|e| e.err) + send(svm, solver, instructions).map(|_| ()) } // --- Limit price --------------------------------------------------------- diff --git a/test-cli/src/cmd/create_order.rs b/test-cli/src/cmd/create_order.rs index 617671c..3465203 100644 --- a/test-cli/src/cmd/create_order.rs +++ b/test-cli/src/cmd/create_order.rs @@ -165,6 +165,7 @@ fn execute(ctx: Context, parsed: ParsedOrder, common: CommonArgs) -> anyhow::Res buy_amount, valid_to: common.valid_to, flags: Flags { + created_on_chain: true, kind, partially_fillable: common.partially_fillable, },