From 271664260180a72ee306fb965555a0b5b01c5448 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Thu, 30 Apr 2026 17:30:32 +0700 Subject: [PATCH 01/11] feat(state): port session action permission types from lazorkit-protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the 8 session action types (SolLimit, SolRecurringLimit, SolMaxPerTx, TokenLimit, TokenRecurringLimit, TokenMaxPerTx, ProgramWhitelist, ProgramBlacklist) plus parser, validator, and 30 unit tests verbatim from lazorkit-protocol. Type discriminators (1, 2, 3, 4, 5, 6, 10, 11) and the 11-byte header layout are preserved so the unified SDK can encode actions identically for both programs. Foundation only — does not yet wire actions into CreateSession or enforce them at Execute. Those land in follow-up commits (P1b, P1c). Notes: - action.rs is byte-identical to lazorkit-protocol@HEAD for audit comparability; existing harmless `unused_mut` warning ported as-is. - AuthError numeric codes 3020-3029 mirror lazorkit-protocol so the unified SDK can decode action errors uniformly. --- program/src/error.rs | 11 + program/src/state/action.rs | 697 ++++++++++++++++++++++++++++++++++++ program/src/state/mod.rs | 1 + 3 files changed, 709 insertions(+) create mode 100644 program/src/state/action.rs diff --git a/program/src/error.rs b/program/src/error.rs index f555307..3547dde 100644 --- a/program/src/error.rs +++ b/program/src/error.rs @@ -22,6 +22,17 @@ pub enum AuthError { UnauthorizedReclaim = 3017, DeferredAuthorizationNotExpired = 3018, InvalidSessionAccount = 3019, + // Session action errors (codes aligned with lazorkit-protocol for unified SDK error decoding) + ActionBufferInvalid = 3020, + ActionProgramNotWhitelisted = 3021, + ActionProgramBlacklisted = 3022, + ActionSolMaxPerTxExceeded = 3023, + ActionSolLimitExceeded = 3024, + ActionSolRecurringLimitExceeded = 3025, + ActionTokenLimitExceeded = 3026, + ActionTokenRecurringLimitExceeded = 3027, + ActionWhitelistBlacklistConflict = 3028, + ActionTokenMaxPerTxExceeded = 3029, } impl From for ProgramError { diff --git a/program/src/state/action.rs b/program/src/state/action.rs new file mode 100644 index 0000000..7195fa4 --- /dev/null +++ b/program/src/state/action.rs @@ -0,0 +1,697 @@ +//! Session action types for permission enforcement. +//! +//! Actions are optional, immutable permission rules attached to sessions at creation time. +//! They are stored as a flat byte buffer appended after the 80-byte SessionAccount header. +//! +//! Each action has an 11-byte header: [type: u8][data_len: u16 LE][expires_at: u64 LE] +//! followed by type-specific data bytes. + +use pinocchio::program_error::ProgramError; + +use crate::error::AuthError; + +// ─── Action Header ──────────────────────────────────────────────────── + +/// Size of each action header in bytes. +pub const ACTION_HEADER_SIZE: usize = 11; + +/// Maximum number of actions per session. +pub const MAX_ACTIONS: usize = 16; + +// ─── Action Types ───────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum ActionType { + /// Lifetime SOL spending cap. Data: {remaining: u64} + SolLimit = 1, + /// Per-window SOL spending cap. Data: {limit, spent, window, last_reset} + SolRecurringLimit = 2, + /// Maximum SOL per single execute. Data: {max: u64} + SolMaxPerTx = 3, + /// Lifetime token spending cap per mint. Data: {mint: [u8;32], remaining: u64} + TokenLimit = 4, + /// Per-window token spending cap per mint. Data: {mint, limit, spent, window, last_reset} + TokenRecurringLimit = 5, + /// Maximum tokens per single execute per mint. Data: {mint: [u8;32], max: u64} + TokenMaxPerTx = 6, + /// Allow CPI only to this program. Repeatable. Data: {program_id: [u8;32]} + ProgramWhitelist = 10, + /// Block CPI to this program. Repeatable. Data: {program_id: [u8;32]} + ProgramBlacklist = 11, +} + +impl ActionType { + pub fn from_u8(v: u8) -> Result { + match v { + 1 => Ok(Self::SolLimit), + 2 => Ok(Self::SolRecurringLimit), + 3 => Ok(Self::SolMaxPerTx), + 4 => Ok(Self::TokenLimit), + 5 => Ok(Self::TokenRecurringLimit), + 6 => Ok(Self::TokenMaxPerTx), + 10 => Ok(Self::ProgramWhitelist), + 11 => Ok(Self::ProgramBlacklist), + _ => Err(AuthError::ActionBufferInvalid.into()), + } + } + + /// Expected data size for this action type (excluding header). + pub fn expected_data_size(&self) -> usize { + match self { + Self::SolLimit => SOL_LIMIT_SIZE, + Self::SolRecurringLimit => SOL_RECURRING_LIMIT_SIZE, + Self::SolMaxPerTx => SOL_MAX_PER_TX_SIZE, + Self::TokenLimit => TOKEN_LIMIT_SIZE, + Self::TokenRecurringLimit => TOKEN_RECURRING_LIMIT_SIZE, + Self::TokenMaxPerTx => TOKEN_MAX_PER_TX_SIZE, + Self::ProgramWhitelist => PROGRAM_WHITELIST_SIZE, + Self::ProgramBlacklist => PROGRAM_BLACKLIST_SIZE, + } + } +} + +// ─── Data Sizes ─────────────────────────────────────────────────────── + +pub const SOL_LIMIT_SIZE: usize = 8; +pub const SOL_RECURRING_LIMIT_SIZE: usize = 32; +pub const SOL_MAX_PER_TX_SIZE: usize = 8; +pub const TOKEN_LIMIT_SIZE: usize = 40; +pub const TOKEN_RECURRING_LIMIT_SIZE: usize = 64; +pub const TOKEN_MAX_PER_TX_SIZE: usize = 40; +pub const PROGRAM_WHITELIST_SIZE: usize = 32; +pub const PROGRAM_BLACKLIST_SIZE: usize = 32; + +// ─── Action View (zero-copy index into buffer) ─────────────────────── + +/// A parsed reference to an action within the session data buffer. +/// Does not own data — just indexes into the buffer. +#[derive(Debug, Clone)] +pub struct ActionView { + pub action_type: ActionType, + pub expires_at: u64, + /// Byte offset of this action's data within the actions buffer + /// (relative to start of actions buffer, NOT session account start). + pub data_offset: usize, + pub data_len: usize, +} + +// ─── Buffer Parsing ─────────────────────────────────────────────────── + +/// Parse all actions from a raw actions buffer. +/// +/// The buffer starts immediately after the 80-byte session header. +/// Returns a Vec of ActionViews indexing into the buffer. +pub fn parse_actions(buf: &[u8]) -> Result, ProgramError> { + let mut actions = Vec::new(); + let mut cursor = 0; + + while cursor < buf.len() { + if cursor + ACTION_HEADER_SIZE > buf.len() { + return Err(AuthError::ActionBufferInvalid.into()); + } + + let action_type = ActionType::from_u8(buf[cursor])?; + let data_len = u16::from_le_bytes([buf[cursor + 1], buf[cursor + 2]]) as usize; + let expires_at = u64::from_le_bytes( + buf[cursor + 3..cursor + 11] + .try_into() + .map_err(|_| AuthError::ActionBufferInvalid)?, + ); + + let data_offset = cursor + ACTION_HEADER_SIZE; + if data_offset + data_len > buf.len() { + return Err(AuthError::ActionBufferInvalid.into()); + } + + actions.push(ActionView { + action_type, + expires_at, + data_offset, + data_len, + }); + + cursor = data_offset + data_len; + + if actions.len() > MAX_ACTIONS { + return Err(AuthError::ActionBufferInvalid.into()); + } + } + + Ok(actions) +} + +/// Validate an actions buffer at session creation time. +/// +/// Checks: +/// - All action types are known +/// - Data sizes match expected sizes per type +/// - No simultaneous ProgramWhitelist + ProgramBlacklist +/// - Buffer is fully consumed (no trailing bytes) +/// - Not exceeding MAX_ACTIONS +pub fn validate_actions_buffer(buf: &[u8]) -> Result<(), ProgramError> { + if buf.is_empty() { + return Ok(()); + } + + let actions = parse_actions(buf)?; + + // Verify data sizes match expected + for action in &actions { + if action.data_len != action.action_type.expected_data_size() { + return Err(AuthError::ActionBufferInvalid.into()); + } + } + + // Check no whitelist + blacklist coexistence + let has_whitelist = actions + .iter() + .any(|a| a.action_type == ActionType::ProgramWhitelist); + let has_blacklist = actions + .iter() + .any(|a| a.action_type == ActionType::ProgramBlacklist); + if has_whitelist && has_blacklist { + return Err(AuthError::ActionWhitelistBlacklistConflict.into()); + } + + // Check no duplicate non-repeatable actions + let mut has_sol_limit = false; + let mut has_sol_recurring = false; + let mut has_sol_max_per_tx = false; + for action in &actions { + match action.action_type { + ActionType::SolLimit => { + if has_sol_limit { + return Err(AuthError::ActionBufferInvalid.into()); + } + has_sol_limit = true; + } + ActionType::SolRecurringLimit => { + if has_sol_recurring { + return Err(AuthError::ActionBufferInvalid.into()); + } + has_sol_recurring = true; + } + ActionType::SolMaxPerTx => { + if has_sol_max_per_tx { + return Err(AuthError::ActionBufferInvalid.into()); + } + has_sol_max_per_tx = true; + } + _ => {} // Repeatable types are fine + } + } + + // Check no duplicate token actions for the same mint + // (Two TokenLimit for the same mint would create confusing deduction semantics) + { + let token_types = [ + ActionType::TokenLimit, + ActionType::TokenRecurringLimit, + ActionType::TokenMaxPerTx, + ]; + for token_type in &token_types { + let token_actions: Vec<&ActionView> = actions + .iter() + .filter(|a| &a.action_type == token_type) + .collect(); + for i in 0..token_actions.len() { + for j in (i + 1)..token_actions.len() { + let mint_a = &buf[token_actions[i].data_offset..token_actions[i].data_offset + 32]; + let mint_b = &buf[token_actions[j].data_offset..token_actions[j].data_offset + 32]; + if mint_a == mint_b { + return Err(AuthError::ActionBufferInvalid.into()); + } + } + } + } + } + + // Validate recurring limit initial state + for action in &actions { + if action.action_type == ActionType::SolRecurringLimit { + let data = &buf[action.data_offset..action.data_offset + action.data_len]; + // spent must be 0 at creation + let spent = u64::from_le_bytes(data[8..16].try_into().unwrap()); + if spent != 0 { + return Err(AuthError::ActionBufferInvalid.into()); + } + // window must be > 0 + let window = u64::from_le_bytes(data[16..24].try_into().unwrap()); + if window == 0 { + return Err(AuthError::ActionBufferInvalid.into()); + } + // last_reset must be 0 + let last_reset = u64::from_le_bytes(data[24..32].try_into().unwrap()); + if last_reset != 0 { + return Err(AuthError::ActionBufferInvalid.into()); + } + } + if action.action_type == ActionType::TokenRecurringLimit { + let data = &buf[action.data_offset..action.data_offset + action.data_len]; + // spent must be 0 (bytes 40..48) + let spent = u64::from_le_bytes(data[40..48].try_into().unwrap()); + if spent != 0 { + return Err(AuthError::ActionBufferInvalid.into()); + } + // window must be > 0 (bytes 48..56) + let window = u64::from_le_bytes(data[48..56].try_into().unwrap()); + if window == 0 { + return Err(AuthError::ActionBufferInvalid.into()); + } + // last_reset must be 0 (bytes 56..64) + let last_reset = u64::from_le_bytes(data[56..64].try_into().unwrap()); + if last_reset != 0 { + return Err(AuthError::ActionBufferInvalid.into()); + } + } + } + + Ok(()) +} + +// ─── Data Layout Helpers ────────────────────────────────────────────── + +// SolLimit: [remaining: u64] = 8 bytes +// Offsets within data: remaining = 0..8 + +// SolRecurringLimit: [limit: u64][spent: u64][window: u64][last_reset: u64] = 32 bytes +// Offsets: limit = 0..8, spent = 8..16, window = 16..24, last_reset = 24..32 + +// SolMaxPerTx: [max: u64] = 8 bytes +// Offsets: max = 0..8 + +// TokenLimit: [mint: [u8;32]][remaining: u64] = 40 bytes +// Offsets: mint = 0..32, remaining = 32..40 + +// TokenRecurringLimit: [mint: [u8;32]][limit: u64][spent: u64][window: u64][last_reset: u64] = 64 bytes +// Offsets: mint = 0..32, limit = 32..40, spent = 40..48, window = 48..56, last_reset = 56..64 + +// TokenMaxPerTx: [mint: [u8;32]][max: u64] = 40 bytes +// Offsets: mint = 0..32, max = 32..40 + +// ProgramWhitelist: [program_id: [u8;32]] = 32 bytes +// ProgramBlacklist: [program_id: [u8;32]] = 32 bytes + +/// Read a u64 from a byte slice at the given offset (LE). +#[inline(always)] +pub fn read_u64(data: &[u8], offset: usize) -> u64 { + u64::from_le_bytes(data[offset..offset + 8].try_into().unwrap()) +} + +/// Write a u64 to a byte slice at the given offset (LE). +#[inline(always)] +pub fn write_u64(data: &mut [u8], offset: usize, value: u64) { + data[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); +} + +// ─── Tests ──────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn build_action(action_type: u8, expires_at: u64, data: &[u8]) -> Vec { + let mut buf = Vec::new(); + buf.push(action_type); + buf.extend_from_slice(&(data.len() as u16).to_le_bytes()); + buf.extend_from_slice(&expires_at.to_le_bytes()); + buf.extend_from_slice(data); + buf + } + + #[test] + fn test_parse_empty_buffer() { + let actions = parse_actions(&[]).unwrap(); + assert!(actions.is_empty()); + } + + #[test] + fn test_parse_sol_limit() { + let data = 1_000_000u64.to_le_bytes(); + let buf = build_action(1, 0, &data); + let actions = parse_actions(&buf).unwrap(); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].action_type, ActionType::SolLimit); + assert_eq!(actions[0].expires_at, 0); + assert_eq!(actions[0].data_len, 8); + } + + #[test] + fn test_parse_sol_recurring_limit() { + let mut data = Vec::new(); + data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit + data.extend_from_slice(&0u64.to_le_bytes()); // spent + data.extend_from_slice(&216_000u64.to_le_bytes()); // window (~1 day) + data.extend_from_slice(&0u64.to_le_bytes()); // last_reset + let buf = build_action(2, 0, &data); + let actions = parse_actions(&buf).unwrap(); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0].action_type, ActionType::SolRecurringLimit); + assert_eq!(actions[0].data_len, 32); + } + + #[test] + fn test_parse_program_whitelist_multiple() { + let prog1 = [1u8; 32]; + let prog2 = [2u8; 32]; + let mut buf = build_action(10, 0, &prog1); + buf.extend_from_slice(&build_action(10, 0, &prog2)); + let actions = parse_actions(&buf).unwrap(); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0].action_type, ActionType::ProgramWhitelist); + assert_eq!(actions[1].action_type, ActionType::ProgramWhitelist); + } + + #[test] + fn test_parse_multiple_action_types() { + let mut buf = Vec::new(); + // SolMaxPerTx + buf.extend_from_slice(&build_action(3, 0, &500_000u64.to_le_bytes())); + // ProgramWhitelist + buf.extend_from_slice(&build_action(10, 0, &[0xAA; 32])); + let actions = parse_actions(&buf).unwrap(); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0].action_type, ActionType::SolMaxPerTx); + assert_eq!(actions[1].action_type, ActionType::ProgramWhitelist); + } + + #[test] + fn test_parse_with_expiry() { + let data = 1_000_000u64.to_le_bytes(); + let buf = build_action(1, 5000, &data); + let actions = parse_actions(&buf).unwrap(); + assert_eq!(actions[0].expires_at, 5000); + } + + #[test] + fn test_validate_unknown_type() { + let buf = build_action(99, 0, &[0u8; 8]); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_wrong_data_size() { + // SolLimit expects 8 bytes, give it 16 + let buf = build_action(1, 0, &[0u8; 16]); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_whitelist_blacklist_conflict() { + let mut buf = build_action(10, 0, &[1u8; 32]); // whitelist + buf.extend_from_slice(&build_action(11, 0, &[2u8; 32])); // blacklist + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_duplicate_sol_limit() { + let mut buf = build_action(1, 0, &1_000_000u64.to_le_bytes()); + buf.extend_from_slice(&build_action(1, 0, &2_000_000u64.to_le_bytes())); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_recurring_nonzero_spent() { + let mut data = Vec::new(); + data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit + data.extend_from_slice(&100u64.to_le_bytes()); // spent (should be 0!) + data.extend_from_slice(&216_000u64.to_le_bytes()); // window + data.extend_from_slice(&0u64.to_le_bytes()); // last_reset + let buf = build_action(2, 0, &data); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_recurring_zero_window() { + let mut data = Vec::new(); + data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit + data.extend_from_slice(&0u64.to_le_bytes()); // spent + data.extend_from_slice(&0u64.to_le_bytes()); // window = 0 (invalid!) + data.extend_from_slice(&0u64.to_le_bytes()); // last_reset + let buf = build_action(2, 0, &data); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_empty_ok() { + assert!(validate_actions_buffer(&[]).is_ok()); + } + + #[test] + fn test_validate_valid_combined() { + let mut buf = Vec::new(); + // SolRecurringLimit + let mut sol_rec = Vec::new(); + sol_rec.extend_from_slice(&1_000_000u64.to_le_bytes()); + sol_rec.extend_from_slice(&0u64.to_le_bytes()); + sol_rec.extend_from_slice(&216_000u64.to_le_bytes()); + sol_rec.extend_from_slice(&0u64.to_le_bytes()); + buf.extend_from_slice(&build_action(2, 0, &sol_rec)); + // ProgramWhitelist + buf.extend_from_slice(&build_action(10, 0, &[0xBB; 32])); + // SolMaxPerTx + buf.extend_from_slice(&build_action(3, 0, &500_000u64.to_le_bytes())); + assert!(validate_actions_buffer(&buf).is_ok()); + } + + #[test] + fn test_truncated_header() { + let buf = vec![1u8, 0]; // Only 2 bytes, need 11 + assert!(parse_actions(&buf).is_err()); + } + + #[test] + fn test_truncated_data() { + let mut buf = Vec::new(); + buf.push(1); // type + buf.extend_from_slice(&8u16.to_le_bytes()); // data_len = 8 + buf.extend_from_slice(&0u64.to_le_bytes()); // expires_at + buf.extend_from_slice(&[0u8; 4]); // only 4 bytes of data (need 8) + assert!(parse_actions(&buf).is_err()); + } + + #[test] + fn test_token_max_per_tx() { + let mut data = Vec::new(); + data.extend_from_slice(&[0xCC; 32]); // mint + data.extend_from_slice(&1_000_000u64.to_le_bytes()); // max + let buf = build_action(6, 0, &data); + let actions = parse_actions(&buf).unwrap(); + assert_eq!(actions[0].action_type, ActionType::TokenMaxPerTx); + assert!(validate_actions_buffer(&buf).is_ok()); + } + + #[test] + fn test_read_write_u64() { + let mut data = [0u8; 16]; + write_u64(&mut data, 0, 12345); + write_u64(&mut data, 8, 67890); + assert_eq!(read_u64(&data, 0), 12345); + assert_eq!(read_u64(&data, 8), 67890); + } + + // ─── Security: Duplicate token mint validation (Audit Finding 6) ── + + #[test] + fn test_validate_duplicate_token_limit_same_mint() { + let mint = [0xAA; 32]; + let mut data1 = Vec::new(); + data1.extend_from_slice(&mint); + data1.extend_from_slice(&1_000_000u64.to_le_bytes()); + + let mut data2 = Vec::new(); + data2.extend_from_slice(&mint); // same mint! + data2.extend_from_slice(&2_000_000u64.to_le_bytes()); + + let mut buf = build_action(4, 0, &data1); // TokenLimit + buf.extend_from_slice(&build_action(4, 0, &data2)); // TokenLimit same mint + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_duplicate_token_recurring_same_mint() { + let mint = [0xBB; 32]; + let mut make_data = || { + let mut data = Vec::new(); + data.extend_from_slice(&mint); + data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit + data.extend_from_slice(&0u64.to_le_bytes()); // spent + data.extend_from_slice(&100u64.to_le_bytes()); // window + data.extend_from_slice(&0u64.to_le_bytes()); // last_reset + data + }; + + let mut buf = build_action(5, 0, &make_data()); // TokenRecurringLimit + buf.extend_from_slice(&build_action(5, 0, &make_data())); // same mint + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_different_token_mints_ok() { + let mut data1 = Vec::new(); + data1.extend_from_slice(&[0xAA; 32]); // mint A + data1.extend_from_slice(&1_000_000u64.to_le_bytes()); + + let mut data2 = Vec::new(); + data2.extend_from_slice(&[0xBB; 32]); // mint B (different!) + data2.extend_from_slice(&2_000_000u64.to_le_bytes()); + + let mut buf = build_action(4, 0, &data1); + buf.extend_from_slice(&build_action(4, 0, &data2)); + assert!(validate_actions_buffer(&buf).is_ok()); + } + + #[test] + fn test_validate_same_mint_different_action_types_ok() { + // Same mint in TokenLimit and TokenMaxPerTx is OK (different action types) + let mint = [0xCC; 32]; + let mut data_limit = Vec::new(); + data_limit.extend_from_slice(&mint); + data_limit.extend_from_slice(&1_000_000u64.to_le_bytes()); + + let mut data_max = Vec::new(); + data_max.extend_from_slice(&mint); + data_max.extend_from_slice(&500_000u64.to_le_bytes()); + + let mut buf = build_action(4, 0, &data_limit); // TokenLimit + buf.extend_from_slice(&build_action(6, 0, &data_max)); // TokenMaxPerTx + assert!(validate_actions_buffer(&buf).is_ok()); + } + + // ─── Security: Duplicate SOL actions ────────────────────────────── + + #[test] + fn test_validate_duplicate_sol_recurring_limit() { + let make_data = || { + let mut data = Vec::new(); + data.extend_from_slice(&1_000_000u64.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + data.extend_from_slice(&100u64.to_le_bytes()); + data.extend_from_slice(&0u64.to_le_bytes()); + data + }; + let mut buf = build_action(2, 0, &make_data()); + buf.extend_from_slice(&build_action(2, 0, &make_data())); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_duplicate_sol_max_per_tx() { + let mut buf = build_action(3, 0, &500_000u64.to_le_bytes()); + buf.extend_from_slice(&build_action(3, 0, &300_000u64.to_le_bytes())); + assert!(validate_actions_buffer(&buf).is_err()); + } + + // ─── Security: Token recurring limit initial state ──────────────── + + #[test] + fn test_validate_token_recurring_nonzero_spent() { + let mut data = Vec::new(); + data.extend_from_slice(&[0xAA; 32]); // mint + data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit + data.extend_from_slice(&100u64.to_le_bytes()); // spent (should be 0!) + data.extend_from_slice(&100u64.to_le_bytes()); // window + data.extend_from_slice(&0u64.to_le_bytes()); // last_reset + let buf = build_action(5, 0, &data); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_token_recurring_zero_window() { + let mut data = Vec::new(); + data.extend_from_slice(&[0xAA; 32]); // mint + data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit + data.extend_from_slice(&0u64.to_le_bytes()); // spent + data.extend_from_slice(&0u64.to_le_bytes()); // window = 0! + data.extend_from_slice(&0u64.to_le_bytes()); // last_reset + let buf = build_action(5, 0, &data); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_token_recurring_nonzero_last_reset() { + let mut data = Vec::new(); + data.extend_from_slice(&[0xAA; 32]); // mint + data.extend_from_slice(&1_000_000u64.to_le_bytes()); // limit + data.extend_from_slice(&0u64.to_le_bytes()); // spent + data.extend_from_slice(&100u64.to_le_bytes()); // window + data.extend_from_slice(&50u64.to_le_bytes()); // last_reset != 0! + let buf = build_action(5, 0, &data); + assert!(validate_actions_buffer(&buf).is_err()); + } + + // ─── Security: Action type boundary values ──────────────────────── + + #[test] + fn test_validate_action_type_7_rejected() { + let buf = build_action(7, 0, &[0u8; 8]); // gap value + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_action_type_8_rejected() { + let buf = build_action(8, 0, &[0u8; 8]); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_action_type_9_rejected() { + let buf = build_action(9, 0, &[0u8; 8]); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_action_type_12_rejected() { + let buf = build_action(12, 0, &[0u8; 32]); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_action_type_255_rejected() { + let buf = build_action(255, 0, &[0u8; 8]); + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_action_type_0_rejected() { + let buf = build_action(0, 0, &[0u8; 8]); + assert!(validate_actions_buffer(&buf).is_err()); + } + + // ─── Security: MAX_ACTIONS limit ────────────────────────────────── + + #[test] + fn test_validate_max_actions_limit() { + let mut buf = Vec::new(); + for i in 0..=MAX_ACTIONS { + let mut prog = [0u8; 32]; + prog[0] = i as u8; + buf.extend_from_slice(&build_action(10, 0, &prog)); // ProgramWhitelist + } + // 17 actions should fail (MAX_ACTIONS = 16) + assert!(validate_actions_buffer(&buf).is_err()); + } + + #[test] + fn test_validate_exactly_max_actions_ok() { + let mut buf = Vec::new(); + for i in 0..MAX_ACTIONS { + let mut prog = [0u8; 32]; + prog[0] = i as u8; + buf.extend_from_slice(&build_action(10, 0, &prog)); + } + // 16 actions should be fine + assert!(validate_actions_buffer(&buf).is_ok()); + } + + // ─── Security: Trailing bytes ───────────────────────────────────── + + #[test] + fn test_validate_trailing_bytes_rejected() { + let mut buf = build_action(3, 0, &500_000u64.to_le_bytes()); + buf.push(0xFF); // trailing garbage byte + // parse_actions should fail because the trailing byte doesn't form a valid header + assert!(validate_actions_buffer(&buf).is_err()); + } +} diff --git a/program/src/state/mod.rs b/program/src/state/mod.rs index 22802b8..55ece4a 100644 --- a/program/src/state/mod.rs +++ b/program/src/state/mod.rs @@ -1,3 +1,4 @@ +pub mod action; pub mod authority; pub mod deferred; pub mod session; From 3ff167fa4a6c65e50c3f05a6daf41cfd06ebf352 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Thu, 30 Apr 2026 17:42:43 +0700 Subject: [PATCH 02/11] feat(session): support optional action permission buffer at session creation Port from lazorkit-protocol the variable-size SessionAccount and the ParsedCreateSessionArgs flow that lets a CreateSession instruction carry an optional 8-action permission buffer. state/session.rs is now byte-identical to upstream: - exposes SESSION_HEADER_SIZE (80) constant - adds has_actions() and actions_slice() helpers - documents that optional actions follow the fixed header processor/create_session.rs: - replaces CreateSessionArgs with ParsedCreateSessionArgs that parses [session_key(32)][expires_at(8)][actions_len(2)?][actions(N)?] - caps actions_len at 2048 to prevent BPF heap exhaustion - runs validate_actions_buffer() at creation time - session PDA size is now SESSION_HEADER_SIZE + actions_bytes.len() - ed25519 + secp256r1 signed payloads include actions_bytes so the permission set is bound to the signature Wire-up only: actions are stored on-chain but not yet enforced at Execute time. P1c will port execute/actions.rs to add enforcement. Backwards compatibility note: clients still sending exactly 40 bytes (no actions_len header) continue to work via the legacy parser branch, matching upstream behavior. --- program/src/processor/create_session.rs | 278 ++++++++++++++++++++---- program/src/state/session.rs | 23 +- 2 files changed, 254 insertions(+), 47 deletions(-) diff --git a/program/src/processor/create_session.rs b/program/src/processor/create_session.rs index ed378cb..175b06b 100644 --- a/program/src/processor/create_session.rs +++ b/program/src/processor/create_session.rs @@ -14,14 +14,24 @@ use crate::{ ed25519::Ed25519Authenticator, secp256r1::Secp256r1Authenticator, traits::Authenticator, }, error::AuthError, - state::{authority::AuthorityAccountHeader, session::SessionAccount, AccountDiscriminator}, + state::{ + action::validate_actions_buffer, + authority::AuthorityAccountHeader, + session::{SessionAccount, SESSION_HEADER_SIZE}, + AccountDiscriminator, + }, }; /// Arguments for the `CreateSession` instruction. /// /// Layout: -/// - `session_key`: The public key of the ephemeral session signer. -/// - `expires_at`: The absolute slot height when this session expires. +/// - `session_key`: The public key of the ephemeral session signer (32 bytes). +/// - `expires_at`: The absolute slot height when this session expires (8 bytes). +/// - `actions_len`: Length of the actions buffer in bytes (2 bytes, u16 LE). 0 = no actions. +/// - `actions`: Raw actions buffer (variable, `actions_len` bytes). +/// +/// Total fixed: 42 bytes minimum. Backwards compatible: old clients sending 40 bytes +/// will have actions_len=0 (no actions). #[repr(C, align(8))] #[derive(NoPadding)] pub struct CreateSessionArgs { @@ -29,23 +39,75 @@ pub struct CreateSessionArgs { pub expires_at: u64, } -impl CreateSessionArgs { +/// Parsed session creation arguments including optional actions. +pub struct ParsedCreateSessionArgs { + pub session_key: [u8; 32], + pub expires_at: u64, + /// Raw actions buffer bytes (empty if no actions). + pub actions_bytes: Vec, + /// Byte offset where the actions section ends in instruction_data. + /// Everything after this is auth_payload for Secp256r1. + pub args_end_offset: usize, +} + +impl ParsedCreateSessionArgs { pub fn from_bytes(data: &[u8]) -> Result { if data.len() < 40 { return Err(ProgramError::InvalidInstructionData); } - // args are: [session_key(32)][expires_at(8)] - let (key_bytes, rest) = data.split_at(32); - let (alloc_bytes, _) = rest.split_at(8); let mut session_key = [0u8; 32]; - session_key.copy_from_slice(key_bytes); - - let expires_at = u64::from_le_bytes(alloc_bytes.try_into().unwrap()); + session_key.copy_from_slice(&data[..32]); + let expires_at = u64::from_le_bytes(data[32..40].try_into().unwrap()); + + // Check for actions buffer + if data.len() >= 42 { + let actions_len = u16::from_le_bytes(data[40..42].try_into().unwrap()) as usize; + + // Cap actions buffer size to prevent BPF heap exhaustion. + // 16 actions * max ~128 bytes each = 2048 is generous. + // The BPF heap is 32KB; allocating 64KB (u16 max) would OOM. + const MAX_ACTIONS_BUFFER_SIZE: usize = 2048; + if actions_len > MAX_ACTIONS_BUFFER_SIZE { + return Err(ProgramError::InvalidInstructionData); + } + + if actions_len > 0 { + let actions_start = 42; + let actions_end = actions_start + actions_len; + + if data.len() < actions_end { + return Err(ProgramError::InvalidInstructionData); + } + + let actions_bytes = data[actions_start..actions_end].to_vec(); + + // Validate actions buffer at creation time + validate_actions_buffer(&actions_bytes)?; + + return Ok(Self { + session_key, + expires_at, + actions_bytes, + args_end_offset: actions_end, + }); + } + + // actions_len == 0 + return Ok(Self { + session_key, + expires_at, + actions_bytes: Vec::new(), + args_end_offset: 42, + }); + } + // Legacy format: exactly 40 bytes, no actions Ok(Self { session_key, expires_at, + actions_bytes: Vec::new(), + args_end_offset: 40, }) } } @@ -53,11 +115,13 @@ impl CreateSessionArgs { /// Processes the `CreateSession` instruction. /// /// Creates a temporary `Session` account that facilitates limited-scope execution (Spender role). +/// Optional actions (permissions) can be attached to restrict what the session can do. /// /// # Logic: /// 1. Verifies the authorizing authority (must be Owner or Admin). -/// 2. Derives a fresh Session PDA from `["session", wallet, session_key]`. -/// 3. Allocates and initializes the Session account with expiry. +/// 2. Validates optional actions buffer. +/// 3. Derives a fresh Session PDA from `["session", wallet, session_key]`. +/// 4. Allocates and initializes the Session account with expiry and actions. /// /// # Accounts: /// 1. `[signer, writable]` Payer: Pays for rent. @@ -71,7 +135,7 @@ pub fn process( accounts: &[AccountInfo], instruction_data: &[u8], ) -> ProgramResult { - let args = CreateSessionArgs::from_bytes(instruction_data)?; + let args = ParsedCreateSessionArgs::from_bytes(instruction_data)?; let account_info_iter = &mut accounts.iter(); let payer = account_info_iter @@ -115,9 +179,6 @@ pub fn process( return Err(ProgramError::InvalidAccountData); } - // Verify Authorizer - // Check removed: conditional writable check inside match - let auth_data = unsafe { authorizer_pda.borrow_mut_data_unchecked() }; // Safe Copy of Header using read_unaligned @@ -154,35 +215,36 @@ pub fn process( } // Authenticate Authorizer - - // We assume CreateSession instruction data AFTER the args is payload for Secp256r1 if any - let payload_offset = std::mem::size_of::(); - let authority_payload = if instruction_data.len() > payload_offset { - &instruction_data[payload_offset..] + // instruction_data layout: [args(40)][actions_len(2)][actions(N)][auth_payload...] + // args.args_end_offset points to the end of the args+actions section. + let data_payload = &instruction_data[..args.args_end_offset]; + let authority_payload = if instruction_data.len() > args.args_end_offset { + &instruction_data[args.args_end_offset..] } else { &[] }; - // But wait, `CreateSessionArgs` consumes 40 bytes. - // `instruction_data` passed here is whatever follows the discriminator. - // `Execute` passes compact instructions. - // Here we pass args. - - // For Secp256r1, we need to distinguish args from auth payload. - // The instruction format is [discriminator][args][payload]. - // `instruction_data` here is [args][payload]. - let data_payload = &instruction_data[..payload_offset]; - - // Include payer in signed payload to prevent payer swap - let mut ed25519_payload = Vec::with_capacity(64); + // Ed25519 signed payload — includes payer + session_key + actions. + // Note: Ed25519Authenticator only checks that the authority keypair is a tx signer, + // so this payload is not cryptographically verified. The protection is that only the + // keypair holder can sign the transaction. For Secp256r1, the data_payload IS verified. + let mut ed25519_payload = Vec::with_capacity(64 + args.actions_bytes.len()); ed25519_payload.extend_from_slice(payer.key().as_ref()); ed25519_payload.extend_from_slice(&args.session_key); + ed25519_payload.extend_from_slice(&args.actions_bytes); match auth_header.authority_type { 0 => { - // Ed25519: Include payer + session_key in signed payload - Ed25519Authenticator.authenticate(accounts, auth_data, &[], &ed25519_payload, &[5], program_id)?; - }, + // Ed25519: Include payer + session_key + actions in signed payload + Ed25519Authenticator.authenticate( + accounts, + auth_data, + &[], + &ed25519_payload, + &[5], + program_id, + )?; + } 1 => { // Secp256r1: Include payer in data_payload let mut extended_data_payload = Vec::with_capacity(data_payload.len() + 32); @@ -197,7 +259,7 @@ pub fn process( &[5], program_id, )?; - }, + } _ => return Err(AuthError::InvalidAuthenticationKind.into()), } @@ -211,8 +273,8 @@ pub fn process( } check_zero_data(session_pda, ProgramError::AccountAlreadyInitialized)?; - // Create Session Account - let space = std::mem::size_of::(); + // Create Session Account — variable size if actions are present + let space = SESSION_HEADER_SIZE + args.actions_bytes.len(); let session_rent = rent.minimum_balance(space); let bump_arr = [bump]; @@ -245,14 +307,20 @@ pub fn process( expires_at: args.expires_at, }; - // Safe write + // Write fixed header let session_bytes = unsafe { std::slice::from_raw_parts( &session as *const SessionAccount as *const u8, std::mem::size_of::(), ) }; - data[0..std::mem::size_of::()].copy_from_slice(session_bytes); + data[..SESSION_HEADER_SIZE].copy_from_slice(session_bytes); + + // Write actions buffer (if any) + if !args.actions_bytes.is_empty() { + data[SESSION_HEADER_SIZE..SESSION_HEADER_SIZE + args.actions_bytes.len()] + .copy_from_slice(&args.actions_bytes); + } Ok(()) } @@ -264,20 +332,140 @@ mod tests { #[test] fn test_create_session_args_from_bytes() { let mut data = Vec::new(); - // session_key(32) + expires_at(8) let session_key = [7u8; 32]; let expires_at = 12345678u64; data.extend_from_slice(&session_key); data.extend_from_slice(&expires_at.to_le_bytes()); - let args = CreateSessionArgs::from_bytes(&data).unwrap(); + let args = ParsedCreateSessionArgs::from_bytes(&data).unwrap(); assert_eq!(args.session_key, session_key); assert_eq!(args.expires_at, expires_at); + assert!(args.actions_bytes.is_empty()); + assert_eq!(args.args_end_offset, 40); } #[test] fn test_create_session_args_too_short() { - let data = vec![0u8; 39]; // Need 40 - assert!(CreateSessionArgs::from_bytes(&data).is_err()); + let data = vec![0u8; 39]; + assert!(ParsedCreateSessionArgs::from_bytes(&data).is_err()); + } + + #[test] + fn test_create_session_args_with_no_actions() { + let mut data = Vec::new(); + data.extend_from_slice(&[7u8; 32]); // session_key + data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at + data.extend_from_slice(&0u16.to_le_bytes()); // actions_len = 0 + + let args = ParsedCreateSessionArgs::from_bytes(&data).unwrap(); + assert!(args.actions_bytes.is_empty()); + assert_eq!(args.args_end_offset, 42); + } + + #[test] + fn test_create_session_args_with_actions() { + let mut data = Vec::new(); + data.extend_from_slice(&[7u8; 32]); // session_key + data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at + + // Build a SolMaxPerTx action: header(11) + data(8) = 19 bytes + let mut actions = Vec::new(); + actions.push(3u8); // type = SolMaxPerTx + actions.extend_from_slice(&8u16.to_le_bytes()); // data_len + actions.extend_from_slice(&0u64.to_le_bytes()); // expires_at + actions.extend_from_slice(&500_000u64.to_le_bytes()); // max + + data.extend_from_slice(&(actions.len() as u16).to_le_bytes()); // actions_len + data.extend_from_slice(&actions); + + let args = ParsedCreateSessionArgs::from_bytes(&data).unwrap(); + assert_eq!(args.actions_bytes.len(), 19); + assert_eq!(args.args_end_offset, 42 + 19); + } + + #[test] + fn test_create_session_args_with_invalid_actions() { + let mut data = Vec::new(); + data.extend_from_slice(&[7u8; 32]); // session_key + data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at + + // Invalid action type + let mut actions = Vec::new(); + actions.push(99u8); // bad type + actions.extend_from_slice(&8u16.to_le_bytes()); + actions.extend_from_slice(&0u64.to_le_bytes()); + actions.extend_from_slice(&500_000u64.to_le_bytes()); + + data.extend_from_slice(&(actions.len() as u16).to_le_bytes()); + data.extend_from_slice(&actions); + + assert!(ParsedCreateSessionArgs::from_bytes(&data).is_err()); + } + + #[test] + fn test_create_session_args_with_trailing_auth_payload() { + let mut data = Vec::new(); + data.extend_from_slice(&[7u8; 32]); // session_key + data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at + + // SolMaxPerTx action + let mut actions = Vec::new(); + actions.push(3u8); + actions.extend_from_slice(&8u16.to_le_bytes()); + actions.extend_from_slice(&0u64.to_le_bytes()); + actions.extend_from_slice(&500_000u64.to_le_bytes()); + + data.extend_from_slice(&(actions.len() as u16).to_le_bytes()); + data.extend_from_slice(&actions); + + // Simulate trailing auth payload + data.extend_from_slice(&[0xAA; 50]); + + let args = ParsedCreateSessionArgs::from_bytes(&data).unwrap(); + assert_eq!(args.actions_bytes.len(), 19); + assert_eq!(args.args_end_offset, 42 + 19); + // Trailing 50 bytes would be auth_payload — not parsed here + } + + #[test] + fn test_actions_len_exceeds_cap_rejected() { + let mut data = Vec::new(); + data.extend_from_slice(&[7u8; 32]); // session_key + data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at + + // actions_len = 3000 > MAX_ACTIONS_BUFFER_SIZE (2048) + data.extend_from_slice(&3000u16.to_le_bytes()); + // Pad enough bytes so the length check doesn't fail first + data.extend_from_slice(&vec![0u8; 3000]); + + let result = ParsedCreateSessionArgs::from_bytes(&data); + assert!(result.is_err()); + } + + #[test] + fn test_actions_len_at_cap_allowed() { + let mut data = Vec::new(); + data.extend_from_slice(&[7u8; 32]); // session_key + data.extend_from_slice(&12345678u64.to_le_bytes()); // expires_at + + // Build a valid action buffer that's under 2048 + // 16 ProgramWhitelist actions = 16 * (11 header + 32 data) = 16 * 43 = 688 bytes + let mut actions = Vec::new(); + for i in 0..16u8 { + let mut prog = [0u8; 32]; + prog[0] = i; + actions.push(10u8); // ProgramWhitelist type + actions.extend_from_slice(&32u16.to_le_bytes()); // data_len + actions.extend_from_slice(&0u64.to_le_bytes()); // expires_at + actions.extend_from_slice(&prog); + } + assert!(actions.len() <= 2048); + + data.extend_from_slice(&(actions.len() as u16).to_le_bytes()); + data.extend_from_slice(&actions); + + let result = ParsedCreateSessionArgs::from_bytes(&data); + assert!(result.is_ok()); + assert_eq!(result.unwrap().actions_bytes.len(), actions.len()); } } diff --git a/program/src/state/session.rs b/program/src/state/session.rs index 4593bdf..655dbdb 100644 --- a/program/src/state/session.rs +++ b/program/src/state/session.rs @@ -1,13 +1,15 @@ use no_padding::NoPadding; use pinocchio::pubkey::Pubkey; +/// Size of the fixed session header (excluding actions). +pub const SESSION_HEADER_SIZE: usize = 80; + #[repr(C, align(8))] #[derive(NoPadding)] /// Ephemeral Session Account. /// /// Represents a temporary delegated authority with an expiration time. -// Removed duplicate attribute -#[derive(NoPadding)] +/// Optional actions may follow the 80-byte header as a flat byte buffer. pub struct SessionAccount { /// Account discriminator (must be `3` for Session). pub discriminator: u8, // 1 @@ -24,3 +26,20 @@ pub struct SessionAccount { /// Absolute slot height when this session expires. pub expires_at: u64, // 8 } + +/// Returns true if the session account data contains actions after the header. +#[inline] +pub fn has_actions(session_data: &[u8]) -> bool { + session_data.len() > SESSION_HEADER_SIZE +} + +/// Returns the actions buffer slice (bytes after the 80-byte header). +/// Returns empty slice if no actions. +#[inline] +pub fn actions_slice(session_data: &[u8]) -> &[u8] { + if session_data.len() > SESSION_HEADER_SIZE { + &session_data[SESSION_HEADER_SIZE..] + } else { + &[] + } +} From a3930f2121aaa04676d531ee5cd7e189d0239143 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Thu, 30 Apr 2026 17:50:40 +0700 Subject: [PATCH 03/11] chore(compact): port zero-copy ref-based instruction parser from upstream Adds CompactInstructionRef::{from_bytes, decompress}, DecompressedInstructionRef, and parse_compact_instructions_ref_with_len from lazorkit-protocol. Lets the Execute hot path parse and decompress without per-instruction Vec allocs for account-index bytes or instruction data. Existing parse_compact_instructions / serialize_compact_instructions remain for callers (execute_deferred) that still need owned copies. File is now byte-identical to upstream compact.rs for audit comparability. --- program/src/compact.rs | 152 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 145 insertions(+), 7 deletions(-) diff --git a/program/src/compact.rs b/program/src/compact.rs index fd4db2f..4b2cca9 100644 --- a/program/src/compact.rs +++ b/program/src/compact.rs @@ -26,6 +26,8 @@ pub struct CompactInstruction { } /// Reference version of CompactInstruction that borrows its data. +/// Used by the Execute hot path to avoid Vec allocations during parse +/// + decompress. /// /// # Fields /// * `program_id_index` - Index of the program ID in the account list @@ -37,6 +39,108 @@ pub struct CompactInstructionRef<'a> { pub data: &'a [u8], } +impl<'a> CompactInstructionRef<'a> { + /// Deserialize a CompactInstructionRef from bytes — zero-copy, the + /// returned struct borrows from `bytes`. + /// Format: [program_id_index: u8][num_accounts: u8][accounts...][data_len: u16][data...] + pub fn from_bytes(bytes: &'a [u8]) -> Result<(Self, &'a [u8]), ProgramError> { + if bytes.len() < 4 { + return Err(ProgramError::InvalidInstructionData); + } + + let program_id_index = bytes[0]; + let num_accounts = bytes[1] as usize; + + if bytes.len() < 2 + num_accounts + 2 { + return Err(ProgramError::InvalidInstructionData); + } + + let accounts = &bytes[2..2 + num_accounts]; + let data_len_offset = 2 + num_accounts; + let data_len = + u16::from_le_bytes([bytes[data_len_offset], bytes[data_len_offset + 1]]) as usize; + + let data_start = data_len_offset + 2; + if bytes.len() < data_start + data_len { + return Err(ProgramError::InvalidInstructionData); + } + + let data = &bytes[data_start..data_start + data_len]; + let rest = &bytes[data_start + data_len..]; + + Ok(( + CompactInstructionRef { + program_id_index, + accounts, + data, + }, + rest, + )) + } + + /// Decompress into a full Instruction without cloning the instruction + /// data. `account_infos` lifetime 'b is tracked separately from the + /// instruction-data lifetime 'a. + pub fn decompress<'b>( + &self, + account_infos: &'b [AccountInfo], + ) -> Result, ProgramError> { + if (self.program_id_index as usize) >= account_infos.len() { + return Err(ProgramError::InvalidInstructionData); + } + let program_id = account_infos[self.program_id_index as usize].key(); + + let mut accounts: Vec<&AccountInfo> = Vec::with_capacity(self.accounts.len()); + for &index in self.accounts { + if (index as usize) >= account_infos.len() { + return Err(ProgramError::InvalidInstructionData); + } + accounts.push(&account_infos[index as usize]); + } + + Ok(DecompressedInstructionRef { + program_id, + accounts, + data: self.data, + }) + } +} + +/// Zero-copy variant of DecompressedInstruction. `data` borrows from the +/// original instruction_data — no clone. +pub struct DecompressedInstructionRef<'a, 'b> { + pub program_id: &'b Pubkey, + pub accounts: Vec<&'b AccountInfo>, + pub data: &'a [u8], +} + +/// Parse + return total bytes consumed (ref-based, no allocations for +/// account index bytes or instruction data). +pub fn parse_compact_instructions_ref_with_len<'a>( + bytes: &'a [u8], +) -> Result<(Vec>, usize), ProgramError> { + if bytes.is_empty() { + return Err(ProgramError::InvalidInstructionData); + } + + let num_instructions = bytes[0] as usize; + if num_instructions > MAX_COMPACT_INSTRUCTIONS { + return Err(ProgramError::InvalidInstructionData); + } + + let mut instructions = Vec::with_capacity(num_instructions); + let mut remaining = &bytes[1..]; + + for _ in 0..num_instructions { + let (ix, rest) = CompactInstructionRef::from_bytes(remaining)?; + instructions.push(ix); + remaining = rest; + } + + let consumed = bytes.len() - remaining.len(); + Ok((instructions, consumed)) +} + impl CompactInstructions { /// Serializes the compact instructions into bytes. /// @@ -52,8 +156,12 @@ impl CompactInstructions { /// # Returns /// * `Vec` - Serialized instruction data pub fn into_bytes(&self) -> Vec { + // Lengths are encoded as u8 — values > 255 would silently truncate and corrupt + // the instruction stream on deserialization. Enforce at runtime, not just debug. + assert!(self.inner_instructions.len() <= 255, "instruction count exceeds u8 max"); let mut bytes = vec![self.inner_instructions.len() as u8]; for ix in self.inner_instructions.iter() { + assert!(ix.accounts.len() <= 255, "account count exceeds u8 max"); bytes.push(ix.program_id_index); bytes.push(ix.accounts.len() as u8); bytes.extend(ix.accounts.iter()); @@ -105,6 +213,7 @@ impl CompactInstruction { /// Serialize this CompactInstruction to bytes pub fn to_bytes(&self) -> Vec { + assert!(self.accounts.len() <= 255, "account count exceeds u8 max"); let mut bytes = Vec::with_capacity(4 + self.accounts.len() + self.data.len()); bytes.push(self.program_id_index); bytes.push(self.accounts.len() as u8); @@ -150,14 +259,31 @@ pub struct DecompressedInstruction<'a> { pub data: Vec, // Owned data to avoid lifetime issues } +/// Maximum number of compact instructions per Execute call. +/// Prevents compute-unit exhaustion DoS. +pub const MAX_COMPACT_INSTRUCTIONS: usize = 16; + /// Parse multiple CompactInstructions from bytes /// Format: [num_instructions: u8][instruction_0][instruction_1]... pub fn parse_compact_instructions(bytes: &[u8]) -> Result, ProgramError> { + parse_compact_instructions_with_len(bytes).map(|(ixs, _)| ixs) +} + +/// Parse + return total bytes consumed. Used by the Execute processor to +/// split the instruction data into the compact-instructions prefix and the +/// auth payload suffix without re-serializing. +pub fn parse_compact_instructions_with_len( + bytes: &[u8], +) -> Result<(Vec, usize), ProgramError> { if bytes.is_empty() { return Err(ProgramError::InvalidInstructionData); } let num_instructions = bytes[0] as usize; + if num_instructions > MAX_COMPACT_INSTRUCTIONS { + return Err(ProgramError::InvalidInstructionData); + } + let mut instructions = Vec::with_capacity(num_instructions); let mut remaining = &bytes[1..]; @@ -167,7 +293,8 @@ pub fn parse_compact_instructions(bytes: &[u8]) -> Result = (0..=255).collect(); + // Test with 255 accounts (the valid u8 max) + let accounts: Vec = (0..255).collect(); let ix = CompactInstruction { program_id_index: 0, - accounts, + accounts: accounts.clone(), data: vec![1], }; let bytes = ix.to_bytes(); let (deserialized, _) = CompactInstruction::from_bytes(&bytes).unwrap(); + assert_eq!(deserialized.accounts.len(), 255); + } - // Note: accounts.len() wraps to 0 when cast to u8! - // This is a known limitation - can't have exactly 256 accounts - assert_eq!(deserialized.accounts.len(), 0); // Wraps around! + #[test] + #[should_panic(expected = "account count exceeds u8 max")] + fn test_256_accounts_panics() { + // 256 accounts would silently truncate to 0 via `as u8`. + // The assert! guard must catch this. + let accounts: Vec = (0..=255).collect(); // 256 elements + let ix = CompactInstruction { + program_id_index: 0, + accounts, + data: vec![1], + }; + let _ = ix.to_bytes(); // should panic } #[test] From 644c5d6d60c8a150a122d11cfbeaff25a818f9a4 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Thu, 30 Apr 2026 17:51:00 +0700 Subject: [PATCH 04/11] feat(execute): enforce session action permissions at execute time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the 8 session action types (ported in earlier commits) into the Execute instruction. Sessions with attached actions now have spending limits, recurring caps, per-tx maxes, and program whitelist/blacklist rules enforced around the CPI loop. Files: - processor/execute_actions.rs (NEW, 1644 lines, byte-identical to upstream processor/execute/actions.rs): the enforcement engine — pre-CPI program whitelist/blacklist checks, token-balance + token-authority snapshots, post-CPI delta computation, SOL/token cap enforcement with saturating arithmetic, recurring window resets aligned to slot boundaries. - processor/execute.rs (REPLACED with upstream processor/execute/immediate.rs): integrates pre/post action evaluation around the CPI loop, snapshots vault metadata + token authorities for invariant checks, tracks gross SOL outflow per CPI for SolMaxPerTx. Adds L5 anti-CPI guard for session-authenticated Execute (stack height must be 1). - error.rs: adds SessionVaultOwnerChanged (3030), SessionVaultDataLenChanged (3031), SessionTokenAuthorityChanged (3032) for the vault-invariant defenses against System::Assign and SetAuthority escapes. - processor/mod.rs: wires execute_actions module. Verification: - 111 unit tests pass (action validator + execute_actions helpers) - 18 litesvm integration tests pass (wallet_lifecycle, sessions, etc.) - Existing devnet program ID FLb7… still works for backward compatibility Note on file structure: upstream organizes execute as a subdir (processor/execute/{actions,immediate,...}.rs); program-v2 keeps the flat layout for now, putting the helpers in processor/execute_actions.rs and the immediate-execute logic in processor/execute.rs. Future P2 may restructure to subdir for closer upstream alignment. --- program/src/error.rs | 4 + program/src/processor/execute.rs | 272 ++-- program/src/processor/execute_actions.rs | 1644 ++++++++++++++++++++++ program/src/processor/mod.rs | 1 + 4 files changed, 1834 insertions(+), 87 deletions(-) create mode 100644 program/src/processor/execute_actions.rs diff --git a/program/src/error.rs b/program/src/error.rs index 3547dde..c3b483b 100644 --- a/program/src/error.rs +++ b/program/src/error.rs @@ -33,6 +33,10 @@ pub enum AuthError { ActionTokenRecurringLimitExceeded = 3027, ActionWhitelistBlacklistConflict = 3028, ActionTokenMaxPerTxExceeded = 3029, + // Session vault + token invariants (defense against System::Assign / SetAuthority escapes) + SessionVaultOwnerChanged = 3030, + SessionVaultDataLenChanged = 3031, + SessionTokenAuthorityChanged = 3032, } impl From for ProgramError { diff --git a/program/src/processor/execute.rs b/program/src/processor/execute.rs index 32175e1..4d05e50 100644 --- a/program/src/processor/execute.rs +++ b/program/src/processor/execute.rs @@ -2,9 +2,14 @@ use crate::{ auth::{ ed25519::Ed25519Authenticator, secp256r1::Secp256r1Authenticator, traits::Authenticator, }, - compact::parse_compact_instructions, + compact::{parse_compact_instructions_ref_with_len, CompactInstructionRef}, error::AuthError, - state::{authority::AuthorityAccountHeader, AccountDiscriminator}, + processor::execute_actions::{ + evaluate_post_actions, evaluate_pre_actions, snapshot_token_authorities, + snapshot_token_balances, verify_token_authorities_unchanged, + }, + state::{authority::AuthorityAccountHeader, session::has_actions, AccountDiscriminator}, + utils::get_stack_height, }; use pinocchio::{ account_info::AccountInfo, @@ -16,14 +21,13 @@ use pinocchio::{ ProgramResult, }; -/// Process the Execute instruction -/// Processes the `Execute` instruction. +/// Process the Execute instruction. /// /// Executes a batch of condensed "Compact Instructions" on behalf of the wallet. /// /// # Logic: /// 1. **Authentication**: Verifies that the signer is a valid `Authority` or `Session` for this wallet. -/// 2. **Session Checks**: If authenticated via Session, enforces slot expiry. +/// 2. **Session Checks**: If authenticated via Session, enforces slot expiry and action permissions. /// 3. **Decompression**: Expands `CompactInstructions` (index-based references) into full Solana instructions. /// 4. **Execution**: Invokes the Instructions via CPI, signing with the Vault PDA. /// @@ -72,8 +76,6 @@ pub fn process( } // Read authority header - // Safe copy header - // Read authority data let authority_data = unsafe { authority_pda.borrow_mut_data_unchecked() }; // Authenticate based on discriminator @@ -83,12 +85,17 @@ pub fn process( return Err(ProgramError::InvalidAccountData); }; - // Parse compact instructions - let compact_instructions = parse_compact_instructions(instruction_data)?; + // Parse compact instructions and get their consumed byte length. The + // length is used to split `instruction_data` into the compact-instructions + // prefix (the data_payload bound into the Secp256r1 signature) and the + // auth payload suffix. Tracking the parse cursor avoids re-serializing + // just to measure length. + let (compact_instructions, compact_len) = + parse_compact_instructions_ref_with_len(instruction_data)?; - // Serialize compact instructions to get their byte length - let compact_bytes = crate::compact::serialize_compact_instructions(&compact_instructions); - let compact_len = compact_bytes.len(); + // Track whether this is a session-based execution and the current slot + let mut is_session = false; + let mut session_slot: u64 = 0; match discriminator { 2 => { @@ -96,7 +103,6 @@ pub fn process( if authority_data.len() < std::mem::size_of::() { return Err(ProgramError::InvalidAccountData); } - // Use read_unaligned to safely copy potentially unaligned data into a local struct let authority_header = unsafe { std::ptr::read_unaligned(authority_data.as_ptr() as *const AuthorityAccountHeader) }; @@ -110,21 +116,22 @@ pub fn process( } match authority_header.authority_type { 0 => { - // Ed25519: Verify signer (authority_payload ignored) - Ed25519Authenticator.authenticate(accounts, authority_data, &[], &[], &[4], program_id)?; - }, + // Ed25519 + Ed25519Authenticator.authenticate( + accounts, + authority_data, + &[], + &[], + &[4], + program_id, + )?; + } 1 => { // Secp256r1 (WebAuthn) - // Issue #11: Include accounts hash to prevent account reordering attacks - // signed_payload is compact_instructions bytes + accounts hash for Execute let data_payload = &instruction_data[..compact_len]; let authority_payload = &instruction_data[compact_len..]; - - // Compute hash of all account pubkeys referenced by compact instructions - // This binds the signature to the exact accounts, preventing reordering - let accounts_hash = compute_accounts_hash(accounts, &compact_instructions)?; - - // Extended payload: compact_instructions + accounts_hash + let accounts_hash = + compute_accounts_hash(accounts, &compact_instructions)?; let mut extended_payload = Vec::with_capacity(compact_len + 32); extended_payload.extend_from_slice(data_payload); extended_payload.extend_from_slice(&accounts_hash); @@ -137,21 +144,30 @@ pub fn process( &[4], program_id, )?; - }, + } _ => return Err(AuthError::InvalidAuthenticationKind.into()), } - }, + } 3 => { - // Session - let session_data = unsafe { authority_pda.borrow_mut_data_unchecked() }; - if session_data.len() < std::mem::size_of::() { + // Session — reuse the existing `authority_data` borrow; no re-borrow needed. + + // L5: anti-CPI guard, mirroring the Secp256r1 authenticator check. + // A session-authenticated Execute is only valid as a top-level instruction + // (stack_height == 1). Rejecting CPI entry prevents any future bugs where + // a wrapper program could chain through Execute with forged account context. + if get_stack_height() > 1 { + return Err(AuthError::PermissionDenied.into()); + } + + if authority_data.len() + < std::mem::size_of::() + { return Err(ProgramError::InvalidAccountData); } - // Use read_unaligned to safely load SessionAccount let session = unsafe { std::ptr::read_unaligned( - session_data.as_ptr() as *const crate::state::session::SessionAccount + authority_data.as_ptr() as *const crate::state::session::SessionAccount, ) }; @@ -179,7 +195,20 @@ pub fn process( if !signer_matched { return Err(ProgramError::MissingRequiredSignature); } - }, + + // Pre-CPI action checks (program whitelist/blacklist) + if has_actions(authority_data) { + evaluate_pre_actions( + authority_data, + &compact_instructions, + accounts, + current_slot, + )?; + } + + is_session = true; + session_slot = current_slot; + } _ => return Err(ProgramError::InvalidAccountData), } @@ -188,118 +217,187 @@ pub fn process( find_program_address(&[b"vault", wallet_pda.key().as_ref()], program_id); // Verify vault PDA. - // CRITICAL: Ensure we are signing with the correct Vault derived from this Wallet. if vault_pda.key() != &vault_key { return Err(ProgramError::InvalidSeeds); } + // Snapshot balances before CPI (for session action enforcement) + let vault_lamports_before = if is_session { vault_pda.lamports() } else { 0 }; + let token_snapshots_before = if is_session { + // Reuse the existing `authority_data` borrow — no additional borrow of authority_pda. + snapshot_token_balances(authority_data, accounts, vault_pda.key())? + } else { + Vec::new() + }; + + // ── Session invariants (defense against System::Assign / SetAuthority escapes) ── + // A session that whitelists System Program (a common pattern for SOL transfers) + // could otherwise craft `System::Assign(vault, attacker)` — the lamport-based + // limits see no outflow, but ownership of the vault silently transfers to the + // attacker, who then drains it in a follow-up tx. Same class of attack via + // SPL Token's `SetAuthority` / `Approve` on vault-owned token accounts. + // + // Snapshot the vault's metadata + every listed-mint vault-owned token account's + // authority fields BEFORE the CPI loop; verify unchanged AFTER. + let session_has_actions = is_session && has_actions(authority_data); + let vault_owner_before = if session_has_actions { + Some(*vault_pda.owner()) + } else { + None + }; + let vault_data_len_before = if session_has_actions { + Some(unsafe { vault_pda.borrow_data_unchecked().len() }) + } else { + None + }; + let token_authority_snapshots = if session_has_actions { + snapshot_token_authorities(authority_data, accounts, vault_pda.key())? + } else { + Vec::new() + }; + + // Track gross SOL outflow across all CPIs (for SolMaxPerTx check) + let mut vault_lamports_gross_out: u64 = 0; + let mut prev_vault_lamports = vault_lamports_before; + + // Reuse the same Vecs across all inner CPIs — allocated once, cleared + + // repushed each iteration. Saves 2 Vec::with_capacity allocations per + // inner instruction vs. .collect()ing fresh Vecs each time. + const MAX_INNER_ACCOUNTS: usize = 32; + let mut account_metas: Vec = Vec::with_capacity(MAX_INNER_ACCOUNTS); + let mut cpi_accounts: Vec = Vec::with_capacity(MAX_INNER_ACCOUNTS); + + // PDA signer seeds (constant across the loop) + let vault_bump_arr = [vault_bump]; + let seeds = [ + Seed::from(b"vault"), + Seed::from(wallet_pda.key().as_ref()), + Seed::from(&vault_bump_arr), + ]; + // Execute each compact instruction for compact_ix in &compact_instructions { let decompressed = compact_ix.decompress(accounts)?; - // Build AccountMeta array for instruction - let account_metas: Vec = decompressed - .accounts - .iter() - .map(|acc| AccountMeta { - pubkey: acc.key(), - is_signer: acc.is_signer() || acc.key() == vault_pda.key(), - is_writable: acc.is_writable(), - }) - .collect(); - // Prevent self-reentrancy (Issue #10) - // Reject CPI calls back into this program to avoid unexpected state mutations if decompressed.program_id.as_ref() == program_id.as_ref() { return Err(AuthError::SelfReentrancyNotAllowed.into()); } - // Create instruction + account_metas.clear(); + cpi_accounts.clear(); + for &acc in &decompressed.accounts { + account_metas.push(AccountMeta { + pubkey: acc.key(), + is_signer: acc.is_signer() || acc.key() == vault_pda.key(), + is_writable: acc.is_writable(), + }); + cpi_accounts.push(Account::from(acc)); + } + let ix = Instruction { program_id: decompressed.program_id, accounts: &account_metas, - data: &decompressed.data, + data: decompressed.data, }; - // Create seeds for vault signing (pinocchio style) - let vault_bump_arr = [vault_bump]; - let seeds = [ - Seed::from(b"vault"), - Seed::from(wallet_pda.key().as_ref()), - Seed::from(&vault_bump_arr), - ]; let signer: Signer = (&seeds).into(); - // Convert AccountInfo to Account for invoke_signed_unchecked - let cpi_accounts: Vec = decompressed - .accounts - .iter() - .map(|acc| Account::from(*acc)) - .collect(); - - // Invoke with vault as signer - // Use unchecked invocation to support dynamic account list (slice) unsafe { invoke_signed_unchecked(&ix, &cpi_accounts, &[signer]); } + + // Track gross SOL outflow per CPI (used for SolMaxPerTx — not net balance diff). + if is_session { + let post = vault_pda.lamports(); + if prev_vault_lamports > post { + vault_lamports_gross_out = vault_lamports_gross_out + .saturating_add(prev_vault_lamports - post); + } + prev_vault_lamports = post; + } + } + + // ── Post-CPI session invariants ──────────────────────────────────── + // Verify vault's ownership and data layout were not tampered with. Any + // change (System::Assign, Allocate, AllocateWithSeed, AssignWithSeed) is + // rejected. This complements the balance-based limits below. + if let Some(owner_before) = vault_owner_before { + if *vault_pda.owner() != owner_before { + return Err(AuthError::SessionVaultOwnerChanged.into()); + } + } + if let Some(len_before) = vault_data_len_before { + let len_after = unsafe { vault_pda.borrow_data_unchecked().len() }; + if len_after != len_before { + return Err(AuthError::SessionVaultDataLenChanged.into()); + } + } + // Verify no SetAuthority / Approve on listed-mint vault-owned token accounts. + verify_token_authorities_unchanged(&token_authority_snapshots, accounts)?; + + // Post-CPI action checks (spending limits) + // Reuse the existing `authority_data` borrow — no additional borrow of authority_pda. + if session_has_actions { + evaluate_post_actions( + authority_data, + accounts, + vault_pda.key(), + vault_lamports_before, + vault_pda.lamports(), + vault_lamports_gross_out, + &token_snapshots_before, + session_slot, + )?; } Ok(()) } -/// Compute SHA256 hash of all account pubkeys referenced by compact instructions (Issue #11) -/// -/// This binds the signature to the exact accounts in their exact order, -/// preventing account reordering attacks where an attacker could swap -/// recipient addresses while keeping the signature valid. -/// -/// # Arguments -/// * `accounts` - Slice of all account infos in the transaction -/// * `compact_instructions` - Parsed compact instructions containing account indices +/// Compute SHA256 hash of all account pubkeys referenced by compact instructions (Issue #11). /// -/// # Returns -/// * 32-byte SHA256 hash of all referenced pubkeys +/// Optimisation: pass each 32-byte pubkey as a separate slice to sol_sha256 +/// instead of concatenating them into an owned Vec first. sol_sha256 accepts +/// an array of slices natively, so the concat step was pure overhead. fn compute_accounts_hash( accounts: &[AccountInfo], - compact_instructions: &[crate::compact::CompactInstruction], + compact_instructions: &[CompactInstructionRef<'_>], ) -> Result<[u8; 32], ProgramError> { - // Collect all account pubkeys in order of reference - let mut pubkeys_data = Vec::new(); + // Collect slice references (16 bytes each) instead of copying 32-byte pubkeys. + // With MAX_COMPACT_INSTRUCTIONS = 16 and a reasonable per-ix account count, + // this fits comfortably on the BPF heap. + let mut refs: Vec<&[u8]> = Vec::with_capacity(compact_instructions.len() * 4); for ix in compact_instructions { - // Include program_id let program_idx = ix.program_id_index as usize; if program_idx >= accounts.len() { return Err(ProgramError::InvalidInstructionData); } - pubkeys_data.extend_from_slice(accounts[program_idx].key().as_ref()); + refs.push(accounts[program_idx].key().as_ref()); - // Include all account pubkeys - for &acc_idx in &ix.accounts { + for &acc_idx in ix.accounts { let idx = acc_idx as usize; if idx >= accounts.len() { return Err(ProgramError::InvalidInstructionData); } - pubkeys_data.extend_from_slice(accounts[idx].key().as_ref()); + refs.push(accounts[idx].key().as_ref()); } } - // Compute SHA256 hash #[allow(unused_assignments)] let mut hash = [0u8; 32]; #[cfg(target_os = "solana")] unsafe { pinocchio::syscalls::sol_sha256( - [pubkeys_data.as_slice()].as_ptr() as *const u8, - 1, + refs.as_ptr() as *const u8, + refs.len() as u64, hash.as_mut_ptr(), ); } #[cfg(not(target_os = "solana"))] { - // For tests, use a dummy hash hash = [0xAA; 32]; - let _ = pubkeys_data; // suppress warning + let _ = refs; } Ok(hash) diff --git a/program/src/processor/execute_actions.rs b/program/src/processor/execute_actions.rs new file mode 100644 index 0000000..bdf5b05 --- /dev/null +++ b/program/src/processor/execute_actions.rs @@ -0,0 +1,1644 @@ +//! Session action evaluation for the Execute instruction. +//! +//! Provides pre-CPI and post-CPI checks for session-based execution. +//! Pre-CPI: program whitelist/blacklist enforcement. +//! Post-CPI: spending limit enforcement with balance diffing. +//! +//! Security model (learned from Swig wallet): +//! - Saturating arithmetic throughout to prevent overflow/underflow +//! - Balance increases (vault gains) are ignored, only outflows count +//! - Recurring limit windows align to slot boundaries +//! - Recurring limits validate single-tx doesn't exceed full window limit +//! - State mutations only happen after all checks pass +//! - Zero spending transactions pass through without triggering limits + +use pinocchio::{account_info::AccountInfo, program_error::ProgramError, pubkey::Pubkey}; + +use crate::{ + compact::CompactInstructionRef, + error::AuthError, + state::{ + action::{parse_actions, read_u64, write_u64, ActionType, ActionView}, + session::{has_actions, SESSION_HEADER_SIZE}, + }, +}; + +// ─── Token Account Layout (SPL Token) ──────────────────────────────── +// mint: bytes 0..32 +// owner: bytes 32..64 (the authority for Transfer/Burn — changed by SetAuthority(AccountOwner)) +// amount: bytes 64..72 +// delegate_coi: bytes 72..108 (COption — changed by Approve/Revoke) +// close_authority: bytes 129..165 (COption — changed by SetAuthority(CloseAccount)) + +const TOKEN_MINT_OFFSET: usize = 0; +const TOKEN_OWNER_OFFSET: usize = 32; +const TOKEN_AMOUNT_OFFSET: usize = 64; +const TOKEN_DELEGATE_OFFSET: usize = 72; +const TOKEN_CLOSE_AUTHORITY_OFFSET: usize = 129; +const TOKEN_ACCOUNT_MIN_SIZE: usize = 165; + +/// A snapshot of a token account balance for a specific mint. +pub struct TokenSnapshot { + pub mint: [u8; 32], + pub amount: u64, +} + +/// Per-token-account snapshot of authority-related fields, captured BEFORE the +/// CPI loop in a session+actions execute. +/// +/// Detects SetAuthority attacks (changing owner/close_authority to attacker) and +/// Approve-delegation attacks (granting delegate to attacker who drains outside +/// the session). All three fields are frozen for vault-owned token accounts on +/// listed mints while a session is executing. +pub struct TokenAuthoritySnapshot { + /// The token account address (so we can re-find it post-CPI). + pub account_key: [u8; 32], + /// owner field bytes [32..64] + pub owner: [u8; 32], + /// delegate COption bytes [72..108] + pub delegate: [u8; 36], + /// close_authority COption bytes [129..165] + pub close_authority: [u8; 36], +} + +/// Evaluate pre-CPI actions (program whitelist/blacklist). +/// +/// Call this BEFORE executing compact instructions. +/// Returns early with Ok(()) if no actions exist. +pub fn evaluate_pre_actions( + session_data: &[u8], + compact_instructions: &[CompactInstructionRef<'_>], + accounts: &[AccountInfo], + current_slot: u64, +) -> Result<(), ProgramError> { + if !has_actions(session_data) { + return Ok(()); + } + + let actions_buf = &session_data[SESSION_HEADER_SIZE..]; + let actions = parse_actions(actions_buf)?; + + // Collect whitelist/blacklist program IDs. + // Expired whitelist actions are intentionally NOT added to `whitelisted`, but they still set + // `has_any_whitelist_action = true`. This means if a whitelist existed but has now expired, + // NO program is permitted — treating an expired whitelist as a hard deny rather than open + // access. An expired blacklist entry, however, is silently dropped (the ban has lifted). + let mut whitelisted: Vec<[u8; 32]> = Vec::new(); + let mut blacklisted: Vec<[u8; 32]> = Vec::new(); + let mut has_any_whitelist_action = false; + + for action in &actions { + match action.action_type { + ActionType::ProgramWhitelist => { + has_any_whitelist_action = true; + if !is_expired(action, current_slot) { + let mut prog_id = [0u8; 32]; + prog_id.copy_from_slice( + &actions_buf[action.data_offset..action.data_offset + 32], + ); + whitelisted.push(prog_id); + } + } + ActionType::ProgramBlacklist => { + if !is_expired(action, current_slot) { + let mut prog_id = [0u8; 32]; + prog_id.copy_from_slice( + &actions_buf[action.data_offset..action.data_offset + 32], + ); + blacklisted.push(prog_id); + } + } + _ => {} + } + } + + // Enforce program restrictions on each instruction + for ix in compact_instructions { + let prog_idx = ix.program_id_index as usize; + if prog_idx >= accounts.len() { + return Err(ProgramError::InvalidInstructionData); + } + let target_program = accounts[prog_idx].key(); + + // Whitelist: if any whitelist action EVER existed (even expired), program must be in the + // active set. An expired whitelist = deny all programs. + if has_any_whitelist_action && !whitelisted.iter().any(|p| p == target_program.as_ref()) { + return Err(AuthError::ActionProgramNotWhitelisted.into()); + } + + // Blacklist: program must NOT be in the active set (expired entries already dropped above). + if blacklisted.iter().any(|p| p == target_program.as_ref()) { + return Err(AuthError::ActionProgramBlacklisted.into()); + } + } + + Ok(()) +} + +/// Snapshot token balances for mints referenced in token actions. +pub fn snapshot_token_balances( + session_data: &[u8], + accounts: &[AccountInfo], + vault_key: &Pubkey, +) -> Result, ProgramError> { + if !has_actions(session_data) { + return Ok(Vec::new()); + } + + let actions_buf = &session_data[SESSION_HEADER_SIZE..]; + let actions = parse_actions(actions_buf)?; + + let mut mints: Vec<[u8; 32]> = Vec::new(); + for action in &actions { + match action.action_type { + ActionType::TokenLimit + | ActionType::TokenRecurringLimit + | ActionType::TokenMaxPerTx => { + let mut mint = [0u8; 32]; + mint.copy_from_slice(&actions_buf[action.data_offset..action.data_offset + 32]); + if !mints.iter().any(|m| m == &mint) { + mints.push(mint); + } + } + _ => {} + } + } + + if mints.is_empty() { + return Ok(Vec::new()); + } + + let mut snapshots = Vec::new(); + for mint in &mints { + if let Some(amount) = find_token_balance(accounts, vault_key, mint) { + snapshots.push(TokenSnapshot { + mint: *mint, + amount, + }); + } + } + + Ok(snapshots) +} + +/// Snapshot per-token-account authority fields for every vault-owned token +/// account whose mint appears in a token action. +/// +/// Paired with `verify_token_authorities_unchanged` post-CPI. Together they +/// prevent `SetAuthority` and `Approve`-style escapes where the session key +/// would otherwise reassign control of vault-owned token accounts without +/// moving any lamports (so the balance-based limits would miss it). +pub fn snapshot_token_authorities( + session_data: &[u8], + accounts: &[AccountInfo], + vault_key: &Pubkey, +) -> Result, ProgramError> { + if !has_actions(session_data) { + return Ok(Vec::new()); + } + + let actions_buf = &session_data[SESSION_HEADER_SIZE..]; + let actions = parse_actions(actions_buf)?; + + // Collect listed mints (same logic as snapshot_token_balances). + let mut mints: Vec<[u8; 32]> = Vec::new(); + for action in &actions { + match action.action_type { + ActionType::TokenLimit + | ActionType::TokenRecurringLimit + | ActionType::TokenMaxPerTx => { + let mut mint = [0u8; 32]; + mint.copy_from_slice(&actions_buf[action.data_offset..action.data_offset + 32]); + if !mints.iter().any(|m| m == &mint) { + mints.push(mint); + } + } + _ => {} + } + } + if mints.is_empty() { + return Ok(Vec::new()); + } + + // Scan all SPL-Token-owned accounts; snapshot each vault-owned one whose + // mint is listed in the session actions. + let mut out = Vec::new(); + for acc in accounts { + let owner = acc.owner(); + if owner.as_ref() != &SPL_TOKEN_PROGRAM_ID + && owner.as_ref() != &SPL_TOKEN_2022_PROGRAM_ID + { + continue; + } + let data = unsafe { acc.borrow_data_unchecked() }; + if data.len() < TOKEN_ACCOUNT_MIN_SIZE { + continue; + } + // vault must currently own it + if &data[TOKEN_OWNER_OFFSET..TOKEN_OWNER_OFFSET + 32] != vault_key.as_ref() { + continue; + } + // mint must be listed + let mut mint = [0u8; 32]; + mint.copy_from_slice(&data[TOKEN_MINT_OFFSET..TOKEN_MINT_OFFSET + 32]); + if !mints.iter().any(|m| m == &mint) { + continue; + } + + let mut owner_bytes = [0u8; 32]; + owner_bytes.copy_from_slice(&data[TOKEN_OWNER_OFFSET..TOKEN_OWNER_OFFSET + 32]); + let mut delegate = [0u8; 36]; + delegate.copy_from_slice(&data[TOKEN_DELEGATE_OFFSET..TOKEN_DELEGATE_OFFSET + 36]); + let mut close_authority = [0u8; 36]; + close_authority.copy_from_slice( + &data[TOKEN_CLOSE_AUTHORITY_OFFSET..TOKEN_CLOSE_AUTHORITY_OFFSET + 36], + ); + + let mut account_key = [0u8; 32]; + account_key.copy_from_slice(acc.key().as_ref()); + + out.push(TokenAuthoritySnapshot { + account_key, + owner: owner_bytes, + delegate, + close_authority, + }); + } + + Ok(out) +} + +/// Verify that every snapshotted token account still has the same owner, +/// delegate, and close_authority fields. Returns an error if any field has +/// changed. +pub fn verify_token_authorities_unchanged( + snapshots: &[TokenAuthoritySnapshot], + accounts: &[AccountInfo], +) -> Result<(), ProgramError> { + for snap in snapshots { + // Find the account by key in the tx accounts list. + let acc = match accounts.iter().find(|a| a.key().as_ref() == snap.account_key) { + Some(a) => a, + // If the account disappeared (e.g. CloseAccount closed it), that's also a + // mutation we should reject. CloseAccount sends rent lamports to an + // attacker-chosen destination without touching the token balance. + None => return Err(AuthError::SessionTokenAuthorityChanged.into()), + }; + + // Must still be owned by SPL Token (not re-assigned to another program) + let owner = acc.owner(); + if owner.as_ref() != &SPL_TOKEN_PROGRAM_ID + && owner.as_ref() != &SPL_TOKEN_2022_PROGRAM_ID + { + return Err(AuthError::SessionTokenAuthorityChanged.into()); + } + + let data = unsafe { acc.borrow_data_unchecked() }; + if data.len() < TOKEN_ACCOUNT_MIN_SIZE { + return Err(AuthError::SessionTokenAuthorityChanged.into()); + } + + if &data[TOKEN_OWNER_OFFSET..TOKEN_OWNER_OFFSET + 32] != snap.owner { + return Err(AuthError::SessionTokenAuthorityChanged.into()); + } + if &data[TOKEN_DELEGATE_OFFSET..TOKEN_DELEGATE_OFFSET + 36] != snap.delegate { + return Err(AuthError::SessionTokenAuthorityChanged.into()); + } + if &data[TOKEN_CLOSE_AUTHORITY_OFFSET..TOKEN_CLOSE_AUTHORITY_OFFSET + 36] + != snap.close_authority + { + return Err(AuthError::SessionTokenAuthorityChanged.into()); + } + } + Ok(()) +} + +/// Evaluate post-CPI actions (spending limits). +/// +/// `vault_lamports_gross_out` is the sum of all per-CPI outflows from the vault, used for +/// `SolMaxPerTx` (which must block even DeFi round-trips that appear net-zero). +/// `vault_lamports_before`/`after` net diff is used for the cumulative limits (SolLimit, +/// SolRecurringLimit), where net accounting is conservative and appropriate. +/// +/// Security: This function first computes all spending deltas and validates +/// ALL limits before writing any state. This ensures no partial state mutation +/// if a later check fails. +pub fn evaluate_post_actions( + session_data: &mut [u8], + accounts: &[AccountInfo], + vault_key: &Pubkey, + vault_lamports_before: u64, + vault_lamports_after: u64, + vault_lamports_gross_out: u64, + token_snapshots_before: &[TokenSnapshot], + current_slot: u64, +) -> Result<(), ProgramError> { + if !has_actions(session_data) { + return Ok(()); + } + + // Only count outflows. If vault gained lamports, sol_spent = 0. + // This matches Swig's pattern: balance increases are tracked but not counted against limits. + let sol_spent = vault_lamports_before.saturating_sub(vault_lamports_after); + + // If nothing was spent, skip all checks (no state mutation needed for SOL). + // Token checks still need to run. + + let actions_buf_readonly = &session_data[SESSION_HEADER_SIZE..]; + let actions = parse_actions(actions_buf_readonly)?; + + // ── Phase 1: Validate all SOL limits (read-only check) ────────── + // Expired spending-limit actions are treated as fully exhausted / "0 remaining": + // if any SOL was spent and a limit action has expired, the tx is rejected. + // This prevents a session with expired limits from becoming unrestricted. + for action in &actions { + let action_expired = is_expired(action, current_slot); + let abs_data_offset = SESSION_HEADER_SIZE + action.data_offset; + + match action.action_type { + ActionType::SolMaxPerTx => { + // Use gross outflow so DeFi round-trips that return most lamports cannot bypass + // a per-tx cap (the net diff would be near-zero but gross could be large). + if vault_lamports_gross_out > 0 { + if action_expired { + return Err(AuthError::ActionSolMaxPerTxExceeded.into()); + } + let max = read_u64(&session_data[abs_data_offset..], 0); + if vault_lamports_gross_out > max { + return Err(AuthError::ActionSolMaxPerTxExceeded.into()); + } + } + } + ActionType::SolLimit => { + if sol_spent > 0 { + if action_expired { + return Err(AuthError::ActionSolLimitExceeded.into()); + } + let remaining = read_u64(&session_data[abs_data_offset..], 0); + if sol_spent > remaining { + return Err(AuthError::ActionSolLimitExceeded.into()); + } + } + } + ActionType::SolRecurringLimit => { + if sol_spent > 0 { + if action_expired { + return Err(AuthError::ActionSolRecurringLimitExceeded.into()); + } + let limit = read_u64(&session_data[abs_data_offset..], 0); + let spent = read_u64(&session_data[abs_data_offset..], 8); + let window = read_u64(&session_data[abs_data_offset..], 16); + let last_reset = read_u64(&session_data[abs_data_offset..], 24); + + let effective_spent = if current_slot.saturating_sub(last_reset) > window { + // Window expired — reset. But single tx can't exceed full limit. + if sol_spent > limit { + return Err(AuthError::ActionSolRecurringLimitExceeded.into()); + } + 0u64 + } else { + spent + }; + + // Use saturating_add to prevent overflow + if effective_spent.saturating_add(sol_spent) > limit { + return Err(AuthError::ActionSolRecurringLimitExceeded.into()); + } + } + } + _ => {} + } + } + + // ── Phase 1b: Validate all token limits (read-only check) ─────── + // Same policy as SOL limits: expired = treat as fully exhausted. + for action in &actions { + let action_expired = is_expired(action, current_slot); + let abs_data_offset = SESSION_HEADER_SIZE + action.data_offset; + + match action.action_type { + ActionType::TokenMaxPerTx | ActionType::TokenLimit | ActionType::TokenRecurringLimit => { + let mut mint = [0u8; 32]; + mint.copy_from_slice(&session_data[abs_data_offset..abs_data_offset + 32]); + + let before_amount = token_snapshots_before + .iter() + .find(|s| s.mint == mint) + .map(|s| s.amount) + .unwrap_or(0); + + let after_amount = find_token_balance(accounts, vault_key, &mint).unwrap_or(0); + + // Only count outflows + let token_spent = before_amount.saturating_sub(after_amount); + + if token_spent > 0 { + if action_expired { + // Treat expired token limit as fully exhausted — deny any spend. + return match action.action_type { + ActionType::TokenMaxPerTx => Err(AuthError::ActionTokenMaxPerTxExceeded.into()), + ActionType::TokenLimit => Err(AuthError::ActionTokenLimitExceeded.into()), + _ => Err(AuthError::ActionTokenRecurringLimitExceeded.into()), + }; + } + match action.action_type { + ActionType::TokenMaxPerTx => { + let max = read_u64(&session_data[abs_data_offset..], 32); + if token_spent > max { + return Err(AuthError::ActionTokenMaxPerTxExceeded.into()); + } + } + ActionType::TokenLimit => { + let remaining = read_u64(&session_data[abs_data_offset..], 32); + if token_spent > remaining { + return Err(AuthError::ActionTokenLimitExceeded.into()); + } + } + ActionType::TokenRecurringLimit => { + let limit = read_u64(&session_data[abs_data_offset..], 32); + let spent = read_u64(&session_data[abs_data_offset..], 40); + let window = read_u64(&session_data[abs_data_offset..], 48); + let last_reset = read_u64(&session_data[abs_data_offset..], 56); + + let effective_spent = + if current_slot.saturating_sub(last_reset) > window { + if token_spent > limit { + return Err( + AuthError::ActionTokenRecurringLimitExceeded.into() + ); + } + 0u64 + } else { + spent + }; + + if effective_spent.saturating_add(token_spent) > limit { + return Err(AuthError::ActionTokenRecurringLimitExceeded.into()); + } + } + _ => {} + } + } + } + _ => {} + } + } + + // ── Phase 2: All checks passed. Now write state mutations. ────── + // Re-parse using a slice reference — no allocation needed, same bytes, same offsets. + let actions = parse_actions(&session_data[SESSION_HEADER_SIZE..])?; + + for action in &actions { + if is_expired(action, current_slot) { + continue; + } + + let abs_data_offset = SESSION_HEADER_SIZE + action.data_offset; + + match action.action_type { + ActionType::SolLimit => { + if sol_spent > 0 { + let remaining = read_u64(&session_data[abs_data_offset..], 0); + write_u64( + &mut session_data[abs_data_offset..], + 0, + remaining.saturating_sub(sol_spent), + ); + } + } + ActionType::SolRecurringLimit => { + if sol_spent > 0 { + let _limit = read_u64(&session_data[abs_data_offset..], 0); + let spent = read_u64(&session_data[abs_data_offset..], 8); + let window = read_u64(&session_data[abs_data_offset..], 16); + let last_reset = read_u64(&session_data[abs_data_offset..], 24); + + let (new_spent, new_last_reset) = + if current_slot.saturating_sub(last_reset) > window { + let aligned = (current_slot / window) * window; + (sol_spent, aligned) + } else { + (spent.saturating_add(sol_spent), last_reset) + }; + + write_u64(&mut session_data[abs_data_offset..], 8, new_spent); + write_u64(&mut session_data[abs_data_offset..], 24, new_last_reset); + } + } + ActionType::TokenLimit => { + let mut mint = [0u8; 32]; + mint.copy_from_slice(&session_data[abs_data_offset..abs_data_offset + 32]); + let before = token_snapshots_before + .iter() + .find(|s| s.mint == mint) + .map(|s| s.amount) + .unwrap_or(0); + let after = find_token_balance(accounts, vault_key, &mint).unwrap_or(0); + let token_spent = before.saturating_sub(after); + + if token_spent > 0 { + let remaining = read_u64(&session_data[abs_data_offset..], 32); + write_u64( + &mut session_data[abs_data_offset..], + 32, + remaining.saturating_sub(token_spent), + ); + } + } + ActionType::TokenRecurringLimit => { + let mut mint = [0u8; 32]; + mint.copy_from_slice(&session_data[abs_data_offset..abs_data_offset + 32]); + let before = token_snapshots_before + .iter() + .find(|s| s.mint == mint) + .map(|s| s.amount) + .unwrap_or(0); + let after = find_token_balance(accounts, vault_key, &mint).unwrap_or(0); + let token_spent = before.saturating_sub(after); + + if token_spent > 0 { + let spent = read_u64(&session_data[abs_data_offset..], 40); + let window = read_u64(&session_data[abs_data_offset..], 48); + let last_reset = read_u64(&session_data[abs_data_offset..], 56); + + let (new_spent, new_last_reset) = + if current_slot.saturating_sub(last_reset) > window { + let aligned = (current_slot / window) * window; + (token_spent, aligned) + } else { + (spent.saturating_add(token_spent), last_reset) + }; + + write_u64(&mut session_data[abs_data_offset..], 40, new_spent); + write_u64(&mut session_data[abs_data_offset..], 56, new_last_reset); + } + } + _ => {} // SolMaxPerTx, TokenMaxPerTx, whitelist/blacklist have no mutable state + } + } + + Ok(()) +} + +// ─── Helpers ────────────────────────────────────────────────────────── + +/// Check if an action has expired. +#[inline] +fn is_expired(action: &ActionView, current_slot: u64) -> bool { + action.expires_at != 0 && current_slot > action.expires_at +} + +/// SPL Token program ID +const SPL_TOKEN_PROGRAM_ID: [u8; 32] = [ + 6, 221, 246, 225, 215, 101, 161, 147, 217, 203, 225, 70, 206, 235, 121, 172, 28, 180, 133, + 237, 95, 91, 55, 145, 58, 140, 245, 133, 126, 255, 0, 169, +]; + +/// SPL Token-2022 program ID +const SPL_TOKEN_2022_PROGRAM_ID: [u8; 32] = [ + 6, 221, 246, 225, 238, 117, 143, 222, 170, 164, 12, 4, 223, 116, 174, 240, 70, 137, 163, 89, + 77, 149, 128, 12, 61, 73, 196, 253, 210, 164, 82, 159, +]; + +/// Find the total token balance across ALL token accounts for a given mint owned by the vault. +/// +/// Security: Sums every matching account rather than returning the first match. +/// Returning only the first match allowed an attacker to place a 0-balance dummy +/// token account (owned by vault, same mint) before the real account in the +/// accounts list, causing both the pre-CPI snapshot and post-CPI check to read +/// the dummy account (balance always 0) and bypass all token spending limits. +/// +/// Verifies each account is owned by SPL Token or Token-2022 to prevent fake +/// accounts with fabricated mint/owner fields. +fn find_token_balance( + accounts: &[AccountInfo], + vault_key: &Pubkey, + mint: &[u8; 32], +) -> Option { + let mut total: u64 = 0; + let mut found = false; + + for acc in accounts { + // CRITICAL: Verify account is owned by SPL Token or Token-2022 program. + let owner = acc.owner(); + if owner.as_ref() != &SPL_TOKEN_PROGRAM_ID && owner.as_ref() != &SPL_TOKEN_2022_PROGRAM_ID + { + continue; + } + + let data = unsafe { acc.borrow_data_unchecked() }; + if data.len() < TOKEN_ACCOUNT_MIN_SIZE { + continue; + } + if &data[TOKEN_MINT_OFFSET..TOKEN_MINT_OFFSET + 32] != mint { + continue; + } + if &data[TOKEN_OWNER_OFFSET..TOKEN_OWNER_OFFSET + 32] != vault_key.as_ref() { + continue; + } + let amount = u64::from_le_bytes( + match data[TOKEN_AMOUNT_OFFSET..TOKEN_AMOUNT_OFFSET + 8].try_into() { + Ok(b) => b, + Err(_) => continue, + }, + ); + total = total.saturating_add(amount); + found = true; + } + + if found { Some(total) } else { None } +} + +// ─── Tests ──────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::action::ACTION_HEADER_SIZE; + + fn build_action(action_type: u8, expires_at: u64, data: &[u8]) -> Vec { + let mut buf = Vec::new(); + buf.push(action_type); + buf.extend_from_slice(&(data.len() as u16).to_le_bytes()); + buf.extend_from_slice(&expires_at.to_le_bytes()); + buf.extend_from_slice(data); + buf + } + + fn build_session_data(actions: &[u8]) -> Vec { + let mut data = vec![0u8; SESSION_HEADER_SIZE]; + data[0] = 3; // discriminator + data.extend_from_slice(actions); + data + } + + /// Test helper: calls evaluate_post_actions with gross_out = before - after (single CPI). + fn eval_post( + session_data: &mut [u8], + accounts: &[AccountInfo], + vault_key: &Pubkey, + before: u64, + after: u64, + token_snapshots: &[TokenSnapshot], + slot: u64, + ) -> Result<(), ProgramError> { + let gross = before.saturating_sub(after); + evaluate_post_actions(session_data, accounts, vault_key, before, after, gross, token_snapshots, slot) + } + + fn build_sol_recurring(limit: u64, spent: u64, window: u64, last_reset: u64) -> Vec { + let mut data = Vec::new(); + data.extend_from_slice(&limit.to_le_bytes()); + data.extend_from_slice(&spent.to_le_bytes()); + data.extend_from_slice(&window.to_le_bytes()); + data.extend_from_slice(&last_reset.to_le_bytes()); + data + } + + // ─── Basic functionality ────────────────────────────────────── + + #[test] + fn test_no_actions_passthrough() { + let mut session_data = vec![0u8; SESSION_HEADER_SIZE]; + session_data[0] = 3; + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 10_000_000, 0, &[], 100, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_zero_spending_no_state_change() { + let actions = build_action(1, 0, &1_000_000u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + let original = session_data.clone(); + + // vault gained lamports (before < after) → sol_spent = 0 + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 2_000_000, // vault gained 1M + &[], 100, + ); + assert!(result.is_ok()); + // State unchanged — remaining should still be 1M + assert_eq!(session_data, original); + } + + #[test] + fn test_vault_balance_increase_ignored() { + // SolMaxPerTx of 500k, but vault GAINS lamports + let actions = build_action(3, 0, &500_000u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 5_000_000, // gained 4M + &[], 100, + ); + assert!(result.is_ok()); // No violation, gains are ignored + } + + // ─── SolLimit ───────────────────────────────────────────────── + + #[test] + fn test_sol_limit_exact_remaining() { + let actions = build_action(1, 0, &1_000_000u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + // Spend exactly the remaining amount — should succeed + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_000_000, // spent exactly 1M + &[], 100, + ); + assert!(result.is_ok()); + + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + let remaining = read_u64(&session_data[abs_offset..], 0); + assert_eq!(remaining, 0); + } + + #[test] + fn test_sol_limit_depletes_across_txs() { + let actions = build_action(1, 0, &1_000_000u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + // Tx 1: spend 600k + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_400_000, &[], 100, + ); + assert!(result.is_ok()); + + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + assert_eq!(read_u64(&session_data[abs_offset..], 0), 400_000); + + // Tx 2: spend 400k (exact remaining) — OK + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_400_000, 1_000_000, &[], 101, + ); + assert!(result.is_ok()); + assert_eq!(read_u64(&session_data[abs_offset..], 0), 0); + + // Tx 3: spend 1 lamport — should fail (0 remaining) + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 999_999, &[], 102, + ); + assert!(result.is_err()); + } + + #[test] + fn test_sol_limit_single_overspend() { + let actions = build_action(1, 0, &1_000_000u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + // Try to spend 1M + 1 — should fail + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 999_999, // spent 1_000_001 + &[], 100, + ); + assert!(result.is_err()); + + // State unchanged after failed check + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + assert_eq!(read_u64(&session_data[abs_offset..], 0), 1_000_000); + } + + // ─── SolMaxPerTx ────────────────────────────────────────────── + + #[test] + fn test_sol_max_per_tx_exact_limit() { + let actions = build_action(3, 0, &500_000u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + // Spend exactly the max — OK + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_500_000, &[], 100, + ); + assert!(result.is_ok()); + + // Exceed by 1 — fail + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_499_999, &[], 101, + ); + assert!(result.is_err()); + } + + #[test] + fn test_sol_max_per_tx_repeatable() { + // MaxPerTx does NOT accumulate — each tx is independent + let actions = build_action(3, 0, &500_000u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + for slot in 100..110 { + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_500_000, // 500k each time + &[], slot, + ); + assert!(result.is_ok()); + } + } + + // ─── SolRecurringLimit ──────────────────────────────────────── + + #[test] + fn test_sol_recurring_limit_basic() { + let data = build_sol_recurring(1_000_000, 0, 100, 0); + let actions = build_action(2, 0, &data); + let mut session_data = build_session_data(&actions); + + // Spend 600k at slot 50 + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_400_000, &[], 50, + ); + assert!(result.is_ok()); + + // Spend 500k more at slot 60 — total 1.1M > 1M limit + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_400_000, 900_000, &[], 60, + ); + assert!(result.is_err()); + } + + #[test] + fn test_sol_recurring_limit_window_reset() { + let data = build_sol_recurring(1_000_000, 0, 100, 0); + let actions = build_action(2, 0, &data); + let mut session_data = build_session_data(&actions); + + // Spend 900k at slot 50 + eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_100_000, &[], 50, + ).unwrap(); + + // At slot 150 (after window), 500k should work again + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_100_000, 600_000, &[], 150, + ); + assert!(result.is_ok()); + + // Verify last_reset was aligned to window boundary + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + let last_reset = read_u64(&session_data[abs_offset..], 24); + assert_eq!(last_reset, 100); // (150 / 100) * 100 = 100 + } + + #[test] + fn test_sol_recurring_single_tx_exceeds_full_limit_after_reset() { + let data = build_sol_recurring(1_000_000, 0, 100, 0); + let actions = build_action(2, 0, &data); + let mut session_data = build_session_data(&actions); + + // At slot 150 (fresh window), try to spend more than the full limit + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 5_000_000, 3_500_000, // 1.5M > 1M limit + &[], 150, + ); + assert!(result.is_err()); + } + + #[test] + fn test_sol_recurring_exact_limit_in_window() { + let data = build_sol_recurring(1_000_000, 0, 100, 0); + let actions = build_action(2, 0, &data); + let mut session_data = build_session_data(&actions); + + // Spend exactly the limit + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_000_000, &[], 50, + ); + assert!(result.is_ok()); + + // Spend 1 more in same window — fail + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 999_999, &[], 60, + ); + assert!(result.is_err()); + } + + #[test] + fn test_sol_recurring_overflow_protection() { + // spent is near u64::MAX, adding more would overflow + let data = build_sol_recurring(u64::MAX, u64::MAX - 100, 1000, 0); + let actions = build_action(2, 0, &data); + let mut session_data = build_session_data(&actions); + + // Spend 200 — would overflow spent + sol_spent without saturating_add + // But limit is u64::MAX so it should be within limit + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 999_800, // spent 200 + &[], 50, + ); + // saturating_add(u64::MAX - 100, 200) = u64::MAX, which == limit, so OK + assert!(result.is_ok()); + } + + // ─── Combined actions ───────────────────────────────────────── + + #[test] + fn test_combined_sol_limit_and_max_per_tx() { + let mut actions_buf = Vec::new(); + // SolLimit: 2M lifetime + actions_buf.extend_from_slice(&build_action(1, 0, &2_000_000u64.to_le_bytes())); + // SolMaxPerTx: 500k per tx + actions_buf.extend_from_slice(&build_action(3, 0, &500_000u64.to_le_bytes())); + + let mut session_data = build_session_data(&actions_buf); + + // 400k — under both limits + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 5_000_000, 4_600_000, &[], 100, + ); + assert!(result.is_ok()); + + // 600k — under lifetime (1.6M left) but over per-tx (500k) + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 4_600_000, 4_000_000, &[], 101, + ); + assert!(result.is_err()); + } + + #[test] + fn test_combined_recurring_and_max_per_tx() { + let mut actions_buf = Vec::new(); + // SolRecurringLimit: 1M per 100 slots + actions_buf.extend_from_slice(&build_action(2, 0, &build_sol_recurring(1_000_000, 0, 100, 0))); + // SolMaxPerTx: 300k per tx + actions_buf.extend_from_slice(&build_action(3, 0, &300_000u64.to_le_bytes())); + + let mut session_data = build_session_data(&actions_buf); + + // 200k — OK + eval_post( + &mut session_data, &[], &Pubkey::default(), + 5_000_000, 4_800_000, &[], 50, + ).unwrap(); + + // 200k more — OK (400k total in window, under 1M; 200k under 300k per-tx) + eval_post( + &mut session_data, &[], &Pubkey::default(), + 4_800_000, 4_600_000, &[], 60, + ).unwrap(); + + // 350k — fails per-tx (350k > 300k) even though recurring has room + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 4_600_000, 4_250_000, &[], 70, + ); + assert!(result.is_err()); + } + + // ─── Action expiry ──────────────────────────────────────────── + + #[test] + fn test_expired_action_blocks_spending() { + // Expired spending limits are treated as fully exhausted (not skipped). + let actions = build_action(3, 50, &500_000u64.to_le_bytes()); // Expires at slot 50 + let mut session_data = build_session_data(&actions); + + // At slot 100, action expired — any spend should FAIL + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_400_000, &[], 100, + ); + assert!(result.is_err()); + + // Zero spending is still OK even with expired action + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 2_000_000, &[], 100, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_action_active_at_expiry_slot() { + // Action expires at slot 50. At exactly slot 50 it should still be active. + // Only expired when current_slot > expires_at. + let actions = build_action(3, 50, &500_000u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + // At slot 50 — still active, 600k > 500k → fail + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_400_000, &[], 50, + ); + assert!(result.is_err()); + + // At slot 51 — expired, any spend → also fail (expired = exhausted) + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_400_000, &[], 51, + ); + assert!(result.is_err()); + } + + #[test] + fn test_mixed_expired_and_active_actions() { + let mut actions_buf = Vec::new(); + // SolMaxPerTx: 500k, expires at slot 50 + actions_buf.extend_from_slice(&build_action(3, 50, &500_000u64.to_le_bytes())); + // SolLimit: 2M, never expires + actions_buf.extend_from_slice(&build_action(1, 0, &2_000_000u64.to_le_bytes())); + + let mut session_data = build_session_data(&actions_buf); + + // At slot 100: MaxPerTx expired → any spend blocked by expired MaxPerTx + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 5_000_000, 2_000_000, &[], 100, + ); + assert!(result.is_err()); + + // Even 1 lamport fails because expired MaxPerTx blocks all spending + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 5_000_000, 4_999_999, &[], 100, + ); + assert!(result.is_err()); + + // Zero spend is OK + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 5_000_000, 5_000_000, &[], 100, + ); + assert!(result.is_ok()); + } + + // ─── State mutation safety ──────────────────────────────────── + + #[test] + fn test_failed_check_no_state_mutation() { + let mut actions_buf = Vec::new(); + // SolLimit: 2M + actions_buf.extend_from_slice(&build_action(1, 0, &2_000_000u64.to_le_bytes())); + // SolMaxPerTx: 100k (will fail) + actions_buf.extend_from_slice(&build_action(3, 0, &100_000u64.to_le_bytes())); + + let mut session_data = build_session_data(&actions_buf); + let original = session_data.clone(); + + // 500k spend — passes SolLimit but fails SolMaxPerTx + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 5_000_000, 4_500_000, &[], 100, + ); + assert!(result.is_err()); + + // Because we validate ALL checks before writing, state is unchanged + assert_eq!(session_data, original); + } + + #[test] + fn test_recurring_state_persists_correctly() { + let data = build_sol_recurring(1_000_000, 0, 100, 0); + let actions = build_action(2, 0, &data); + let mut session_data = build_session_data(&actions); + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + + // Spend 300k at slot 50 + eval_post( + &mut session_data, &[], &Pubkey::default(), + 2_000_000, 1_700_000, &[], 50, + ).unwrap(); + + assert_eq!(read_u64(&session_data[abs_offset..], 8), 300_000); // spent + assert_eq!(read_u64(&session_data[abs_offset..], 24), 0); // last_reset (first window) + + // Spend 200k at slot 60 + eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_700_000, 1_500_000, &[], 60, + ).unwrap(); + + assert_eq!(read_u64(&session_data[abs_offset..], 8), 500_000); // cumulative + + // Window reset at slot 200 + eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_500_000, 1_300_000, &[], 200, + ).unwrap(); + + assert_eq!(read_u64(&session_data[abs_offset..], 8), 200_000); // reset + new spend + assert_eq!(read_u64(&session_data[abs_offset..], 24), 200); // aligned: (200/100)*100 + } + + // ─── Edge: zero limit ───────────────────────────────────────── + + #[test] + fn test_zero_sol_limit_blocks_all_spending() { + let actions = build_action(1, 0, &0u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + // Even 1 lamport should fail + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 999_999, &[], 100, + ); + assert!(result.is_err()); + + // But zero spending is OK + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 1_000_000, &[], 100, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_zero_max_per_tx_blocks_all_spending() { + let actions = build_action(3, 0, &0u64.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 999_999, &[], 100, + ); + assert!(result.is_err()); + + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 1_000_000, &[], 100, + ); + assert!(result.is_ok()); + } + + // ══════════════════════════════════════════════════════════════════ + // Token spending limit tests + // ══════════════════════════════════════════════════════════════════ + // + // Token tests use empty `accounts` slice so find_token_balance returns + // None → unwrap_or(0), meaning "all tokens drained." We provide + // token_snapshots_before with the initial balance to compute spending. + + fn build_token_limit(mint: &[u8; 32], remaining: u64) -> Vec { + let mut data = Vec::new(); + data.extend_from_slice(mint); // [0..32] + data.extend_from_slice(&remaining.to_le_bytes()); // [32..40] + data + } + + fn build_token_max_per_tx(mint: &[u8; 32], max: u64) -> Vec { + let mut data = Vec::new(); + data.extend_from_slice(mint); + data.extend_from_slice(&max.to_le_bytes()); + data + } + + fn build_token_recurring(mint: &[u8; 32], limit: u64, spent: u64, window: u64, last_reset: u64) -> Vec { + let mut data = Vec::new(); + data.extend_from_slice(mint); // [0..32] + data.extend_from_slice(&limit.to_le_bytes()); // [32..40] + data.extend_from_slice(&spent.to_le_bytes()); // [40..48] + data.extend_from_slice(&window.to_le_bytes()); // [48..56] + data.extend_from_slice(&last_reset.to_le_bytes()); // [56..64] + data + } + + // ── TokenLimit ─────────────────────────────────────────────────── + + #[test] + fn test_token_limit_within_budget() { + let mint = [0xAA; 32]; + let actions = build_action(4, 0, &build_token_limit(&mint, 1_000_000)); + let mut session_data = build_session_data(&actions); + let snapshots = vec![TokenSnapshot { mint, amount: 500_000 }]; + + // accounts=[] → after=0, token_spent=500_000, within 1M limit + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 0, 0, &snapshots, 100, + ); + assert!(result.is_ok()); + + // Verify remaining was decremented + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + let remaining = read_u64(&session_data[abs_offset..], 32); + assert_eq!(remaining, 500_000); + } + + #[test] + fn test_token_limit_exceeds_budget() { + let mint = [0xBB; 32]; + let actions = build_action(4, 0, &build_token_limit(&mint, 100_000)); + let mut session_data = build_session_data(&actions); + let snapshots = vec![TokenSnapshot { mint, amount: 200_000 }]; + + // token_spent=200k > remaining=100k → fail + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 0, 0, &snapshots, 100, + ); + assert!(result.is_err()); + } + + #[test] + fn test_token_limit_exact_budget() { + let mint = [0xCC; 32]; + let actions = build_action(4, 0, &build_token_limit(&mint, 500_000)); + let mut session_data = build_session_data(&actions); + let snapshots = vec![TokenSnapshot { mint, amount: 500_000 }]; + + // token_spent = exactly remaining → OK + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 0, 0, &snapshots, 100, + ); + assert!(result.is_ok()); + + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + assert_eq!(read_u64(&session_data[abs_offset..], 32), 0); + } + + #[test] + fn test_token_limit_depletes_across_txs() { + let mint = [0xDD; 32]; + let actions = build_action(4, 0, &build_token_limit(&mint, 1_000_000)); + let mut session_data = build_session_data(&actions); + + // Tx 1: drain 600k + let s1 = vec![TokenSnapshot { mint, amount: 600_000 }]; + eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s1, 100).unwrap(); + + // remaining = 400k + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + assert_eq!(read_u64(&session_data[abs_offset..], 32), 400_000); + + // Tx 2: drain 400k → exact + let s2 = vec![TokenSnapshot { mint, amount: 400_000 }]; + eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s2, 101).unwrap(); + assert_eq!(read_u64(&session_data[abs_offset..], 32), 0); + + // Tx 3: drain 1 → fail + let s3 = vec![TokenSnapshot { mint, amount: 1 }]; + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s3, 102); + assert!(result.is_err()); + } + + // ── TokenMaxPerTx ─────────────────────────────────────────────── + + #[test] + fn test_token_max_per_tx_within_limit() { + let mint = [0xEE; 32]; + let actions = build_action(6, 0, &build_token_max_per_tx(&mint, 500_000)); + let mut session_data = build_session_data(&actions); + let snapshots = vec![TokenSnapshot { mint, amount: 300_000 }]; + + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); + assert!(result.is_ok()); + } + + #[test] + fn test_token_max_per_tx_exceeds() { + let mint = [0xFF; 32]; + let actions = build_action(6, 0, &build_token_max_per_tx(&mint, 500_000)); + let mut session_data = build_session_data(&actions); + let snapshots = vec![TokenSnapshot { mint, amount: 600_000 }]; + + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); + assert!(result.is_err()); + } + + #[test] + fn test_token_max_per_tx_repeatable() { + // MaxPerTx has no cumulative state — repeated spends within limit all pass + let mint = [0x11; 32]; + let actions = build_action(6, 0, &build_token_max_per_tx(&mint, 500_000)); + let mut session_data = build_session_data(&actions); + + for slot in 100..105 { + let snapshots = vec![TokenSnapshot { mint, amount: 500_000 }]; + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, slot); + assert!(result.is_ok()); + } + } + + // ── TokenRecurringLimit ───────────────────────────────────────── + + #[test] + fn test_token_recurring_basic() { + let mint = [0x22; 32]; + let actions = build_action(5, 0, &build_token_recurring(&mint, 1_000_000, 0, 100, 0)); + let mut session_data = build_session_data(&actions); + + // Spend 600k at slot 50 — OK + let s1 = vec![TokenSnapshot { mint, amount: 600_000 }]; + eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s1, 50).unwrap(); + + // Spend 500k more at slot 60 → total 1.1M > 1M limit → fail + let s2 = vec![TokenSnapshot { mint, amount: 500_000 }]; + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s2, 60); + assert!(result.is_err()); + } + + #[test] + fn test_token_recurring_window_reset() { + let mint = [0x33; 32]; + let actions = build_action(5, 0, &build_token_recurring(&mint, 1_000_000, 0, 100, 0)); + let mut session_data = build_session_data(&actions); + + // Spend 900k at slot 50 + let s1 = vec![TokenSnapshot { mint, amount: 900_000 }]; + eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s1, 50).unwrap(); + + // At slot 150 (after window), spending resets → 500k OK + let s2 = vec![TokenSnapshot { mint, amount: 500_000 }]; + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &s2, 150); + assert!(result.is_ok()); + } + + // ── Expired token limits ───────────────────────────────────────── + + #[test] + fn test_expired_token_limit_blocks_spending() { + let mint = [0x44; 32]; + let actions = build_action(4, 50, &build_token_limit(&mint, 1_000_000)); // expires at slot 50 + let mut session_data = build_session_data(&actions); + let snapshots = vec![TokenSnapshot { mint, amount: 100 }]; + + // At slot 100 (expired), any token spend → fail + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); + assert!(result.is_err()); + } + + #[test] + fn test_expired_token_max_per_tx_blocks_spending() { + let mint = [0x55; 32]; + let actions = build_action(6, 50, &build_token_max_per_tx(&mint, 1_000_000)); // expires at 50 + let mut session_data = build_session_data(&actions); + let snapshots = vec![TokenSnapshot { mint, amount: 1 }]; + + // Expired → even 1 token blocked + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); + assert!(result.is_err()); + } + + #[test] + fn test_expired_token_recurring_blocks_spending() { + let mint = [0x66; 32]; + let actions = build_action(5, 50, &build_token_recurring(&mint, 1_000_000, 0, 100, 0)); + let mut session_data = build_session_data(&actions); + let snapshots = vec![TokenSnapshot { mint, amount: 1 }]; + + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); + assert!(result.is_err()); + } + + // ── Multiple mint limits ──────────────────────────────────────── + + #[test] + fn test_multiple_mints_independent() { + let mint_a = [0xAA; 32]; + let mint_b = [0xBB; 32]; + let mut actions_buf = Vec::new(); + actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint_a, 100_000))); + actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint_b, 500_000))); + let mut session_data = build_session_data(&actions_buf); + + // Drain mint_a within its limit, drain mint_b within its limit + let snapshots = vec![ + TokenSnapshot { mint: mint_a, amount: 50_000 }, + TokenSnapshot { mint: mint_b, amount: 400_000 }, + ]; + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); + assert!(result.is_ok()); + } + + #[test] + fn test_multiple_mints_one_exceeds() { + let mint_a = [0xAA; 32]; + let mint_b = [0xBB; 32]; + let mut actions_buf = Vec::new(); + actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint_a, 100_000))); + actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint_b, 500_000))); + let mut session_data = build_session_data(&actions_buf); + + // mint_a: 50k OK, mint_b: 600k > 500k → fail + let snapshots = vec![ + TokenSnapshot { mint: mint_a, amount: 50_000 }, + TokenSnapshot { mint: mint_b, amount: 600_000 }, + ]; + let result = eval_post(&mut session_data, &[], &Pubkey::default(), 0, 0, &snapshots, 100); + assert!(result.is_err()); + } + + // ── Combined SOL + Token limits ───────────────────────────────── + + #[test] + fn test_combined_sol_and_token_limits() { + let mint = [0xCC; 32]; + let mut actions_buf = Vec::new(); + actions_buf.extend_from_slice(&build_action(1, 0, &1_000_000u64.to_le_bytes())); // SolLimit: 1M + actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint, 500_000))); // TokenLimit: 500k + let mut session_data = build_session_data(&actions_buf); + + let snapshots = vec![TokenSnapshot { mint, amount: 300_000 }]; + + // SOL: 200k spent (under 1M), Token: 300k spent (under 500k) → OK + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 800_000, &snapshots, 100, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_combined_sol_ok_token_exceeds() { + let mint = [0xDD; 32]; + let mut actions_buf = Vec::new(); + actions_buf.extend_from_slice(&build_action(1, 0, &10_000_000u64.to_le_bytes())); // SolLimit: 10M + actions_buf.extend_from_slice(&build_action(4, 0, &build_token_limit(&mint, 100_000))); // TokenLimit: 100k + let mut session_data = build_session_data(&actions_buf); + + let snapshots = vec![TokenSnapshot { mint, amount: 200_000 }]; + + // SOL: 500k spent (under 10M), Token: 200k > 100k → fail + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 5_000_000, 4_500_000, &snapshots, 100, + ); + assert!(result.is_err()); + } + + // ══════════════════════════════════════════════════════════════════ + // Gross outflow tests (SolMaxPerTx uses gross, not net) + // ══════════════════════════════════════════════════════════════════ + + #[test] + fn test_sol_max_per_tx_gross_vs_net() { + // SolMaxPerTx = 1 SOL. A DeFi swap sends 10 SOL out and receives 9.5 back. + // Net = 0.5 SOL (would pass if using net), Gross = 10 SOL (must fail). + let actions = build_action(3, 0, &1_000_000_000u64.to_le_bytes()); // 1 SOL max + let mut session_data = build_session_data(&actions); + + // before=20 SOL, after=19.5 SOL → net = 0.5 SOL + // But gross = 10 SOL (passed explicitly) + let result = evaluate_post_actions( + &mut session_data, &[], &Pubkey::default(), + 20_000_000_000, 19_500_000_000, + 10_000_000_000, // gross = 10 SOL + &[], 100, + ); + assert!(result.is_err()); // 10 SOL gross > 1 SOL max → fail + } + + #[test] + fn test_sol_max_per_tx_gross_within_limit() { + let actions = build_action(3, 0, &5_000_000_000u64.to_le_bytes()); // 5 SOL max + let mut session_data = build_session_data(&actions); + + // Gross = 3 SOL, net = 1 SOL + let result = evaluate_post_actions( + &mut session_data, &[], &Pubkey::default(), + 20_000_000_000, 19_000_000_000, + 3_000_000_000, // gross = 3 SOL + &[], 100, + ); + assert!(result.is_ok()); // 3 SOL gross < 5 SOL max → OK + } + + #[test] + fn test_sol_limit_uses_net_not_gross() { + // SolLimit (cumulative) should use net, not gross. + // A round-trip that returns most lamports shouldn't deplete the budget. + let actions = build_action(1, 0, &2_000_000_000u64.to_le_bytes()); // 2 SOL limit + let mut session_data = build_session_data(&actions); + + // net = 0.5 SOL, gross = 10 SOL + let result = evaluate_post_actions( + &mut session_data, &[], &Pubkey::default(), + 20_000_000_000, 19_500_000_000, + 10_000_000_000, + &[], 100, + ); + assert!(result.is_ok()); // SolLimit uses net: 0.5 SOL < 2 SOL → OK + + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + let remaining = read_u64(&session_data[abs_offset..], 0); + assert_eq!(remaining, 1_500_000_000); // 2 SOL - 0.5 SOL net + } + + // ══════════════════════════════════════════════════════════════════ + // Attacker pattern tests + // ══════════════════════════════════════════════════════════════════ + + #[test] + fn test_attacker_all_limits_expired_session_locked() { + // Attacker scenario: create a session with a short-lived SolLimit. + // After expiry, the session should be locked — not unrestricted. + let mut actions_buf = Vec::new(); + actions_buf.extend_from_slice(&build_action(1, 50, &1_000_000u64.to_le_bytes())); // SolLimit, expires at 50 + actions_buf.extend_from_slice(&build_action(3, 50, &500_000u64.to_le_bytes())); // SolMaxPerTx, expires at 50 + let mut session_data = build_session_data(&actions_buf); + + // At slot 100 (both expired), even 1 lamport spend is blocked + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 999_999, &[], 100, + ); + assert!(result.is_err()); + + // Zero spend still OK + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 1_000_000, &[], 100, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_attacker_expired_token_and_sol_limits() { + // All limits expired — both SOL and token spending blocked + let mint = [0xFF; 32]; + let mut actions_buf = Vec::new(); + actions_buf.extend_from_slice(&build_action(1, 50, &1_000_000u64.to_le_bytes())); + actions_buf.extend_from_slice(&build_action(4, 50, &build_token_limit(&mint, 500_000))); + let mut session_data = build_session_data(&actions_buf); + + // SOL spend → blocked + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 999_000, &[], 100, + ); + assert!(result.is_err()); + + // Token spend → blocked + let snapshots = vec![TokenSnapshot { mint, amount: 100 }]; + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 1_000_000, 1_000_000, // no SOL change + &snapshots, 100, + ); + assert!(result.is_err()); + } + + #[test] + fn test_attacker_u64_max_overflow() { + // Attacker tries u64::MAX as remaining — should not cause overflow + let actions = build_action(1, 0, &u64::MAX.to_le_bytes()); + let mut session_data = build_session_data(&actions); + + // Spend u64::MAX → should succeed (exact match) + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + u64::MAX, 0, &[], 100, + ); + assert!(result.is_ok()); + + let abs_offset = SESSION_HEADER_SIZE + ACTION_HEADER_SIZE; + assert_eq!(read_u64(&session_data[abs_offset..], 0), 0); + } + + #[test] + fn test_no_token_snapshot_means_no_change() { + // If a token mint has a limit but no before-snapshot, token_spent = 0 + let mint = [0xAA; 32]; + let actions = build_action(4, 0, &build_token_limit(&mint, 1_000_000)); + let mut session_data = build_session_data(&actions); + + // No snapshots → before=0, after=0 → spent=0 → OK + let result = eval_post( + &mut session_data, &[], &Pubkey::default(), + 0, 0, &[], 100, + ); + assert!(result.is_ok()); + } + + // ── evaluate_pre_actions tests ─────────────────────────────────── + // These test whitelist/blacklist logic directly. + // We create minimal CompactInstructions that reference account indexes. + + #[test] + fn test_pre_actions_no_actions_passthrough() { + let mut session_data = vec![0u8; SESSION_HEADER_SIZE]; + session_data[0] = 3; + + let result = evaluate_pre_actions(&session_data, &[], &[], 100); + assert!(result.is_ok()); + } + + // ── Session creation: actions_len cap ──────────────────────────── + // (tested in session/create.rs but we verify the constant here) + + #[test] + fn test_max_actions_constant_is_16() { + assert_eq!(crate::state::action::MAX_ACTIONS, 16); + } +} diff --git a/program/src/processor/mod.rs b/program/src/processor/mod.rs index a777116..2d2086a 100644 --- a/program/src/processor/mod.rs +++ b/program/src/processor/mod.rs @@ -6,6 +6,7 @@ pub mod authorize; pub mod create_session; pub mod create_wallet; pub mod execute; +pub mod execute_actions; pub mod execute_deferred; pub mod manage_authority; pub mod reclaim_deferred; From 4cbe03d397d590092fe1d0066e1f0797aae0538c Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Thu, 30 Apr 2026 18:09:09 +0700 Subject: [PATCH 05/11] feat(build): dual-cluster Cargo features for compile-time program ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply Pattern D from lazorkit-protocol PR #9: the embedded program ID is now chosen by `--features mainnet` or `--features devnet`, with a `compile_error!` if neither (or both) is set. Prevents accidental cross-cluster deploys — a binary compiled with one ID malfunctions if deployed to the other cluster's slot. Mainnet feature embeds LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi — the SAME program ID as lazorkit-protocol. program-v2 (foundation, no-fee build) occupies that mainnet slot for the duration of the foundation contract; at contract end the upgrade authority swaps the binary at the same slot to lazorkit-protocol's commercial build. dApp integrators keep one stable program ID through the transition. Devnet feature keeps program-v2's existing FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao. Build verification: cargo build-sbf --features mainnet → sha differs from devnet cargo build-sbf --features devnet → sha differs from mainnet cargo build-sbf → fails with compile_error cargo build-sbf --features mainnet --features devnet → fails --- assertions/Cargo.toml | 6 +++++ assertions/src/lib.rs | 25 ++++++++++++++++++++- program/Cargo.toml | 8 +++++++ scripts/sync-program-id.sh | 46 -------------------------------------- 4 files changed, 38 insertions(+), 47 deletions(-) delete mode 100755 scripts/sync-program-id.sh diff --git a/assertions/Cargo.toml b/assertions/Cargo.toml index e810079..2cd256a 100644 --- a/assertions/Cargo.toml +++ b/assertions/Cargo.toml @@ -7,3 +7,9 @@ edition = "2021" pinocchio = { workspace = true } pinocchio-pubkey = { workspace = true } pinocchio-system = { workspace = true } + +# Cluster selection for the embedded program ID. Exactly one must be set +# at build time; lib.rs emits a compile_error! otherwise. +[features] +mainnet = [] +devnet = [] diff --git a/assertions/src/lib.rs b/assertions/src/lib.rs index 477828f..5c22e52 100644 --- a/assertions/src/lib.rs +++ b/assertions/src/lib.rs @@ -10,9 +10,32 @@ use pinocchio::{ use pinocchio_pubkey::declare_id; use pinocchio_system::ID as SYSTEM_ID; -// LazorKit Program ID +// LazorKit Program ID — chosen at build time via the `mainnet` / `devnet` +// cargo features. Exactly one must be enabled; otherwise the build fails +// loudly via the `compile_error!` below. This prevents accidental cross- +// cluster deploys (a binary compiled with one ID malfunctions if deployed +// to a slot at the other ID — every internal `crate::ID` check fails). +// +// The mainnet ID intentionally matches lazorkit-protocol's mainnet ID: +// program-v2 (no-fee foundation variant) is deployed to that slot for the +// duration of the foundation contract; at contract end, the upgrade +// authority swaps the binary at the same slot to lazorkit-protocol's +// commercial build. dApp integrators keep using the same program ID +// throughout — only the on-chain behavior changes. +#[cfg(all(feature = "mainnet", not(feature = "devnet")))] +declare_id!("LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi"); + +#[cfg(all(feature = "devnet", not(feature = "mainnet")))] declare_id!("FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao"); +#[cfg(any( + all(feature = "mainnet", feature = "devnet"), + all(not(feature = "mainnet"), not(feature = "devnet")) +))] +compile_error!( + "LazorKit: pick exactly one cluster — `--features mainnet` OR `--features devnet`" +); + #[allow(unused_imports)] use std::mem::MaybeUninit; diff --git a/program/Cargo.toml b/program/Cargo.toml index 37229b6..8809596 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -6,6 +6,14 @@ edition = "2021" [lib] crate-type = ["cdylib", "lib"] +# Cluster selection — forwards to the assertions crate where the program ID +# is embedded via declare_id!. Exactly one must be set at build time: +# cargo build-sbf --features mainnet +# cargo build-sbf --features devnet +[features] +mainnet = ["assertions/mainnet"] +devnet = ["assertions/devnet"] + [dependencies] pinocchio = { workspace = true } pinocchio-pubkey = { workspace = true } diff --git a/scripts/sync-program-id.sh b/scripts/sync-program-id.sh deleted file mode 100755 index bba425d..0000000 --- a/scripts/sync-program-id.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/bin/bash - -# Check if new Program ID is provided -if [ -z "$1" ]; then - echo "Usage: $0 " - exit 1 -fi - -NEW_ID=$1 - -# Detect OLD_ID from assertions/src/lib.rs -OLD_ID=$(grep -oE "declare_id\!\(\"[A-Za-z0-9]+\"\)" assertions/src/lib.rs | sed -E 's/declare_id\!\(\"([A-Za-z0-9]+)\"\)/\1/') - -if [ -z "$OLD_ID" ]; then - echo "❌ Error: Could not detect current Program ID from assertions/src/lib.rs" - exit 1 -fi - -if [ "$OLD_ID" == "$NEW_ID" ]; then - echo "Program ID is already $NEW_ID. Skipping sync." - exit 0 -fi - -echo "Syncing Program ID: $OLD_ID -> $NEW_ID" - -# 1. Update Rust assertions -sed -i '' "s/$OLD_ID/$NEW_ID/g" assertions/src/lib.rs - -# 2. Update SDK generation script -sed -i '' "s/$OLD_ID/$NEW_ID/g" sdk/solita-client/generate.mjs - -# 3. Update SDK tests common configuration -sed -i '' "s/$OLD_ID/$NEW_ID/g" tests-sdk/tests/common.ts - -# 4. Update validator start script in tests -sed -i '' "s/$OLD_ID/$NEW_ID/g" tests-sdk/package.json - -# 5. Run SDK generation to update the TypeScript client -echo "Regenerating SDK..." -cd sdk/solita-client -node generate.mjs -cd ../.. - -echo "✓ Program ID synced across: Rust code, SDK, and Tests." -echo "✓ SDK regenerated with new address." -echo "Pro tip: Now run 'cargo build-sbf' to rebuild the program with the correct ID." From 57358e80298887e188a04cf28b34dbbad829f335 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Thu, 30 Apr 2026 18:09:27 +0700 Subject: [PATCH 06/11] feat(program): embed security.txt with audit + contact info Add solana-security-txt + default-env deps and embed a `security_txt!` block in program/src/lib.rs so on-chain inspectors (and security researchers) get a self-described pointer to the SECURITY.md, contact endpoints, source repo, and audit report. Identifies the binary as the "Foundation Build" of LazorKit Smart Wallet to distinguish it from the lazorkit-protocol commercial binary that may later occupy the same mainnet slot. source_revision and source_release are populated from GITHUB_SHA / GITHUB_REF_NAME at CI build time. Audit pointer is the existing Accretion Labs report shipped under audits/. --- program/Cargo.toml | 2 ++ program/src/lib.rs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/program/Cargo.toml b/program/Cargo.toml index 8809596..a9b9e4b 100644 --- a/program/Cargo.toml +++ b/program/Cargo.toml @@ -21,6 +21,8 @@ pinocchio-system = { workspace = true } no-padding = { workspace = true } assertions = { workspace = true } shank = { version = "0.4.2", git = "https://github.com/anagrambuild/shank.git" } +solana-security-txt = "1.1.2" +default-env = "0.1" [dev-dependencies] solana-sdk = "2.1" diff --git a/program/src/lib.rs b/program/src/lib.rs index aad119c..2a41038 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -1,5 +1,22 @@ #![allow(unexpected_cfgs)] +#[cfg(not(feature = "no-entrypoint"))] +use {default_env::default_env, solana_security_txt::security_txt}; + +#[cfg(not(feature = "no-entrypoint"))] +security_txt! { + name: "LazorKit Smart Wallet", + project_url: "https://lazorkit.com", + contacts: "email:security@lazorkit.app,link:https://github.com/lazor-kit/program-v2/security/advisories/new", + policy: "https://github.com/lazor-kit/program-v2/blob/main/SECURITY.md", + + preferred_languages: "en,vi", + source_code: "https://github.com/lazor-kit/program-v2", + source_revision: default_env!("GITHUB_SHA", ""), + source_release: default_env!("GITHUB_REF_NAME", ""), + auditors: "Accretion Labs (Solana Foundation) — https://github.com/lazor-kit/program-v2/blob/main/audits/2026-accretion-solana-foundation-lazorkit-audit-A26SFR1.pdf" +} + pub mod auth; pub mod compact; pub mod entrypoint; From 2708a247c0a75ec4d61aa6caeb0835327ae7c29e Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Thu, 30 Apr 2026 18:10:06 +0700 Subject: [PATCH 07/11] chore(build): adapt dev workflow to dual-cluster features - scripts/build-all.sh now takes a cluster argument (mainnet|devnet) and passes it through to cargo build-sbf. The program ID for IDL generation is derived from the resulting keypair instead of being passed in. - tests-sdk/package.json validator:start now builds with --features devnet before launching solana-test-validator and reads the program ID via solana-keygen pubkey on the keypair file (matching upstream pattern). - DEVELOPMENT.md updated with the new build invocations, --features devnet for cargo test, and a "Mainnet Deploy Strategy" section documenting the slot-sharing arrangement with lazorkit-protocol and the binary swap at contract end. - Cargo.lock regenerated for the security-txt deps added in the previous commit. --- Cargo.lock | 234 +++++++++++++++++++++++++++-------------- DEVELOPMENT.md | 58 ++++++++-- scripts/build-all.sh | 51 +++++---- tests-sdk/package.json | 2 +- 4 files changed, 237 insertions(+), 108 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 889967d..baacd71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,6 +81,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -159,7 +165,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" dependencies = [ - "quote", + "quote 1.0.44", "syn 1.0.109", ] @@ -171,8 +177,8 @@ checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" dependencies = [ "num-bigint 0.4.6", "num-traits", - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 1.0.109", ] @@ -207,8 +213,8 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 1.0.109", ] @@ -393,7 +399,7 @@ dependencies = [ "borsh-derive-internal", "borsh-schema-derive-internal", "proc-macro-crate 0.1.5", - "proc-macro2", + "proc-macro2 1.0.106", "syn 1.0.109", ] @@ -405,8 +411,8 @@ checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" dependencies = [ "once_cell", "proc-macro-crate 3.4.0", - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -416,8 +422,8 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65d6ba50644c98714aa2a70d13d7df3cd75cd2b523a2b452bf010443800976b3" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 1.0.109", ] @@ -427,8 +433,8 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "276691d96f063427be83e6692b86148e488ebba9f48f77788724ca027ba3b6d4" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 1.0.109", ] @@ -493,8 +499,8 @@ version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -548,8 +554,8 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -774,8 +780,8 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -797,8 +803,8 @@ checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" dependencies = [ "fnv", "ident_case", - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "strsim", "syn 2.0.114", ] @@ -810,10 +816,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", - "quote", + "quote 1.0.44", "syn 2.0.114", ] +[[package]] +name = "default-env" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f753eb82d29277e79efc625e84aecacfd4851ee50e05a8573a4740239a77bfd3" +dependencies = [ + "proc-macro2 0.4.30", + "quote 0.6.13", + "syn 0.15.44", +] + [[package]] name = "der" version = "0.7.10" @@ -837,8 +854,8 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 1.0.109", ] @@ -889,8 +906,8 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -999,8 +1016,8 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -1092,6 +1109,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1271,6 +1294,17 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1620,6 +1654,7 @@ dependencies = [ "assertions", "base64ct", "blake3", + "default-env", "ecdsa", "getrandom 0.2.17", "litesvm", @@ -1633,6 +1668,7 @@ dependencies = [ "shank", "shank_idl", "solana-sdk", + "solana-security-txt", ] [[package]] @@ -1868,8 +1904,8 @@ dependencies = [ name = "no-padding" version = "0.1.0" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -1924,8 +1960,8 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -1987,8 +2023,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ "proc-macro-crate 3.4.0", - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -2025,8 +2061,8 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -2228,6 +2264,15 @@ dependencies = [ "toml_edit 0.23.10+spec-1.0.0", ] +[[package]] +name = "proc-macro2" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759" +dependencies = [ + "unicode-xid", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2252,18 +2297,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e2e25ee72f5b24d773cae88422baddefff7714f97aab68d96fe2b6fc4a28fb2" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] +[[package]] +name = "quote" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce23b6b870e8f94f81fb0a363d65d86675884b34a09043c81e5562f11c1f8e1" +dependencies = [ + "proc-macro2 0.4.30", +] + [[package]] name = "quote" version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ - "proc-macro2", + "proc-macro2 1.0.106", ] [[package]] @@ -2597,8 +2651,8 @@ version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -2653,8 +2707,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ "darling", - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -2725,8 +2779,8 @@ name = "shank_macro" version = "0.4.2" source = "git+https://github.com/anagrambuild/shank.git#d4f046b22b87c896fdb77e55256d74dad6a13a68" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "shank_macro_impl", "shank_render", "syn 1.0.109", @@ -2738,8 +2792,8 @@ version = "0.4.2" source = "git+https://github.com/anagrambuild/shank.git#d4f046b22b87c896fdb77e55256d74dad6a13a68" dependencies = [ "anyhow", - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "serde", "syn 1.0.109", ] @@ -2749,8 +2803,8 @@ name = "shank_render" version = "0.4.2" source = "git+https://github.com/anagrambuild/shank.git#d4f046b22b87c896fdb77e55256d74dad6a13a68" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "shank_macro_impl", ] @@ -4222,8 +4276,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86280da8b99d03560f6ab5aca9de2e38805681df34e0bb8f238e69b29433b9df" dependencies = [ "bs58", - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -4271,6 +4325,15 @@ dependencies = [ "solana-sdk-ids", ] +[[package]] +name = "solana-security-txt" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "156bb61a96c605fa124e052d630dba2f6fb57e08c7d15b757e1e958b3ed7b3fe" +dependencies = [ + "hashbrown 0.15.2", +] + [[package]] name = "solana-seed-derivable" version = "2.2.1" @@ -4862,14 +4925,25 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "0.15.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ca4b3b69a77cbe1ffc9e198781b7acb0c7365a883670e8f1c1bc66fba79a5c5" +dependencies = [ + "proc-macro2 0.4.30", + "quote 0.6.13", + "unicode-xid", +] + [[package]] name = "syn" version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "unicode-ident", ] @@ -4879,8 +4953,8 @@ version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "unicode-ident", ] @@ -4896,8 +4970,8 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -4955,8 +5029,8 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -4966,8 +5040,8 @@ version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -5162,6 +5236,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-xid" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc" + [[package]] name = "universal-hash" version = "0.5.1" @@ -5296,7 +5376,7 @@ version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ - "quote", + "quote 1.0.44", "wasm-bindgen-macro-support", ] @@ -5307,8 +5387,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", "wasm-bindgen-shared", ] @@ -5388,8 +5468,8 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -5399,8 +5479,8 @@ version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -5698,8 +5778,8 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", "synstructure", ] @@ -5719,8 +5799,8 @@ version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -5739,8 +5819,8 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", "synstructure", ] @@ -5760,8 +5840,8 @@ version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] @@ -5793,8 +5873,8 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ - "proc-macro2", - "quote", + "proc-macro2 1.0.106", + "quote 1.0.44", "syn 2.0.114", ] diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d7e7d96..862f8d3 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -25,20 +25,39 @@ This document outlines the standard procedures for building, deploying, and test ### A. Build Program +The program ID is chosen at build time via the `mainnet` / `devnet` cargo features +(see `assertions/src/lib.rs`). Exactly one must be set; an unflagged build fails +with a `compile_error!`. + ```bash -cargo build-sbf +# Devnet build — embeds FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao +cargo build-sbf --features devnet + +# Mainnet build — embeds LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi +# (slot is shared with lazorkit-protocol — see "Mainnet Deploy Strategy" below) +cargo build-sbf --features mainnet ``` +The convenience script `./scripts/build-all.sh ` builds, generates +the IDL, and regenerates the SDK in one shot. + ### B. Run Rust Tests ```bash -cargo test +cargo test --features devnet ``` +The `--features devnet` flag is required because the assertions crate's +`compile_error!` fires on un-flagged builds. Choose either feature — host-side +tests use a runtime `program_id: Pubkey::new_unique()`, so the embedded ID +doesn't affect test outcomes. + ### C. IDL Generation (using Shank) ```bash -cd program && shank idl -o . --out-filename idl.json -p FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao +cd program +PROGRAM_ID=$(solana-keygen pubkey ../target/deploy/lazorkit_program-keypair.json) +shank idl -o . --out-filename idl.json -p "$PROGRAM_ID" ``` ### D. SDK Generation (using Solita) @@ -67,19 +86,42 @@ cd tests-sdk && npm run benchmark Measures CU usage and transaction sizes for all instructions, including deferred execution (Authorize TX1 + ExecuteDeferred TX2). -### G. Program ID Sync +### G. Deploy to Devnet ```bash -./scripts/sync-program-id.sh +cargo build-sbf --features devnet +solana program deploy target/deploy/lazorkit_program.so -u d ``` -### H. Deploy to Devnet +### H. Mainnet Deploy Strategy (Foundation Build) + +program-v2 is the no-fee "foundation" variant of LazorKit. For the duration of +the foundation contract, its mainnet binary occupies the SAME mainnet program +slot as `lazorkit-protocol`'s commercial binary — `LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi`. +dApp integrators keep using one stable program ID; only the on-chain behavior +changes when the binary is swapped. + +**During contract:** ```bash -cargo build-sbf -solana program deploy target/deploy/lazorkit_program.so -u d +cargo build-sbf --features mainnet +solana program deploy target/deploy/lazorkit_program.so -u m +# Optionally pin the upgrade authority to a multisig held jointly by foundation +# and the lazorkit team so the post-contract swap can happen. +``` + +**At contract end** — swap to lazorkit-protocol's commercial binary: + +```bash +# In the lazorkit-protocol repo: +cargo build-sbf --features mainnet +solana program deploy target/deploy/lazorkit_program.so -u m \ + --program-id LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi ``` +The upgrade authority key must control the `LazorjRF…` slot for both deploys. +There is no separate program-v2 vanity keypair to manage. + ## Troubleshooting - **429 Too Many Requests**: Check RPC credits or use local validator. diff --git a/scripts/build-all.sh b/scripts/build-all.sh index 8211f0e..29a9953 100755 --- a/scripts/build-all.sh +++ b/scripts/build-all.sh @@ -1,31 +1,38 @@ #!/bin/bash - -# Configuration -PROGRAM_ID=$1 +# Build the Rust program for a chosen cluster, derive the program ID from the +# resulting keypair, regenerate IDL + SDK against that ID. +# +# Usage: +# ./scripts/build-all.sh devnet # builds with --features devnet (FLb7...) +# ./scripts/build-all.sh mainnet # builds with --features mainnet (LazorjRF... — slot shared with lazorkit-protocol) +# +# After this script the .so + keypair live at target/deploy/. Deploy with: +# solana program deploy target/deploy/lazorkit_program.so -u +set -e + +CLUSTER=$1 ROOT_DIR=$(pwd) PROGRAM_DIR="$ROOT_DIR/program" SDK_DIR="$ROOT_DIR/sdk/solita-client" -if [ -z "$PROGRAM_ID" ]; then - echo "Usage: $0 " - echo "Example: $0 FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao" +if [ "$CLUSTER" != "mainnet" ] && [ "$CLUSTER" != "devnet" ]; then + echo "Usage: $0 " exit 1 fi -echo "--- 🚀 Starting LazorKit Full Sync Workflow ---" - -# Step 1: Update Program ID everywhere -echo "[1/4] Syncing Program ID to $PROGRAM_ID..." -./scripts/sync-program-id.sh "$PROGRAM_ID" +echo "--- 🚀 LazorKit build (cluster: $CLUSTER) ---" -# Step 2: Build Rust Program -echo "[2/4] Building Rust Program..." -cargo build-sbf - -# Step 3: Generate IDL using Shank -echo "[3/4] Generating IDL..." +# Step 1: Build Rust Program with the chosen cluster feature. +# This embeds the right declare_id! at compile time via assertions/src/lib.rs. +echo "[1/3] Building Rust Program (cargo build-sbf --features $CLUSTER)..." cd "$PROGRAM_DIR" -# Assuming shank is installed. If not, this will fail with a clear msg. +cargo build-sbf --features "$CLUSTER" + +# Step 2: Generate IDL using Shank, picking the program ID from the keypair +# the build emitted at target/deploy/lazorkit_program-keypair.json. +echo "[2/3] Generating IDL..." +PROGRAM_ID=$(solana-keygen pubkey ../target/deploy/lazorkit_program-keypair.json) +echo " resolved program ID: $PROGRAM_ID" if command -v shank &> /dev/null; then shank idl -o . --out-filename idl.json -p "$PROGRAM_ID" else @@ -33,10 +40,10 @@ else exit 1 fi -# Step 4: Regenerate SDK with Solita -echo "[4/4] Regenerating Solita SDK..." +# Step 3: Regenerate SDK with Solita. +echo "[3/3] Regenerating Solita SDK..." cd "$SDK_DIR" node generate.mjs -echo "--- ✅ All Done! ---" -echo "Next: Deploy your program using 'solana program deploy program/target/deploy/lazorkit_program.so -u d'" +echo "--- ✅ Done ($CLUSTER) ---" +echo "Deploy: solana program deploy ../target/deploy/lazorkit_program.so -u $([ "$CLUSTER" = "mainnet" ] && echo m || echo d)" diff --git a/tests-sdk/package.json b/tests-sdk/package.json index 98d11f0..8f1f03b 100644 --- a/tests-sdk/package.json +++ b/tests-sdk/package.json @@ -6,7 +6,7 @@ "scripts": { "test": "vitest run --fileParallelism=false", "test:watch": "vitest --fileParallelism=false", - "validator:start": "solana-test-validator --bpf-program FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao ../target/deploy/lazorkit_program.so --reset", + "validator:start": "cd ../program && cargo build-sbf --features devnet && cd - && solana-test-validator --bpf-program $(solana-keygen pubkey ../target/deploy/lazorkit_program-keypair.json) ../target/deploy/lazorkit_program.so --reset", "benchmark": "npx tsx tests/benchmark.ts", "pretest": "echo 'Ensure solana-test-validator is running with the program loaded'" }, From 3525c839ea6b535801d93e54ff3585616936a8aa Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Thu, 30 Apr 2026 18:10:11 +0700 Subject: [PATCH 08/11] ci: port sbf-cluster-check workflow from lazorkit-protocol Verifies the dual-cluster mechanism stays intact: 1. cargo build-sbf --features mainnet succeeds 2. cargo build-sbf --features devnet succeeds 3. The two .so binaries differ (catches a refactor that neutralises the cfg gate on declare_id!) 4. cargo build-sbf with no feature fails with the expected "pick exactly one cluster" compile_error 5. cargo build-sbf with both features fails the same way Workflow is byte-identical to upstream's. Triggers on PRs touching program/ or assertions/, and on pushes to main. --- .github/workflows/sbf-cluster-check.yml | 122 ++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 .github/workflows/sbf-cluster-check.yml diff --git a/.github/workflows/sbf-cluster-check.yml b/.github/workflows/sbf-cluster-check.yml new file mode 100644 index 0000000..6d5134d --- /dev/null +++ b/.github/workflows/sbf-cluster-check.yml @@ -0,0 +1,122 @@ +name: SBF cluster feature check + +# Verifies the Pattern D feature-flag mechanism in `assertions/src/lib.rs`: +# +# 1. `cargo build-sbf --features mainnet` succeeds and produces a binary +# embedding the mainnet vanity ID. +# 2. `cargo build-sbf --features devnet` succeeds and produces a binary +# embedding the devnet ID. +# 3. The two binaries differ (otherwise the feature flag has been +# neutralised by a refactor and Pattern D no longer protects against +# cross-cluster deploys). +# 4. `cargo build-sbf` with no feature flag fails with the expected +# `compile_error!` (otherwise nothing prevents an unflagged build +# from silently embedding whichever ID happens to be the default). +# +# Runs on every PR touching the program / assertions crate or this workflow. + +on: + pull_request: + paths: + - 'program/**' + - 'assertions/**' + - '.github/workflows/sbf-cluster-check.yml' + push: + branches: [main] + +jobs: + cluster-check: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Install Solana toolchain + run: | + sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)" + echo "$HOME/.local/share/solana/install/active_release/bin" >> "$GITHUB_PATH" + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: sbf-${{ hashFiles('**/Cargo.lock') }} + + - name: Build mainnet binary + working-directory: program + run: cargo build-sbf --features mainnet + + - name: Record mainnet hash + id: mainnet + run: | + M=$(shasum -a 256 target/deploy/lazorkit_program.so | awk '{print $1}') + echo "sha=$M" >> "$GITHUB_OUTPUT" + echo "mainnet SBF: $M" + + - name: Build devnet binary + working-directory: program + run: cargo build-sbf --features devnet + + - name: Record devnet hash + id: devnet + run: | + D=$(shasum -a 256 target/deploy/lazorkit_program.so | awk '{print $1}') + echo "sha=$D" >> "$GITHUB_OUTPUT" + echo "devnet SBF: $D" + + - name: Verify binaries differ + run: | + if [ "${{ steps.mainnet.outputs.sha }}" = "${{ steps.devnet.outputs.sha }}" ]; then + echo "ERROR: mainnet + devnet SBF binaries are identical." + echo " Pattern D's compile-time cluster switch has been" + echo " neutralised — likely a refactor removed the cfg gate" + echo " on declare_id! in assertions/src/lib.rs." + exit 1 + fi + echo "✓ binaries differ as expected" + + - name: Verify no-feature build fails with compile_error! + working-directory: program + run: | + # Capture exit code separately — `cmd | tee` returns tee's exit + # (always 0), masking cargo's failure. `set -o pipefail` would + # also work, but capturing to a file gives us the log to search + # afterwards regardless of pipeline state. + set +e + cargo build-sbf > /tmp/build.log 2>&1 + BUILD_EXIT=$? + set -e + cat /tmp/build.log + if [ "$BUILD_EXIT" -eq 0 ]; then + echo "ERROR: cargo build-sbf without --features mainnet/devnet succeeded." + echo " The compile_error! in assertions/src/lib.rs is no longer firing." + exit 1 + fi + if ! grep -q "pick exactly one cluster" /tmp/build.log; then + echo "ERROR: build failed but not with the expected compile_error message." + echo " Expected: 'pick exactly one cluster — --features mainnet OR --features devnet'" + exit 1 + fi + echo "✓ no-feature build correctly rejected by compile_error!" + + - name: Verify both-features build fails + working-directory: program + run: | + set +e + cargo build-sbf --features mainnet --features devnet > /tmp/build-both.log 2>&1 + BUILD_EXIT=$? + set -e + cat /tmp/build-both.log + if [ "$BUILD_EXIT" -eq 0 ]; then + echo "ERROR: cargo build-sbf with BOTH features succeeded." + echo " The compile_error! mutual-exclusion guard is broken." + exit 1 + fi + if ! grep -q "pick exactly one cluster" /tmp/build-both.log; then + echo "ERROR: build failed but not with the expected compile_error message." + exit 1 + fi + echo "✓ both-features build correctly rejected" From d384fd603ff80262b5f6eebdc6ae4f84efbb98b8 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Fri, 1 May 2026 02:41:05 +0700 Subject: [PATCH 09/11] docs(changelog): record P1 + P2 additions under [Unreleased] Captures everything landed since the last audit cycle: - 8 session action permission types (state + parser + validator + tests) - SessionAccount variable-size + CreateSession action buffer support - Pre-CPI program whitelist/blacklist + post-CPI spending caps - Vault + token-account invariant defenses (3030-3032 errors) - Anti-CPI guard for session-authenticated Execute - Zero-copy CompactInstructionRef parser - Dual-cluster Cargo features (mainnet/devnet) with compile_error guard - security.txt embedded via solana-security-txt - Cherry-pick guardrails (fee-paths.txt + scripts + CI workflow) - sbf-cluster-check CI workflow - build-all.sh refactored for feature-flagged builds; sync-program-id.sh removed --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8d8d16..6fee6a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- Session action permissions: 8 immutable permission rules attachable at session creation — `SolLimit`, `SolRecurringLimit`, `SolMaxPerTx`, `TokenLimit`, `TokenRecurringLimit`, `TokenMaxPerTx`, `ProgramWhitelist`, `ProgramBlacklist`. Action discriminators (1, 2, 3, 4, 5, 6, 10, 11) and the 11-byte header layout match `lazorkit-protocol` so the unified SDK can encode actions identically for both builds. +- `SessionAccount` is now variable-size: a session can carry a trailing action buffer (max 16 actions, ≤ 2048 bytes) validated at creation time. +- `CreateSession` instruction data accepts the new `[actions_len: u16][actions: N]` extension after the legacy 40-byte args; old 40-byte clients continue to work via the legacy parser branch. +- Pre-CPI action enforcement at `Execute` time: program whitelist/blacklist checks against each CPI target. +- Post-CPI action enforcement: SOL/token spending caps with saturating arithmetic; recurring-window resets aligned to slot boundaries; per-execute SOL outflow tracked across all CPIs for `SolMaxPerTx`. +- Vault-invariant defenses against `System::Assign` / `SetAuthority` / `Approve` escapes: vault owner + data-length snapshotted pre-CPI and verified unchanged post-CPI; vault-owned token accounts on listed mints have their owner / delegate / close_authority fields snapshotted and verified. +- Anti-CPI guard for session-authenticated `Execute`: stack-height must be 1 (rejects wrapper programs chaining through `Execute`). +- Error codes 3020–3029 (action validation + enforcement) and 3030–3032 (`SessionVaultOwnerChanged`, `SessionVaultDataLenChanged`, `SessionTokenAuthorityChanged`). +- Dual-cluster Cargo features (`mainnet`, `devnet`): the embedded program ID is chosen at compile time via a feature flag with a `compile_error!` if neither / both is set. The `mainnet` feature embeds `LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi` (same slot as `lazorkit-protocol`) for the foundation deployment; `devnet` keeps `FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao`. +- `security.txt` block embedded via `solana-security-txt` macro: links to SECURITY.md, contact email, source repo, source revision (from `GITHUB_SHA`), and the Accretion audit PDF. +- Zero-copy `CompactInstructionRef` parser (`parse_compact_instructions_ref_with_len`) used by the Execute hot path — no per-instruction `Vec` allocations for account-index bytes or instruction data. +- Cherry-pick guardrails: `scripts/fee-paths.txt` declares forbidden fee-surface paths and symbols, `scripts/check-no-fee.sh` verifies the working tree (used by CI), `scripts/strip-fee.sh` auto-removes fee files post-cherry-pick. +- CI workflow `check-no-fee` runs the verifier on every PR. +- CI workflow `sbf-cluster-check` builds both mainnet and devnet SBF binaries, verifies their hashes differ, and asserts that an unflagged `cargo build-sbf` fails with the expected `compile_error!`. +- `scripts/build-all.sh ` now drives a feature-flagged build + IDL regen + SDK regen in one step. The previous `scripts/sync-program-id.sh` is removed (program ID is now a compile-time feature, not a sed target). +- `solana-security-txt` and `default-env` dependencies, `[workspace.metadata.cli]` pinning Solana CLI 3.0.4 for verified builds. - Unified SDK API with discriminated union signer types (`ed25519()`, `secp256r1()`, `session()` helper constructors) - `CreateWalletOwner` union type: single `createWallet()` method for both Ed25519 and Secp256r1 - `AdminSigner` union type for admin operations (addAuthority, removeAuthority, transferOwnership, createSession) From fe249b82ea7c4f3773a21dacc5f2bfcf2987948f Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Fri, 1 May 2026 02:41:15 +0700 Subject: [PATCH 10/11] docs(deploy): mainnet deploy runbook + audit-frozen tag checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operational documents covering the path from audit submission to production deploy: docs/MAINNET_DEPLOY.md — runbook for the foundation deploy and the binary swap to lazorkit-protocol at contract end. Both binaries occupy the same mainnet slot (LazorjRF…) at different times. Covers initial deploy, routine upgrades, binary swap, rollback, pre-existing wallet account behavior across the swap, and final upgrade-authority lock-down. docs/AUDIT_PREP.md — pre-tag checklist run before submitting a revision to Accretion. Covers code state hygiene, dual-feature build verification, test suite, fee-surface invariant (check-no-fee), documentation alignment, diff bounding, and the audit-packet contents to deliver. Also defines the audit-frozen-vN tag naming convention and the post-audit fix-up flow. --- docs/AUDIT_PREP.md | 127 ++++++++++++++++++++++++ docs/MAINNET_DEPLOY.md | 219 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 docs/AUDIT_PREP.md create mode 100644 docs/MAINNET_DEPLOY.md diff --git a/docs/AUDIT_PREP.md b/docs/AUDIT_PREP.md new file mode 100644 index 0000000..0706de7 --- /dev/null +++ b/docs/AUDIT_PREP.md @@ -0,0 +1,127 @@ +# Audit-Frozen Tag Checklist + +Before submitting a `program-v2` revision to Accretion (or any auditor) for +review, walk this checklist and tag the resulting state. The tag becomes the +exact source the audit report references — a deployment that doesn't match +the tag falls outside the audit's scope. + +## Naming convention + +Tags use the format `audit-frozen-vN` where `N` increments per audit cycle. +Example: the first delta audit after the initial Accretion review is +`audit-frozen-v2`. The audit report should cite the tag in its scope section. + +## Pre-tag checklist + +### 1. Code state + +- [ ] Working tree clean (`git status` empty). +- [ ] `git log --oneline main..HEAD` only contains commits intended for this + audit. No experimental work, no half-finished refactors. +- [ ] No `TODO`, `FIXME`, `XXX`, or `unimplemented!` markers in `program/src/` + or `assertions/src/`. Anything genuinely unfinished should be reverted + from the audit branch. +- [ ] No `dbg!`, `println!`, `eprintln!`, or commented-out code in + `program/src/` or `assertions/src/`. + +```bash +git grep -nE "TODO|FIXME|XXX|unimplemented!|dbg!|println!|eprintln!" -- program/src assertions/src +``` + +### 2. Build verification + +- [ ] `cargo build-sbf --features mainnet` succeeds. Record the SHA-256 of + `target/deploy/lazorkit_program.so`. +- [ ] `cargo build-sbf --features devnet` succeeds. Record the SHA-256. +- [ ] The two hashes differ (verifies the dual-cluster mechanism is intact). +- [ ] `cargo build-sbf` (no features) fails with the expected + `compile_error!` message containing "pick exactly one cluster". +- [ ] The CI `sbf-cluster-check` job is green for the tagged commit. + +### 3. Test suite + +- [ ] `cargo test --features devnet` — all unit + litesvm integration tests + pass. +- [ ] `cd tests-sdk && npm test` (with the validator running and the + devnet-built `.so` loaded) — all integration tests pass. +- [ ] No tests are skipped, ignored (`#[ignore]`), or commented out without + a tracking issue. + +```bash +git grep -nE "#\[ignore\]|it\.skip|xit\(|describe\.skip" -- program tests-sdk +``` + +### 4. Fee surface invariant (foundation build) + +- [ ] `bash scripts/check-no-fee.sh` exits 0. The CI `check-no-fee` job is + green for the tagged commit. +- [ ] Diff against the previous audit-frozen tag has no new files matching + paths in `scripts/fee-paths.txt`. + +```bash +git diff audit-frozen-v$(N-1)..HEAD -- 'program/src/state/protocol_config.rs' \ + 'program/src/state/treasury_shard.rs' \ + 'program/src/state/integrator_record.rs' \ + 'program/src/processor/protocol/' +# Should print nothing. +``` + +### 5. Documentation alignment + +- [ ] `CHANGELOG.md` has an entry under `[Unreleased]` describing every + observable behavior change since the last tag. +- [ ] `docs/Architecture.md` reflects new state accounts / instructions. +- [ ] `docs/Costs.md` benchmarks have been re-measured if any instruction + changed CU usage. +- [ ] `SECURITY.md` references the auditor that's about to look at this + revision (or notes the audit is in progress). +- [ ] `program/src/lib.rs` `security_txt!` block — `auditors:` field still + reflects the most recent finalised audit until this one ships. + +### 6. Diff bounded + +- [ ] Touched files are listed in the `Unreleased` CHANGELOG. +- [ ] Each touched file has a clear reason in the commit message that + introduced the change. + +```bash +git diff --name-only audit-frozen-v$(N-1)..HEAD | sort +``` + +### 7. Audit packet contents + +When you push the tag, also produce the audit packet: + +- [ ] PDF / Markdown summary of the changes since the last tag (1–2 pages). +- [ ] List of touched files with line counts. +- [ ] CHANGELOG diff. +- [ ] SHA-256 hashes of the `mainnet` and `devnet` SBF binaries. +- [ ] Output of `solana-verify get-program-hash` against the binaries (so + the auditor can verify they correspond to the source). +- [ ] The two binaries themselves, if the auditor wants byte-level review. +- [ ] Note any out-of-scope changes (typos, README edits, dependency bumps + that don't affect program logic). + +## Tagging + +When every box is checked: + +```bash +git tag -a audit-frozen-vN -m "audit-frozen state submitted for Accretion review N" +git push origin audit-frozen-vN +``` + +The push to origin triggers `.github/workflows/release.yml`, which builds +both feature variants, attaches the binaries + their hashes to a GitHub +Release, and emits the `solana-verify` build attestation for reproducibility. + +## Post-audit + +When the audit comes back with findings: + +1. Branch off the tagged state: `git checkout -b fix/audit-vN audit-frozen-vN`. +2. Land each fix as its own commit with a `Fixes: ` line. +3. After all findings are addressed, repeat this checklist and tag + `audit-frozen-vN.1` (or `vN+1` if the changes are substantial). +4. Update `program/src/lib.rs` `security_txt!` `auditors:` field to reference + the finalised audit report URL once the auditor publishes. diff --git a/docs/MAINNET_DEPLOY.md b/docs/MAINNET_DEPLOY.md new file mode 100644 index 0000000..ff13fb6 --- /dev/null +++ b/docs/MAINNET_DEPLOY.md @@ -0,0 +1,219 @@ +# Mainnet Deployment Runbook (Foundation Build) + +This runbook covers deploying `program-v2` (the no-fee foundation build) to the +LazorKit mainnet program slot, and the binary swap back to `lazorkit-protocol` +(the commercial build) at the end of the foundation contract. + +The same on-chain slot — `LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi` — hosts +both binaries at different times. dApp integrators keep using one stable +program ID through the transition; only the on-chain behavior changes when +the binary is swapped. + +## Why this scheme + +- **Foundation contract** requires a no-profit deploy (no admin, no protocol + fees). `program-v2` ships exactly that. +- **Brand continuity** — the mainnet program ID was published before the + foundation contract. Switching IDs would force every integrating dApp to + redeploy and migrate their on-chain references. +- **Reversibility** — Solana program upgrades replace the binary at a slot. + The same upgrade authority can swap from `program-v2` → `lazorkit-protocol` + in one transaction once the foundation contract ends. + +## Prerequisites + +- Solana CLI 3.0.4 (pinned in `Cargo.toml [workspace.metadata.cli]`). +- The mainnet program **upgrade authority** — a multisig keypair held jointly + by the foundation and the lazorkit team. (If a single key holds the + authority, transition to multisig before deploy — losing it locks the slot.) +- The mainnet program **address keypair** for `LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi` + (only needed for the *initial* deploy; subsequent upgrades use the upgrade + authority). +- A funded mainnet wallet for rent + transaction fees (~5 SOL leaves room). +- Audit sign-off on the exact source revision being deployed (see + `docs/AUDIT_PREP.md` for the pre-tag checklist). + +## Initial deploy (program-v2 → mainnet slot) + +1. Verify the source matches the audit-frozen tag: + + ```bash + git fetch --tags + git checkout audit-frozen-vN # whatever tag was audited + git status # must be clean + ``` + +2. Confirm the workspace is on the right toolchain: + + ```bash + cat Cargo.toml | grep -A1 'metadata.cli' # → solana = "3.0.4" + solana --version # → must be 3.0.4 + rustup show active-toolchain # matches rust-toolchain.toml + ``` + +3. Build the mainnet binary: + + ```bash + cd program + cargo build-sbf --features mainnet + cd .. + ``` + +4. Verify the embedded program ID: + + ```bash + solana-keygen pubkey target/deploy/lazorkit_program-keypair.json + # → must print LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi + ``` + + If the printed pubkey is anything else, the build picked up the wrong + keypair. Replace `target/deploy/lazorkit_program-keypair.json` with the + keypair file for `LazorjRF…` before deploying. **Do not deploy with a + mismatched ID** — the binary's compile-time `crate::ID` reads from + `assertions/src/lib.rs` (the `LazorjRF…` constant under the `mainnet` + feature) and will fail every PDA derivation if loaded into a different + slot. + +5. Record the binary hash for the release artifact: + + ```bash + shasum -a 256 target/deploy/lazorkit_program.so + # Compare against the hash published in the GitHub Release for this tag. + ``` + +6. Deploy: + + ```bash + solana program deploy target/deploy/lazorkit_program.so -u m \ + --program-id \ + --upgrade-authority + ``` + +7. Verify the deployed program: + + ```bash + solana program show LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi -u m + # Look for: ProgramData Address, Authority, Last Deployed In Slot, Data Len + ``` + + The `Authority` line must match the multisig pubkey that should retain + upgrade control. + +8. Smoke-test on-chain: + + - Create a wallet via the SDK pointing at the foundation flavor. + - Verify no fee transfer occurs (compare lamport balances pre/post). + - Run `solana account ` and confirm the discriminator is `1`. + +## Subsequent upgrades (program-v2 patch deploy) + +For a routine upgrade of the foundation build (e.g., a security fix): + +```bash +git checkout +cd program && cargo build-sbf --features mainnet && cd .. +solana program deploy target/deploy/lazorkit_program.so -u m \ + --program-id LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi \ + --upgrade-authority +``` + +Document each upgrade in `CHANGELOG.md` and update the on-chain `security.txt` +metadata via the next build (the macro embeds `GITHUB_SHA` automatically when +built in CI). + +## Binary swap at contract end (program-v2 → lazorkit-protocol) + +When the foundation contract ends and the slot needs to host the commercial +build again: + +1. In the **`lazorkit-protocol`** repo, check out the audit-frozen tag for + the commercial build: + + ```bash + cd ../lazorkit-protocol + git checkout audit-frozen-commercial-vN + ``` + +2. Build the commercial mainnet binary: + + ```bash + cd program && cargo build-sbf --features mainnet && cd .. + ``` + +3. Verify it embeds the same program ID (`LazorjRF…`): + + ```bash + solana-keygen pubkey target/deploy/lazorkit_program-keypair.json + ``` + +4. Deploy the upgrade. **No `--program-id` flag** — the slot already exists, + we're upgrading it: + + ```bash + solana program deploy target/deploy/lazorkit_program.so -u m \ + --program-id LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi \ + --upgrade-authority + ``` + +5. Initialise the protocol config (commercial-only instruction; no equivalent + exists in the foundation build, so this is a fresh state): + + ```bash + # Use the lazorkit-protocol SDK to send InitializeProtocol + InitializeTreasuryShard. + # This step is only needed the first time the commercial build is on this + # slot; subsequent upgrades preserve the existing ProtocolConfig PDA. + ``` + +6. Smoke-test: + + - Create a wallet — the fee transfer to the treasury shard should succeed. + - Verify the SDK consumer receives `protocolConfigPda` etc. without error. + +## Pre-existing wallet accounts across the swap + +Wallet, Vault, Authority, Session, and DeferredExec PDAs created under the +foundation build remain valid and accessible under the commercial build — +they all derive from the same program ID and the same seed schema, and the +account-discriminator layout is identical between the two builds. + +What changes: + +- Fee accounts (`ProtocolConfig`, `FeeRecord`, `TreasuryShard`) start + appearing once the commercial binary is live. Existing wallets continue to + work; the fee is charged on subsequent `CreateWallet` / `Execute` / + `ExecuteDeferred` calls if the SDK appends fee accounts. +- Sessions created with action permissions under the foundation build remain + enforced under the commercial build — action handling is identical. +- The on-chain `security.txt` updates to advertise `lazorkit-protocol` as the + source repo. Integrators querying it should re-fetch. + +## Rollback + +If a deployed upgrade misbehaves: + +```bash +solana program deploy target/deploy/.so -u m \ + --program-id LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi \ + --upgrade-authority +``` + +Solana program slots are upgrade-replaceable (so long as the upgrade +authority hasn't been frozen). Keep the previous mainnet binary archived +locally + in the GitHub Release for at least one audit cycle so a rollback +is one command, not a rebuild. + +## Locking the upgrade authority + +After the post-contract swap to `lazorkit-protocol` is stable and you no +longer want any further upgrades: + +```bash +solana program set-upgrade-authority \ + LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi \ + --upgrade-authority \ + --final +``` + +This is **irreversible**. Only do this after a long stability window and +explicit foundation/team agreement — once finalised, the binary cannot be +patched, security advisories included. From 9f77dcdb27dfe29636b5dd6647e6eb29fd83e63a Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Fri, 1 May 2026 02:41:24 +0700 Subject: [PATCH 11/11] ci(release): tagged-release workflow with verified-build hashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triggered by audit-frozen-v* and v*.*.* tags. Builds both mainnet and devnet SBF binaries, records SHA-256 hashes, asserts they differ (catches a regression of the dual-cluster mechanism), and uploads the binaries + IDL + a manifest as GitHub Release assets. Manifest captures the build environment (Solana CLI version, rustc version, commit SHA) and the reproduction commands so anyone can locally rebuild and confirm the published hashes match. audit-frozen-v* tags create draft + prerelease releases (the artifact is for the auditor, not for end-users); v*.*.* tags create normal releases. Production deploys must consume binaries from a release — see docs/MAINNET_DEPLOY.md. GITHUB_SHA + GITHUB_REF_NAME are passed through to cargo build-sbf so the embedded security.txt advertises the exact source revision. --- .github/workflows/release.yml | 153 ++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c78c27b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,153 @@ +name: release + +# Triggered by pushing an `audit-frozen-v*` tag (or a `v*` semver release tag). +# Builds the mainnet and devnet SBF binaries, records their SHA-256 hashes, +# and attaches the .so files + a manifest to the GitHub Release. +# +# The release artifacts are the source of truth for what gets deployed to +# mainnet — production deploys must use a binary downloaded from a release, +# not a locally-built one. See docs/MAINNET_DEPLOY.md. + +on: + push: + tags: + - 'audit-frozen-v*' + - 'v*.*.*' + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write # for creating GitHub Releases + + steps: + - uses: actions/checkout@v4 + with: + # Full history so source_revision in security_txt embeds the right SHA. + fetch-depth: 0 + + - name: Install Solana toolchain + run: | + sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)" + echo "$HOME/.local/share/solana/install/active_release/bin" >> "$GITHUB_PATH" + + - name: Pin Solana CLI to the version declared in Cargo.toml + run: | + DECLARED=$(grep -A1 'workspace.metadata.cli' Cargo.toml | grep solana | sed -E 's/.*"([^"]+)".*/\1/') + INSTALLED=$(solana --version | awk '{print $2}') + echo "Declared: $DECLARED" + echo "Installed: $INSTALLED" + if [ "$DECLARED" != "$INSTALLED" ]; then + echo "::warning::Installed Solana CLI ($INSTALLED) does not match declared ($DECLARED). Verified-build hashes may differ from what consumers reproduce." + fi + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: release-${{ hashFiles('**/Cargo.lock') }}-${{ github.ref_name }} + + - name: Build mainnet binary + working-directory: program + env: + GITHUB_SHA: ${{ github.sha }} + GITHUB_REF_NAME: ${{ github.ref_name }} + run: cargo build-sbf --features mainnet + + - name: Hash + stage mainnet artifact + run: | + mkdir -p release-artifacts + cp target/deploy/lazorkit_program.so release-artifacts/lazorkit_program-mainnet.so + MAINNET_SHA=$(shasum -a 256 release-artifacts/lazorkit_program-mainnet.so | awk '{print $1}') + echo "MAINNET_SHA=$MAINNET_SHA" >> "$GITHUB_ENV" + echo "mainnet sha256: $MAINNET_SHA" + + - name: Build devnet binary + working-directory: program + env: + GITHUB_SHA: ${{ github.sha }} + GITHUB_REF_NAME: ${{ github.ref_name }} + run: cargo build-sbf --features devnet + + - name: Hash + stage devnet artifact + run: | + cp target/deploy/lazorkit_program.so release-artifacts/lazorkit_program-devnet.so + DEVNET_SHA=$(shasum -a 256 release-artifacts/lazorkit_program-devnet.so | awk '{print $1}') + echo "DEVNET_SHA=$DEVNET_SHA" >> "$GITHUB_ENV" + echo "devnet sha256: $DEVNET_SHA" + + - name: Verify binaries differ + run: | + if [ "$MAINNET_SHA" = "$DEVNET_SHA" ]; then + echo "::error::mainnet and devnet binaries are identical — dual-cluster mechanism broken" + exit 1 + fi + + - name: Stage IDL + keypair + run: | + cp program/idl.json release-artifacts/idl.json + # The keypair file is regenerated per build; useful as a record but + # NOT for deployment (the actual mainnet keypair is held off-CI). + if [ -f target/deploy/lazorkit_program-keypair.json ]; then + cp target/deploy/lazorkit_program-keypair.json release-artifacts/build-keypair.json + fi + + - name: Write release manifest + run: | + cat > release-artifacts/MANIFEST.txt <