From b7d9f3c182501e8fb23a81ff433ae5c87db775f1 Mon Sep 17 00:00:00 2001 From: GroovieGermanikus Date: Wed, 22 Jul 2026 14:31:00 +0200 Subject: [PATCH 01/15] comment on ALLOWED_LIGHTHOUSE_DISCRIMINATORS --- order-engine-sdk/src/fill.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/order-engine-sdk/src/fill.rs b/order-engine-sdk/src/fill.rs index 7a9cb9a..0e17e72 100644 --- a/order-engine-sdk/src/fill.rs +++ b/order-engine-sdk/src/fill.rs @@ -23,7 +23,13 @@ const NATIVE_MINT: Pubkey = pubkey!("So11111111111111111111111111111111111111112 // // If we allow the MemoryWrite instruction, the hacker can drain the signer. // https://github.com/Jac0xb/lighthouse/blob/main/programs/lighthouse/lighthouse.json -const ALLOWED_LIGHTHOUSE_DISCRIMINATORS: &[u8] = &[5, 6, 9, 10]; +const ALLOWED_LIGHTHOUSE_DISCRIMINATORS: &[u8] = &[ + // 0 .. CAUTION - If we allow the MemoryWrite instruction, the hacker can drain the signer. + 5, // AssertAccountInfo + 6, // AssertAccountInfoMulti + 9, // AssertTokenAccount + 10, // AssertTokenAccountMulti +]; pub struct Order { pub taker: Pubkey, @@ -373,6 +379,7 @@ pub fn validate_similar_fill_sanitized_message( original_message_header.num_required_signatures == message_header.num_required_signatures, "Number of required signatures did not match" ); + // TODO use zip! here let mut account_keys_iter = sanitized_message.account_keys().iter(); for original_signer in original_sanitized_message .account_keys() From e420cdbb675a1a0cb9f59b1ac7e1a7ceba908bc8 Mon Sep 17 00:00:00 2001 From: GroovieGermanikus Date: Wed, 22 Jul 2026 14:33:02 +0200 Subject: [PATCH 02/15] assert <=1 fills --- order-engine-sdk/src/fill.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/order-engine-sdk/src/fill.rs b/order-engine-sdk/src/fill.rs index 0e17e72..d86a20a 100644 --- a/order-engine-sdk/src/fill.rs +++ b/order-engine-sdk/src/fill.rs @@ -498,6 +498,8 @@ pub fn validate_similar_fill_sanitized_message( .context("Invalid taker input mint token account ix data")? .pubkey; + // TODO assert that this is not set here already + ensure!(validated_similar_fill.is_none(), "Multiple fill instruction while expecting only one"); validated_similar_fill = Some(ValidatedSimilarFill { taker: *taker, input_amount: fill_ix.input_amount, From ebcd9905fdd38a30596a798ee574914d4dbb5a4c Mon Sep 17 00:00:00 2001 From: GroovieGermanikus Date: Wed, 22 Jul 2026 15:01:25 +0200 Subject: [PATCH 03/15] wip --- order-engine-sdk/src/fill.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/order-engine-sdk/src/fill.rs b/order-engine-sdk/src/fill.rs index d86a20a..3a968aa 100644 --- a/order-engine-sdk/src/fill.rs +++ b/order-engine-sdk/src/fill.rs @@ -135,6 +135,7 @@ pub fn validate_fill_sanitized_message( ); // We verify the taker is paying for the token account + // TODO pull up accounts.first() ensure!(accounts.first().map(|am| am.pubkey) != Some(&order.maker)); } else if program_id == &order_engine::ID { ensure!(!fill_ix_found, "Duplicated fill instruction"); From fe17517d532dbc52db8e792728b6c72cf7e7d634 Mon Sep 17 00:00:00 2001 From: GroovieGermanikus Date: Wed, 22 Jul 2026 15:11:54 +0200 Subject: [PATCH 04/15] assert lighthouse instructions never write --- order-engine-sdk/src/fill.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/order-engine-sdk/src/fill.rs b/order-engine-sdk/src/fill.rs index 3a968aa..0c94b23 100644 --- a/order-engine-sdk/src/fill.rs +++ b/order-engine-sdk/src/fill.rs @@ -20,6 +20,7 @@ const LIGHTHOUSE_PROGRAM_ID: Pubkey = pubkey!("L2TExMFKdjpN9kozasaurPirfHy9P8sbX const NATIVE_MINT: Pubkey = pubkey!("So11111111111111111111111111111111111111112"); // We only allow certain instruction from the Lighthouse program. +// Logic also checks that all accounts are read-only. // // If we allow the MemoryWrite instruction, the hacker can drain the signer. // https://github.com/Jac0xb/lighthouse/blob/main/programs/lighthouse/lighthouse.json @@ -517,7 +518,7 @@ pub fn validate_similar_fill_sanitized_message( index, BorrowedInstruction { program_id, - accounts: _, + accounts, data, }, ) in sanitized_instructions_iter.enumerate() @@ -529,11 +530,17 @@ pub fn validate_similar_fill_sanitized_message( ); ensure!( + // TODO use split_disc1byte_and_bytes data.first() .map(|discriminator| ALLOWED_LIGHTHOUSE_DISCRIMINATORS.contains(discriminator)) .unwrap_or(false), "Invalid Lighthouse instruction discriminator at index {real_index}" ); + + ensure!( + accounts.iter().all(|account| !account.is_writable), + "Lighthouse instruction accounts must be read-only at index {real_index}" + ); } validated_similar_fill.context("Missing validated fill instruction") From 892d1952b538a528dd6e988d6b8f67f68601251e Mon Sep 17 00:00:00 2001 From: GroovieGermanikus Date: Tue, 28 Jul 2026 07:39:39 +0200 Subject: [PATCH 05/15] add test for writable account --- order-engine-sdk/src/fill.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/order-engine-sdk/src/fill.rs b/order-engine-sdk/src/fill.rs index 0c94b23..bd8b9d5 100644 --- a/order-engine-sdk/src/fill.rs +++ b/order-engine-sdk/src/fill.rs @@ -744,6 +744,27 @@ mod tests { .unwrap_err() .to_string() ); + + // Add lighthouse instruction with a writable account + let writable_lighthouse_ix = Instruction { + program_id: LIGHTHOUSE_PROGRAM_ID, + accounts: vec![AccountMeta::new(Pubkey::new_unique(), false)], + data: vec![5], + }; + let sanitized_message = make_sanitized_transaction( + &maker, + &[fill_ix.clone(), writable_lighthouse_ix], + recent_blockhash, + ); + assert_eq!( + "Lighthouse instruction accounts must be read-only at index 1", + validate_similar_fill_sanitized_message( + sanitized_message, + original_sanitized_message.clone() + ) + .unwrap_err() + .to_string() + ); } fn build_fill_ix( From 4cf164a4af5f336c69c41c24473ca4e93f041be2 Mon Sep 17 00:00:00 2001 From: GroovieGermanikus Date: Tue, 28 Jul 2026 07:45:56 +0200 Subject: [PATCH 06/15] add test for writable account --- order-engine-sdk/src/fill.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/order-engine-sdk/src/fill.rs b/order-engine-sdk/src/fill.rs index bd8b9d5..799bba3 100644 --- a/order-engine-sdk/src/fill.rs +++ b/order-engine-sdk/src/fill.rs @@ -711,7 +711,7 @@ mod tests { let lighthouse_ix = Instruction { program_id: LIGHTHOUSE_PROGRAM_ID, accounts: vec![AccountMeta::new_readonly(input_mint, false)], - data: vec![5], + data: vec![5], // need to use a whitelisted discriminator to pass the first check }; let sanitized_message = make_sanitized_transaction( &maker, From 1e74583b0574f024af3495a035f24824d1bbf052 Mon Sep 17 00:00:00 2001 From: GroovieGermanikus Date: Tue, 28 Jul 2026 07:59:10 +0200 Subject: [PATCH 07/15] implement disc utils --- order-engine-sdk/src/lib.rs | 1 + order-engine-sdk/src/parse_util.rs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 order-engine-sdk/src/parse_util.rs diff --git a/order-engine-sdk/src/lib.rs b/order-engine-sdk/src/lib.rs index 2bb3880..58d5062 100644 --- a/order-engine-sdk/src/lib.rs +++ b/order-engine-sdk/src/lib.rs @@ -4,3 +4,4 @@ declare_program!(order_engine); pub mod fill; pub mod transaction; +pub mod parse_util; diff --git a/order-engine-sdk/src/parse_util.rs b/order-engine-sdk/src/parse_util.rs new file mode 100644 index 0000000..f030dba --- /dev/null +++ b/order-engine-sdk/src/parse_util.rs @@ -0,0 +1,19 @@ +use anyhow::{bail, Result}; + +/// Splits an 8-byte discriminator off the front, returning `(disc, remaining_bytes)`. +#[allow(dead_code)] +pub fn split_disc_and_bytes(bytes: &[u8]) -> Result<(&[u8; 8], &[u8])> { + let Some((disc, remaining)) = bytes.split_first_chunk::<8>() else { + bail!("Not enough bytes to split disc and bytes"); + }; + Ok((disc, remaining)) +} + +/// Splits a 1-byte discriminator off the front, returning `(disc, remaining_bytes)`. +#[allow(dead_code)] +pub fn split_disc1byte_and_bytes(bytes: &[u8]) -> Result<(&[u8; 1], &[u8])> { + let Some((disc, remaining)) = bytes.split_first_chunk::<1>() else { + bail!("Not enough bytes to split disc and bytes"); + }; + Ok((disc, remaining)) +} From 637acf09a075dbedb099e058a737293d03df895a Mon Sep 17 00:00:00 2001 From: GroovieGermanikus Date: Tue, 28 Jul 2026 08:11:02 +0200 Subject: [PATCH 08/15] major: rewrite fill.rs --- order-engine-sdk/src/fill.rs | 27 +++++++++++++++------------ order-engine-sdk/src/parse_util.rs | 2 -- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/order-engine-sdk/src/fill.rs b/order-engine-sdk/src/fill.rs index 799bba3..1be4383 100644 --- a/order-engine-sdk/src/fill.rs +++ b/order-engine-sdk/src/fill.rs @@ -1,4 +1,5 @@ use crate::order_engine; +use crate::parse_util::{split_disc1byte_and_bytes, split_disc_and_bytes}; use anchor_lang::{pubkey, AnchorDeserialize, Discriminator}; use anchor_spl::{ associated_token::{self, get_associated_token_address_with_program_id}, @@ -129,9 +130,11 @@ pub fn validate_fill_sanitized_message( _ => bail!("Unexpected compute budget instruction"), } } else if program_id == &associated_token::ID { - // For simplicity we only allow create ata idempotent + // For simplicity, we only allow create ata idempotent + let (discriminator, _) = split_disc1byte_and_bytes(data) + .context("Incorrect associated token account program data")?; ensure!( - data == vec![1], + discriminator == &[1], "Incorrect associated token account program data" ); @@ -142,11 +145,11 @@ pub fn validate_fill_sanitized_message( ensure!(!fill_ix_found, "Duplicated fill instruction"); fill_ix_found = true; - ensure!(data.len() >= 8, "Not enough data in fill instruction"); // Must slice off anchor's discriminator first - let (discriminator, mut ix_data) = data.split_at(8); + let (discriminator, mut ix_data) = + split_disc_and_bytes(data).context("Not enough data in fill instruction")?; ensure!( - discriminator == order_engine::client::args::Fill::DISCRIMINATOR, + discriminator.as_slice() == order_engine::client::args::Fill::DISCRIMINATOR, "Not a fill discriminator" ); @@ -481,10 +484,10 @@ pub fn validate_similar_fill_sanitized_message( validated_similar_fill.is_none(), "Duplicated fill instruction" ); - ensure!(data.len() >= 8, "Not enough data in fill instruction"); - let (discriminator, mut ix_data) = data.split_at(8); + let (discriminator, mut ix_data) = + split_disc_and_bytes(data).context("Not enough data in fill instruction")?; ensure!( - discriminator == order_engine::client::args::Fill::DISCRIMINATOR, + discriminator.as_slice() == order_engine::client::args::Fill::DISCRIMINATOR, "Not a fill discriminator" ); @@ -529,11 +532,11 @@ pub fn validate_similar_fill_sanitized_message( "Additional instructions can only be from Lighthouse program at {real_index}" ); + let (discriminator, _) = split_disc1byte_and_bytes(data).with_context(|| { + format!("Invalid Lighthouse instruction discriminator at index {real_index}") + })?; ensure!( - // TODO use split_disc1byte_and_bytes - data.first() - .map(|discriminator| ALLOWED_LIGHTHOUSE_DISCRIMINATORS.contains(discriminator)) - .unwrap_or(false), + ALLOWED_LIGHTHOUSE_DISCRIMINATORS.contains(&discriminator[0]), "Invalid Lighthouse instruction discriminator at index {real_index}" ); diff --git a/order-engine-sdk/src/parse_util.rs b/order-engine-sdk/src/parse_util.rs index f030dba..37424f9 100644 --- a/order-engine-sdk/src/parse_util.rs +++ b/order-engine-sdk/src/parse_util.rs @@ -1,7 +1,6 @@ use anyhow::{bail, Result}; /// Splits an 8-byte discriminator off the front, returning `(disc, remaining_bytes)`. -#[allow(dead_code)] pub fn split_disc_and_bytes(bytes: &[u8]) -> Result<(&[u8; 8], &[u8])> { let Some((disc, remaining)) = bytes.split_first_chunk::<8>() else { bail!("Not enough bytes to split disc and bytes"); @@ -10,7 +9,6 @@ pub fn split_disc_and_bytes(bytes: &[u8]) -> Result<(&[u8; 8], &[u8])> { } /// Splits a 1-byte discriminator off the front, returning `(disc, remaining_bytes)`. -#[allow(dead_code)] pub fn split_disc1byte_and_bytes(bytes: &[u8]) -> Result<(&[u8; 1], &[u8])> { let Some((disc, remaining)) = bytes.split_first_chunk::<1>() else { bail!("Not enough bytes to split disc and bytes"); From afeead90b9ec6c2b73a075d0c3c8d9803e0cd808 Mon Sep 17 00:00:00 2001 From: GroovieGermanikus Date: Tue, 28 Jul 2026 08:15:14 +0200 Subject: [PATCH 09/15] add tests --- order-engine-sdk/src/parse_util.rs | 79 ++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/order-engine-sdk/src/parse_util.rs b/order-engine-sdk/src/parse_util.rs index 37424f9..f85d288 100644 --- a/order-engine-sdk/src/parse_util.rs +++ b/order-engine-sdk/src/parse_util.rs @@ -15,3 +15,82 @@ pub fn split_disc1byte_and_bytes(bytes: &[u8]) -> Result<(&[u8; 1], &[u8])> { }; Ok((disc, remaining)) } + +#[cfg(test)] +mod tests { + use super::*; + + const TOO_SHORT: &str = "Not enough bytes to split disc and bytes"; + + #[test] + fn test_split_disc_and_bytes_exact_length_leaves_no_remainder() { + let bytes = [1, 2, 3, 4, 5, 6, 7, 8]; + let (disc, remaining) = split_disc_and_bytes(&bytes).unwrap(); + assert_eq!(disc, &[1, 2, 3, 4, 5, 6, 7, 8]); + assert_eq!(remaining, &[] as &[u8]); + } + + #[test] + fn test_split_disc_and_bytes_splits_at_the_eighth_byte() { + let bytes = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + let (disc, remaining) = split_disc_and_bytes(&bytes).unwrap(); + assert_eq!(disc, &[1, 2, 3, 4, 5, 6, 7, 8]); + assert_eq!(remaining, &[9, 10, 11]); + } + + #[test] + fn test_split_disc_and_bytes_rejects_fewer_than_eight_bytes() { + for len in 0..8usize { + let bytes = vec![0xAAu8; len]; + let err = split_disc_and_bytes(&bytes) + .expect_err("{len} bytes must not yield an 8-byte discriminator"); + assert_eq!(err.to_string(), TOO_SHORT, "wrong error for length {len}"); + } + } + + #[test] + fn test_split_disc1byte_and_bytes_exact_length_leaves_no_remainder() { + let bytes = [7]; + let (disc, remaining) = split_disc1byte_and_bytes(&bytes).unwrap(); + assert_eq!(disc, &[7]); + assert_eq!(remaining, &[] as &[u8]); + } + + #[test] + fn test_split_disc1byte_and_bytes_splits_at_the_first_byte() { + let bytes = [7, 8, 9]; + let (disc, remaining) = split_disc1byte_and_bytes(&bytes).unwrap(); + assert_eq!(disc, &[7]); + assert_eq!(remaining, &[8, 9]); + } + + #[test] + fn test_split_disc1byte_and_bytes_rejects_empty_input() { + let err = split_disc1byte_and_bytes(&[]).expect_err("empty input has no discriminator"); + assert_eq!(err.to_string(), TOO_SHORT); + } + + /// A 1-byte discriminator must not be read out of an 8-byte-discriminator + /// buffer or vice versa — the two helpers are not interchangeable. + #[test] + fn test_the_two_helpers_disagree_on_a_single_byte_input() { + let bytes = [5]; + assert!(split_disc_and_bytes(&bytes).is_err()); + assert_eq!(split_disc1byte_and_bytes(&bytes).unwrap().0, &[5]); + } + + /// Both helpers must borrow from the caller's buffer rather than copy it, + /// so callers can keep deserializing straight out of `remaining`. + #[test] + fn test_returned_slices_borrow_from_the_input_buffer() { + let bytes = [1u8, 2, 3, 4, 5, 6, 7, 8, 9]; + + let (disc, remaining) = split_disc_and_bytes(&bytes).unwrap(); + assert_eq!(disc.as_ptr(), bytes.as_ptr()); + assert_eq!(remaining.as_ptr(), bytes[8..].as_ptr()); + + let (disc1, remaining1) = split_disc1byte_and_bytes(&bytes).unwrap(); + assert_eq!(disc1.as_ptr(), bytes.as_ptr()); + assert_eq!(remaining1.as_ptr(), bytes[1..].as_ptr()); + } +} From 5eccc1bd77fd63256f1e1207511a9a6a63c5653e Mon Sep 17 00:00:00 2001 From: GroovieGermanikus Date: Tue, 28 Jul 2026 08:15:22 +0200 Subject: [PATCH 10/15] comment --- order-engine-sdk/src/parse_util.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/order-engine-sdk/src/parse_util.rs b/order-engine-sdk/src/parse_util.rs index f85d288..56b2954 100644 --- a/order-engine-sdk/src/parse_util.rs +++ b/order-engine-sdk/src/parse_util.rs @@ -1,6 +1,6 @@ use anyhow::{bail, Result}; -/// Splits an 8-byte discriminator off the front, returning `(disc, remaining_bytes)`. +/// Splits an 8-byte discriminator (anchor style) off the front, returning `(disc, remaining_bytes)`. pub fn split_disc_and_bytes(bytes: &[u8]) -> Result<(&[u8; 8], &[u8])> { let Some((disc, remaining)) = bytes.split_first_chunk::<8>() else { bail!("Not enough bytes to split disc and bytes"); From a372b3d6e5b0c682d0c41863f29e127bc329a20c Mon Sep 17 00:00:00 2001 From: GroovieGermanikus Date: Tue, 28 Jul 2026 08:25:54 +0200 Subject: [PATCH 11/15] use error instead of anyhow --- order-engine-sdk/src/fill.rs | 1 - order-engine-sdk/src/parse_util.rs | 87 +++++++++++++++++++++++++----- 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/order-engine-sdk/src/fill.rs b/order-engine-sdk/src/fill.rs index 1be4383..99b0cd5 100644 --- a/order-engine-sdk/src/fill.rs +++ b/order-engine-sdk/src/fill.rs @@ -503,7 +503,6 @@ pub fn validate_similar_fill_sanitized_message( .context("Invalid taker input mint token account ix data")? .pubkey; - // TODO assert that this is not set here already ensure!(validated_similar_fill.is_none(), "Multiple fill instruction while expecting only one"); validated_similar_fill = Some(ValidatedSimilarFill { taker: *taker, diff --git a/order-engine-sdk/src/parse_util.rs b/order-engine-sdk/src/parse_util.rs index 56b2954..297dfad 100644 --- a/order-engine-sdk/src/parse_util.rs +++ b/order-engine-sdk/src/parse_util.rs @@ -1,26 +1,52 @@ -use anyhow::{bail, Result}; +use std::fmt; + +/// Errors produced while splitting a discriminator off an instruction data buffer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParseError { + /// The buffer was shorter than the discriminator it had to yield. + NotEnoughBytes { expected: usize, actual: usize }, +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotEnoughBytes { expected, actual } => write!( + f, + "Not enough bytes to split disc and bytes: needed {expected}, got {actual}" + ), + } + } +} + +impl std::error::Error for ParseError {} /// Splits an 8-byte discriminator (anchor style) off the front, returning `(disc, remaining_bytes)`. -pub fn split_disc_and_bytes(bytes: &[u8]) -> Result<(&[u8; 8], &[u8])> { - let Some((disc, remaining)) = bytes.split_first_chunk::<8>() else { - bail!("Not enough bytes to split disc and bytes"); - }; - Ok((disc, remaining)) +pub fn split_disc_and_bytes(bytes: &[u8]) -> Result<(&[u8; 8], &[u8]), ParseError> { + bytes + .split_first_chunk::<8>() + .ok_or(ParseError::NotEnoughBytes { + expected: 8, + actual: bytes.len(), + }) } /// Splits a 1-byte discriminator off the front, returning `(disc, remaining_bytes)`. -pub fn split_disc1byte_and_bytes(bytes: &[u8]) -> Result<(&[u8; 1], &[u8])> { - let Some((disc, remaining)) = bytes.split_first_chunk::<1>() else { - bail!("Not enough bytes to split disc and bytes"); - }; - Ok((disc, remaining)) +pub fn split_disc1byte_and_bytes(bytes: &[u8]) -> Result<(&[u8; 1], &[u8]), ParseError> { + bytes + .split_first_chunk::<1>() + .ok_or(ParseError::NotEnoughBytes { + expected: 1, + actual: bytes.len(), + }) } #[cfg(test)] mod tests { use super::*; - const TOO_SHORT: &str = "Not enough bytes to split disc and bytes"; + fn too_short(expected: usize, actual: usize) -> String { + format!("Not enough bytes to split disc and bytes: needed {expected}, got {actual}") + } #[test] fn test_split_disc_and_bytes_exact_length_leaves_no_remainder() { @@ -44,7 +70,11 @@ mod tests { let bytes = vec![0xAAu8; len]; let err = split_disc_and_bytes(&bytes) .expect_err("{len} bytes must not yield an 8-byte discriminator"); - assert_eq!(err.to_string(), TOO_SHORT, "wrong error for length {len}"); + assert_eq!( + err.to_string(), + too_short(8, len), + "wrong error for length {len}" + ); } } @@ -67,7 +97,36 @@ mod tests { #[test] fn test_split_disc1byte_and_bytes_rejects_empty_input() { let err = split_disc1byte_and_bytes(&[]).expect_err("empty input has no discriminator"); - assert_eq!(err.to_string(), TOO_SHORT); + assert_eq!(err.to_string(), too_short(1, 0)); + } + + /// The error is structured, so callers can inspect the byte counts instead + /// of parsing the message. + #[test] + fn test_error_reports_expected_and_actual_byte_counts() { + assert_eq!( + split_disc_and_bytes(&[1, 2, 3]).unwrap_err(), + ParseError::NotEnoughBytes { + expected: 8, + actual: 3 + } + ); + assert_eq!( + split_disc1byte_and_bytes(&[]).unwrap_err(), + ParseError::NotEnoughBytes { + expected: 1, + actual: 0 + } + ); + } + + /// `fill.rs` calls `.context(..)` on these results, which relies on anyhow's + /// blanket impl for `Result where E: StdError + Send + Sync + 'static`. + /// If `ParseError` ever loses that bound, those call sites break — fail here instead. + #[test] + fn test_parse_error_satisfies_anyhow_context_bound() { + fn assert_bound() {} + assert_bound::(); } /// A 1-byte discriminator must not be read out of an 8-byte-discriminator From e9ba92b385371e36317b8f286b2426cb4663cf08 Mon Sep 17 00:00:00 2001 From: GroovieGermanikus Date: Tue, 28 Jul 2026 08:30:10 +0200 Subject: [PATCH 12/15] fix lighthouse disc check --- order-engine-sdk/src/fill.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/order-engine-sdk/src/fill.rs b/order-engine-sdk/src/fill.rs index 99b0cd5..94b5fc3 100644 --- a/order-engine-sdk/src/fill.rs +++ b/order-engine-sdk/src/fill.rs @@ -25,12 +25,12 @@ const NATIVE_MINT: Pubkey = pubkey!("So11111111111111111111111111111111111111112 // // If we allow the MemoryWrite instruction, the hacker can drain the signer. // https://github.com/Jac0xb/lighthouse/blob/main/programs/lighthouse/lighthouse.json -const ALLOWED_LIGHTHOUSE_DISCRIMINATORS: &[u8] = &[ - // 0 .. CAUTION - If we allow the MemoryWrite instruction, the hacker can drain the signer. - 5, // AssertAccountInfo - 6, // AssertAccountInfoMulti - 9, // AssertTokenAccount - 10, // AssertTokenAccountMulti +const ALLOWED_LIGHTHOUSE_DISCRIMINATORS: &[&[u8]] = &[ + // &[0] .. CAUTION - If we allow the MemoryWrite instruction, the hacker can drain the signer. + &[5], // AssertAccountInfo + &[6], // AssertAccountInfoMulti + &[9], // AssertTokenAccount + &[10], // AssertTokenAccountMulti ]; pub struct Order { @@ -535,7 +535,7 @@ pub fn validate_similar_fill_sanitized_message( format!("Invalid Lighthouse instruction discriminator at index {real_index}") })?; ensure!( - ALLOWED_LIGHTHOUSE_DISCRIMINATORS.contains(&discriminator[0]), + ALLOWED_LIGHTHOUSE_DISCRIMINATORS.contains(&discriminator.as_slice()), "Invalid Lighthouse instruction discriminator at index {real_index}" ); From 38ebd6341a93c5969995a40b94dfc1c53db22ca7 Mon Sep 17 00:00:00 2001 From: GroovieGermanikus Date: Tue, 28 Jul 2026 08:50:08 +0200 Subject: [PATCH 13/15] consolidate accounts array access --- order-engine-sdk/src/fill.rs | 61 ++++++++++++++++++------------------ order-engine-sdk/src/lib.rs | 6 ++++ 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/order-engine-sdk/src/fill.rs b/order-engine-sdk/src/fill.rs index 94b5fc3..7e998bd 100644 --- a/order-engine-sdk/src/fill.rs +++ b/order-engine-sdk/src/fill.rs @@ -1,4 +1,4 @@ -use crate::order_engine; +use crate::{account_pubkeys, order_engine}; use crate::parse_util::{split_disc1byte_and_bytes, split_disc_and_bytes}; use anchor_lang::{pubkey, AnchorDeserialize, Discriminator}; use anchor_spl::{ @@ -14,7 +14,7 @@ use solana_sdk::{ pubkey::Pubkey, system_instruction::SystemInstruction, system_program, - sysvar::instructions::BorrowedInstruction, + sysvar::instructions::{BorrowedAccountMeta, BorrowedInstruction}, }; const LIGHTHOUSE_PROGRAM_ID: Pubkey = pubkey!("L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95"); @@ -113,6 +113,8 @@ pub fn validate_fill_sanitized_message( data, } in sanitized_message.decompile_instructions() { + let pubkeys = account_pubkeys(&accounts); + if program_id == &compute_budget::ID { // Compute budget should have been driven from the fee payer, certainly need to validate let compute_budget_ix = try_from_slice_unchecked::(data)?; @@ -139,8 +141,13 @@ pub fn validate_fill_sanitized_message( ); // We verify the taker is paying for the token account - // TODO pull up accounts.first() - ensure!(accounts.first().map(|am| am.pubkey) != Some(&order.maker)); + let [funder, ..] = pubkeys.as_slice() else { + bail!("Not enough accounts in create associated token account"); + }; + ensure!( + funder != &order.maker, + "Associated token account funder must not be the maker" + ); } else if program_id == &order_engine::ID { ensure!(!fill_ix_found, "Duplicated fill instruction"); fill_ix_found = true; @@ -153,7 +160,6 @@ pub fn validate_fill_sanitized_message( "Not a fill discriminator" ); - let pubkeys = accounts.into_iter().map(|a| *a.pubkey).collect::>(); let [taker, maker, _taker_input_mint_token_account, _maker_input_mint_token_account, taker_output_mint_token_account, _maker_output_mint_token_account, input_mint, _input_token_program, output_mint, output_token_program, ..] = pubkeys.as_slice() else { @@ -188,17 +194,14 @@ pub fn validate_fill_sanitized_message( else { bail!("Unexpected system program instruction"); }; - let [from, to, ..] = accounts.as_slice() else { + let [from, to, ..] = pubkeys.as_slice() else { bail!("Not enough accounts in system transfer"); }; - ensure!( - *from.pubkey == order.taker, - "System transfer source must be taker" - ); + ensure!(from == &order.taker, "System transfer source must be taker"); let is_integrator_transfer = expected_integrator - .map(|i| *to.pubkey == i.destination) + .map(|i| to == &i.destination) .unwrap_or(false); if is_integrator_transfer { let integrator = expected_integrator.expect("checked just above"); @@ -223,7 +226,7 @@ pub fn validate_fill_sanitized_message( "Unexpected system_program transfer for non-native output" ); ensure!( - *to.pubkey == receiver, + to == &receiver, "Receiver transfer destination must be the receiver" ); ensure!( @@ -239,11 +242,11 @@ pub fn validate_fill_sanitized_message( TokenInstruction::SyncNative => { let integrator = expected_integrator.context("Unexpected sync_native instruction")?; - let [account, ..] = accounts.as_slice() else { + let [account, ..] = pubkeys.as_slice() else { bail!("Not enough accounts in sync_native"); }; ensure!( - *account.pubkey == integrator.destination, + account == &integrator.destination, "sync_native must target the integrator destination" ); ensure!( @@ -257,11 +260,11 @@ pub fn validate_fill_sanitized_message( integrator_sync_native_validated = true; } TokenInstruction::TransferChecked { amount, decimals } => { - let [source, mint, destination, authority, ..] = accounts.as_slice() else { + let [source, mint, destination, authority, ..] = pubkeys.as_slice() else { bail!("Not enough accounts in transfer_checked"); }; let is_integrator_transfer = expected_integrator - .map(|i| *destination.pubkey == i.destination) + .map(|i| destination == &i.destination) .unwrap_or(false); if is_integrator_transfer { let integrator = expected_integrator.expect("checked just above"); @@ -270,7 +273,7 @@ pub fn validate_fill_sanitized_message( "Duplicated integrator-fee transfer" ); ensure!( - *authority.pubkey == order.taker, + authority == &order.taker, "Integrator-fee transfer authority must be the taker" ); ensure!( @@ -303,19 +306,19 @@ pub fn validate_fill_sanitized_message( program_id, ); ensure!( - *source.pubkey == fill.taker_output_mint_token_account, + source == &fill.taker_output_mint_token_account, "Receiver transfer source must be the taker output token account from the fill ix" ); ensure!( - *mint.pubkey == order.output_mint, + mint == &order.output_mint, "Receiver transfer mint must equal output_mint" ); ensure!( - *destination.pubkey == expected_destination, + destination == &expected_destination, "Receiver transfer destination must be the receiver's ATA" ); ensure!( - *authority.pubkey == order.taker, + authority == &order.taker, "Receiver transfer authority must be the taker" ); ensure!( @@ -493,17 +496,15 @@ pub fn validate_similar_fill_sanitized_message( let fill_ix = order_engine::client::args::Fill::deserialize(&mut ix_data) .map_err(|e| anyhow!("Invalid fill ix data {e}"))?; - // We check if the taker has enough balance to fill the order first - let taker = accounts.first().context("Invalid fill ix data")?.pubkey; - let input_mint = accounts.get(6).context("Invalid fill ix data")?.pubkey; - let output_mint = accounts.get(8).context("Invalid fill ix data")?.pubkey; - let taker_input_mint_token_account = accounts - .get(2) - .context("Invalid taker input mint token account ix data")? - .pubkey; + // We check if the taker has enough balance to fill the order first + let pubkeys = account_pubkeys(&accounts); + let [taker, _maker, taker_input_mint_token_account, _maker_input_mint_token_account, _taker_output_mint_token_account, _maker_output_mint_token_account, input_mint, _input_token_program, output_mint, ..] = + pubkeys.as_slice() + else { + bail!("Not enough accounts in fill instruction"); + }; - ensure!(validated_similar_fill.is_none(), "Multiple fill instruction while expecting only one"); validated_similar_fill = Some(ValidatedSimilarFill { taker: *taker, input_amount: fill_ix.input_amount, diff --git a/order-engine-sdk/src/lib.rs b/order-engine-sdk/src/lib.rs index 58d5062..d8fa245 100644 --- a/order-engine-sdk/src/lib.rs +++ b/order-engine-sdk/src/lib.rs @@ -1,7 +1,13 @@ use anchor_lang::prelude::*; +use anchor_lang::solana_program::sysvar::instructions::BorrowedAccountMeta; declare_program!(order_engine); pub mod fill; pub mod transaction; pub mod parse_util; + +pub fn account_pubkeys(accounts: &[BorrowedAccountMeta]) -> Vec { + accounts.iter().map(|meta| *meta.pubkey).collect() +} + From 0c2303412a8e535e96273168f31df80f2b86de95 Mon Sep 17 00:00:00 2001 From: GroovieGermanikus Date: Tue, 28 Jul 2026 08:56:45 +0200 Subject: [PATCH 14/15] small fix --- order-engine-sdk/src/fill.rs | 4 ++-- order-engine-sdk/src/lib.rs | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/order-engine-sdk/src/fill.rs b/order-engine-sdk/src/fill.rs index 7e998bd..6050dd4 100644 --- a/order-engine-sdk/src/fill.rs +++ b/order-engine-sdk/src/fill.rs @@ -1,5 +1,5 @@ -use crate::{account_pubkeys, order_engine}; use crate::parse_util::{split_disc1byte_and_bytes, split_disc_and_bytes}; +use crate::{account_pubkeys, order_engine}; use anchor_lang::{pubkey, AnchorDeserialize, Discriminator}; use anchor_spl::{ associated_token::{self, get_associated_token_address_with_program_id}, @@ -14,7 +14,7 @@ use solana_sdk::{ pubkey::Pubkey, system_instruction::SystemInstruction, system_program, - sysvar::instructions::{BorrowedAccountMeta, BorrowedInstruction}, + sysvar::instructions::BorrowedInstruction, }; const LIGHTHOUSE_PROGRAM_ID: Pubkey = pubkey!("L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95"); diff --git a/order-engine-sdk/src/lib.rs b/order-engine-sdk/src/lib.rs index d8fa245..c592fe9 100644 --- a/order-engine-sdk/src/lib.rs +++ b/order-engine-sdk/src/lib.rs @@ -4,10 +4,9 @@ use anchor_lang::solana_program::sysvar::instructions::BorrowedAccountMeta; declare_program!(order_engine); pub mod fill; -pub mod transaction; pub mod parse_util; +pub mod transaction; pub fn account_pubkeys(accounts: &[BorrowedAccountMeta]) -> Vec { accounts.iter().map(|meta| *meta.pubkey).collect() } - From d546637e34c149b7ac9dea4e2b9ec49cd392b954 Mon Sep 17 00:00:00 2001 From: GroovieGermanikus Date: Tue, 4 Aug 2026 12:36:38 +0200 Subject: [PATCH 15/15] replace .split with parse_util --- order-engine-sdk/src/fill.rs | 6 +++--- order-engine-sdk/src/parse_util.rs | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/order-engine-sdk/src/fill.rs b/order-engine-sdk/src/fill.rs index 8c442fb..3fdd1a0 100644 --- a/order-engine-sdk/src/fill.rs +++ b/order-engine-sdk/src/fill.rs @@ -1,4 +1,4 @@ -use crate::parse_util::{split_disc1byte_and_bytes, split_disc_and_bytes}; +use crate::parse_util::{split_disc1byte_and_bytes, split_disc8bytes_and_bytes}; use crate::{account_pubkeys, order_engine}; use anchor_lang::{pubkey, AnchorDeserialize, Discriminator}; use anchor_spl::{ @@ -154,7 +154,7 @@ pub fn validate_fill_sanitized_message( // Must slice off anchor's discriminator first let (discriminator, mut ix_data) = - split_disc_and_bytes(data).context("Not enough data in fill instruction")?; + split_disc8bytes_and_bytes(data)?; ensure!( discriminator.as_slice() == order_engine::client::args::Fill::DISCRIMINATOR, "Not a fill discriminator" @@ -488,7 +488,7 @@ pub fn validate_similar_fill_sanitized_message( "Duplicated fill instruction" ); let (discriminator, mut ix_data) = - split_disc_and_bytes(data).context("Not enough data in fill instruction")?; + split_disc8bytes_and_bytes(data)?; ensure!( discriminator.as_slice() == order_engine::client::args::Fill::DISCRIMINATOR, "Not a fill discriminator" diff --git a/order-engine-sdk/src/parse_util.rs b/order-engine-sdk/src/parse_util.rs index 297dfad..8b34ac5 100644 --- a/order-engine-sdk/src/parse_util.rs +++ b/order-engine-sdk/src/parse_util.rs @@ -21,7 +21,7 @@ impl fmt::Display for ParseError { impl std::error::Error for ParseError {} /// Splits an 8-byte discriminator (anchor style) off the front, returning `(disc, remaining_bytes)`. -pub fn split_disc_and_bytes(bytes: &[u8]) -> Result<(&[u8; 8], &[u8]), ParseError> { +pub fn split_disc8bytes_and_bytes(bytes: &[u8]) -> Result<(&[u8; 8], &[u8]), ParseError> { bytes .split_first_chunk::<8>() .ok_or(ParseError::NotEnoughBytes { @@ -51,7 +51,7 @@ mod tests { #[test] fn test_split_disc_and_bytes_exact_length_leaves_no_remainder() { let bytes = [1, 2, 3, 4, 5, 6, 7, 8]; - let (disc, remaining) = split_disc_and_bytes(&bytes).unwrap(); + let (disc, remaining) = split_disc8bytes_and_bytes(&bytes).unwrap(); assert_eq!(disc, &[1, 2, 3, 4, 5, 6, 7, 8]); assert_eq!(remaining, &[] as &[u8]); } @@ -59,7 +59,7 @@ mod tests { #[test] fn test_split_disc_and_bytes_splits_at_the_eighth_byte() { let bytes = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; - let (disc, remaining) = split_disc_and_bytes(&bytes).unwrap(); + let (disc, remaining) = split_disc8bytes_and_bytes(&bytes).unwrap(); assert_eq!(disc, &[1, 2, 3, 4, 5, 6, 7, 8]); assert_eq!(remaining, &[9, 10, 11]); } @@ -68,7 +68,7 @@ mod tests { fn test_split_disc_and_bytes_rejects_fewer_than_eight_bytes() { for len in 0..8usize { let bytes = vec![0xAAu8; len]; - let err = split_disc_and_bytes(&bytes) + let err = split_disc8bytes_and_bytes(&bytes) .expect_err("{len} bytes must not yield an 8-byte discriminator"); assert_eq!( err.to_string(), @@ -105,7 +105,7 @@ mod tests { #[test] fn test_error_reports_expected_and_actual_byte_counts() { assert_eq!( - split_disc_and_bytes(&[1, 2, 3]).unwrap_err(), + split_disc8bytes_and_bytes(&[1, 2, 3]).unwrap_err(), ParseError::NotEnoughBytes { expected: 8, actual: 3 @@ -134,7 +134,7 @@ mod tests { #[test] fn test_the_two_helpers_disagree_on_a_single_byte_input() { let bytes = [5]; - assert!(split_disc_and_bytes(&bytes).is_err()); + assert!(split_disc8bytes_and_bytes(&bytes).is_err()); assert_eq!(split_disc1byte_and_bytes(&bytes).unwrap().0, &[5]); } @@ -144,7 +144,7 @@ mod tests { fn test_returned_slices_borrow_from_the_input_buffer() { let bytes = [1u8, 2, 3, 4, 5, 6, 7, 8, 9]; - let (disc, remaining) = split_disc_and_bytes(&bytes).unwrap(); + let (disc, remaining) = split_disc8bytes_and_bytes(&bytes).unwrap(); assert_eq!(disc.as_ptr(), bytes.as_ptr()); assert_eq!(remaining.as_ptr(), bytes[8..].as_ptr());