Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions client/examples/batch_replace.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
use client::e2e_helpers::{
E2e,
Trader,
};
use dropset_interface::instructions::{
BatchReplaceInstructionData,
Orders,
};
use price::OrderInfoArgs;
use solana_sdk::{
signature::Keypair,
signer::Signer,
};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
let trader = Keypair::new();
let e2e = E2e::new_traders_and_market(None, [Trader::new(&trader, 0, 0)]).await?;

let res = e2e
.market
.batch_replace(
trader.pubkey(),
BatchReplaceInstructionData::new(
0,
Orders::new([OrderInfoArgs::new_unscaled(11_000_000, 1)]),
Orders::new([
OrderInfoArgs::new_unscaled(12_000_000, 1),
OrderInfoArgs::new_unscaled(13_000_000, 2),
OrderInfoArgs::new_unscaled(14_000_000, 3),
OrderInfoArgs::new_unscaled(15_000_000, 4),
OrderInfoArgs::new_unscaled(16_000_000, 5),
]),
),
)
.send_single_signer(&e2e.rpc, &trader)
.await?;

for msg in res.parsed_transaction.log_messages {
println!("{msg}");
}

println!(
"Transaction signature: {}",
e2e.register_market_txn.parsed_transaction.signature
);

Ok(())
}
17 changes: 17 additions & 0 deletions client/src/context/market.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
use dropset_interface::{
instructions::{
generated_client::*,
BatchReplaceInstructionData,
CancelOrderInstructionData,
CloseSeatInstructionData,
DepositInstructionData,
Expand Down Expand Up @@ -275,6 +276,22 @@ impl MarketContext {
.expect("Should be a single signer instruction")
}

pub fn batch_replace(
&self,
user: Address,
data: BatchReplaceInstructionData,
) -> SingleSignerInstruction {
BatchReplace {
event_authority: event_authority::ID,
user,
market_account: self.market,
dropset_program: dropset::ID,
}
.create_instruction(data)
.try_into()
.expect("Should be a single signer instruction")
}

fn deposit(
&self,
user: Address,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pub fn render(
// # Safety: `dst` has sufficient writable bytes.
unsafe { <Self as #tagged_trait>::write_bytes_tagged(self, dst) };

// All bytes are initialized during the construction above.
// # Safety: All bytes are initialized during the construction above.
unsafe { *(data.as_ptr() as *const [u8; #size_with_tag]) }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub trait Tagged: Pack {
/// # Safety
///
/// `dst` must point to at least [`Tagged::LEN_WITH_TAG`] contiguous, writable bytes.
#[inline(always)]
unsafe fn write_bytes_tagged(&self, dst: *mut u8) {
dst.write(Self::TAG_BYTE);
<Self as Pack>::write_bytes(self, dst.add(1));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,19 @@ use crate::pack::Pack;
/// Callers of [`read_bytes`](Unpack::read_bytes) must:
/// - Ensure `src` points to at least [`Pack::LEN`] bytes of readable memory.
pub unsafe trait Unpack: Pack + Sized {
/// Reads [`Pack::LEN`] bytes from `src`.
/// Reads [`Pack::LEN`] bytes from `src` and constructs `Self` with them.
///
/// Returns an error if the bytes at `src` represent an invalid byte pattern; e.g., a `bool`
/// with a value of 2.
///
/// # Safety
///
/// `src` must point to at least [`Pack::LEN`] bytes of readable memory.
/// Implementor guarantees:
/// - At most [`Pack::LEN`] bytes are read from `src`.
/// - `src` is read from as an unaligned pointer.
///
/// Caller guarantees:
/// - `src` points to at least [`Pack::LEN`] bytes of readable memory.
unsafe fn read_bytes(src: *const u8) -> Result<Self, ProgramError>;

/// Checks that the length of the passed slice is sufficient before reading its bytes, then
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
//! The `program` feature: [`crate::instructions::generated_program`]
//! The `client` feature: [`crate::instructions::generated_client`]

mod orders;

use instruction_macros::ProgramInstruction;
pub use orders::*;
use price::OrderInfoArgs;

#[repr(u8)]
Expand Down Expand Up @@ -90,9 +93,12 @@ pub enum DropsetInstruction {
CancelOrder,

#[account(0, name = "event_authority", desc = "The event authority PDA signer.")]
#[account(1, signer, name = "user", desc = "The user posting an order.")]
#[account(1, signer, name = "user", desc = "The user canceling an order.")]
#[account(2, writable, name = "market_account", desc = "The market account PDA.")]
#[account(3, name = "dropset_program", desc = "The dropset program itself, used for the self-CPI.")]
#[args(user_sector_index_hint: u32, "A hint indicating which sector the user's seat resides in.")]
#[args(new_bids: Orders, "The new bids to replace the user's current bids.")]
#[args(new_asks: Orders, "The new asks to replace the user's current asks.")]
BatchReplace,

#[account(0, name = "event_authority", desc = "The event authority PDA signer.")]
Expand Down
181 changes: 181 additions & 0 deletions interface/src/instructions/orders.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
use core::mem::MaybeUninit;

use instruction_macros::{
Pack,
Unpack,
};
use price::OrderInfoArgs;
use solana_program_error::ProgramError;
use static_assertions::const_assert_eq;

use crate::{
instructions::orders::private::UpToFive,
state::user_order_sectors::{
MAX_ORDERS,
MAX_ORDERS_USIZE,
},
};

#[repr(C)]
#[derive(Debug, Clone, Pack, Unpack, PartialEq, Eq)]
pub struct Orders {
/// The number of elements representing real order arguments in [`Orders::order_args`].
/// This value will always be less than or equal to the max number of orders [`MAX_ORDERS`].
/// The remaining elements will be zero-initialized but cannot be accessed through the
/// public [`Orders`] interface.
num_orders: u8,
/// Instruction data that isn't read is free, so it's simpler to always use [`MAX_ORDERS`]
/// elements in the array and simply ignore elements with an index >= [`Orders::num_orders`]
/// than to use a slice with a dynamic length.
order_args: OrdersArray,
}

impl Orders {
#[inline(always)]
pub fn new<const N: usize>(orders: [OrderInfoArgs; N]) -> Self
where
[OrderInfoArgs; N]: UpToFive<N>,
{
let mut res: [MaybeUninit<OrderInfoArgs>; MAX_ORDERS_USIZE] =
[const { MaybeUninit::uninit() }; MAX_ORDERS_USIZE];

unsafe {
// Copy the orders passed in. This initializes `res[0..N]`.
//
// Safety:
// - `orders` is valid for `N` reads.
// - `res` is valid for `MAX_ORDERS` writes, and `MAX_ORDERS` >= `N`.
// - Both pointers are aligned and do not overlap.
core::ptr::copy_nonoverlapping(
orders.as_ptr(),
res.as_mut_ptr() as *mut OrderInfoArgs,
N,
);

// Write zeros to the remaining elements. This initializes `res[N..MAX_ORDERS]`.
//
// Safety:
// - `res.as_mut_ptr().add(N)` is valid for up to `MAX_ORDERS - N` writes and `N` is
// guaranteed to be <= `MAX_ORDERS`.
// - Zero is a valid value for all field types.
core::ptr::write_bytes(
(res.as_mut_ptr() as *mut OrderInfoArgs).add(N),
0u8,
MAX_ORDERS_USIZE - N,
);
}

Self {
num_orders: N as u8,
// Safety: The array has been fully initialized with `MAX_ORDERS` elements.
order_args: OrdersArray(unsafe {
core::mem::transmute::<
[MaybeUninit<OrderInfoArgs>; MAX_ORDERS_USIZE],
[OrderInfoArgs; MAX_ORDERS_USIZE],
>(res)
}),
}
}

/// Exposes the order args elements as an owned iterator for indices 0..[`Self::num_orders`]`.
#[inline(always)]
pub fn into_order_args_iter(self) -> impl Iterator<Item = OrderInfoArgs> {
let n = self.num_orders as usize;
self.order_args.0.into_iter().take(n)
Comment on lines +82 to +84
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Orders derives Unpack, so num_orders can be attacker-controlled when deserializing instruction data. into_order_args_iter currently trusts num_orders and will silently clamp via .take(n), which can mask invalid data and risks future out-of-bounds/panic if num_orders is later used for indexing/loop bounds. Consider validating num_orders <= MAX_ORDERS during unpack (custom Unpack impl) or provide a checked accessor that returns InvalidInstructionData when num_orders is out of range.

Suggested change
pub fn into_order_args_iter(self) -> impl Iterator<Item = OrderInfoArgs> {
let n = self.num_orders as usize;
self.order_args.0.into_iter().take(n)
pub fn into_order_args_iter(
self,
) -> Result<impl Iterator<Item = OrderInfoArgs>, ProgramError> {
let n = self.num_orders as usize;
if n > MAX_ORDERS_USIZE {
return Err(ProgramError::InvalidInstructionData);
}
Ok(self.order_args.0.into_iter().take(n))

Copilot uses AI. Check for mistakes.
}

#[inline(always)]
pub fn num_orders(&self) -> u8 {
self.num_orders
}
}

#[repr(transparent)]
#[derive(Debug, Clone, PartialEq, Eq)]
struct OrdersArray([OrderInfoArgs; MAX_ORDERS_USIZE]);

unsafe impl Pack for OrdersArray {
type Packed = [u8; OrderInfoArgs::LEN * MAX_ORDERS_USIZE];

/// # Safety
///
/// Writes [`OrderInfoArgs::LEN`] bytes to `dst` [`MAX_ORDERS`] times, with each write
/// destination offset increased by the amount written each time.
#[inline(always)]
unsafe fn write_bytes(&self, dst: *mut u8) {
// This implementation was written with the expectation that the max number of orders is 5.
// If that changes, the implementation needs to change to account for the different size.
const_assert_eq!(MAX_ORDERS, 5);

let array = &self.0;
array[0].write_bytes(dst);
array[1].write_bytes(dst.add(OrderInfoArgs::LEN));
array[2].write_bytes(dst.add(OrderInfoArgs::LEN * 2));
array[3].write_bytes(dst.add(OrderInfoArgs::LEN * 3));
array[4].write_bytes(dst.add(OrderInfoArgs::LEN * 4));
}

#[inline(always)]
fn pack(&self) -> Self::Packed {
let mut data: [MaybeUninit<u8>; Self::LEN] = [MaybeUninit::uninit(); Self::LEN];
let dst = data.as_mut_ptr() as *mut u8;
// Safety: `dst` points to `Self::LEN` contiguous, writable bytes.
unsafe { self.write_bytes(dst) };

// Safety: All bytes are initialized during the construction above.
unsafe { *(data.as_ptr() as *const [u8; Self::LEN]) }
}
}

unsafe impl Unpack for OrdersArray {
/// # Safety (implementor)
///
/// - Exactly [`Orders::LEN`] bytes are read from `src`.
/// - `src` is read from as an unaligned pointer.
///
/// # Safety (caller)
///
/// Caller must guarantee `src` points to at least [`Self::LEN`] bytes of readable memory.
#[inline(always)]
unsafe fn read_bytes(src: *const u8) -> Result<Self, solana_program_error::ProgramError> {
// This implementation was written with the expectation that the max number of orders is 5.
// If that changes, the implementation needs to change to account for the different size.
const_assert_eq!(MAX_ORDERS, 5);

Ok(Self([
OrderInfoArgs::read_bytes(src)?,
OrderInfoArgs::read_bytes(src.add(OrderInfoArgs::LEN))?,
OrderInfoArgs::read_bytes(src.add(OrderInfoArgs::LEN * 2))?,
OrderInfoArgs::read_bytes(src.add(OrderInfoArgs::LEN * 3))?,
OrderInfoArgs::read_bytes(src.add(OrderInfoArgs::LEN * 4))?,
]))
}

#[inline(always)]
fn unpack(data: &[u8]) -> Result<Self, solana_program_error::ProgramError> {
if data.len() < Self::LEN {
return Err(ProgramError::InvalidInstructionData);
}

// Safety: `data` has at least `Self::LEN` bytes.
unsafe { Self::read_bytes(data.as_ptr()) }
}
}

mod private {
use super::*;

// This sealed trait was written with the expectation that the max number of orders is 5.
// If that changes, the trait needs to change to account for the different size.
const_assert_eq!(MAX_ORDERS, 5);

/// Marker trait: implemented only for arrays of length 0..=[`MAX_ORDERS`].
pub trait UpToFive<const N: usize> {}

impl UpToFive<0> for [OrderInfoArgs; 0] {}
impl UpToFive<1> for [OrderInfoArgs; 1] {}
impl UpToFive<2> for [OrderInfoArgs; 2] {}
impl UpToFive<3> for [OrderInfoArgs; 3] {}
impl UpToFive<4> for [OrderInfoArgs; 4] {}
impl UpToFive<5> for [OrderInfoArgs; 5] {}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"build:debug": "cd program && cargo build-sbf --features debug",
"deploy": "solana program deploy target/deploy/dropset.so --program-id test-keypair.json || echo 'Make sure the local validator is running'",
"examples:register_market": "cargo run --example register_market",
"examples:batch_replace": "cargo run --example batch_replace",
"examples:deposit_and_withdraw": "cargo run --example deposit_and_withdraw",
"examples:close_seat": "cargo run --example close_seat",
"examples:many_instructions": "cargo run --example many_instructions",
Expand Down
Loading