From 271664260180a72ee306fb965555a0b5b01c5448 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Thu, 30 Apr 2026 17:30:32 +0700 Subject: [PATCH 01/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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 < Date: Mon, 4 May 2026 14:19:33 +0700 Subject: [PATCH 12/25] refactor(tests-sdk): migrate from solita-client to @lazorkit/sdk-legacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches the integration test imports from the in-repo Solita-generated client to @lazorkit/sdk-legacy as a file: dependency, on the way to deleting sdk/solita-client entirely. Path changes: - '../../sdk/solita-client/src{,/utils/*,/generated/accounts}' → '@lazorkit/sdk-legacy' - PROGRAM_ID is now imported from ./common (which already hardcodes the foundation-devnet ID FLb7…) rather than from the SDK, since the SDK's exports differ between solita-client (single PROGRAM_ID) and sdk-legacy (PROGRAM_ID_MAINNET / _DEVNET / _FOUNDATION_DEVNET). API differences absorbed: - await added on async client.createWallet calls (sdk-legacy probes ProtocolConfig before building the tx; solita-client returned sync). API differences NOT yet absorbed (~100 type errors remaining; tracked as follow-up): - Standalone instruction builders (createCreateWalletIx, createExecuteIx, etc.) and PDA finders (findWalletPda, findAuthorityPda, etc.) now require an explicit `programId` arg in sdk-legacy (post lazorkit-protocol PR #9). Previously solita-client used an ambient PROGRAM_ID. Each call site needs PROGRAM_ID threaded through; that's a separate mechanical pass. The vitest suite will not pass until those ~100 call sites are updated. The sdk/solita-client directory deletion is gated on that completion. --- tests-sdk/package-lock.json | 90 +++++----------------- tests-sdk/package.json | 1 + tests-sdk/tests/01-wallet.test.ts | 13 ++-- tests-sdk/tests/02-authority.test.ts | 8 +- tests-sdk/tests/03-execute.test.ts | 6 +- tests-sdk/tests/04-session.test.ts | 6 +- tests-sdk/tests/05-replay.test.ts | 2 +- tests-sdk/tests/06-counter.test.ts | 4 +- tests-sdk/tests/07-e2e.test.ts | 6 +- tests-sdk/tests/08-deferred.test.ts | 4 +- tests-sdk/tests/09-permissions.test.ts | 6 +- tests-sdk/tests/10-session-execute.test.ts | 4 +- tests-sdk/tests/11-security.test.ts | 16 ++-- tests-sdk/tests/benchmark.ts | 2 +- tests-sdk/tests/devnet-smoke.ts | 6 +- tests-sdk/tests/secp256r1Utils.ts | 2 +- tests-sdk/tsconfig.json | 2 +- 17 files changed, 64 insertions(+), 114 deletions(-) diff --git a/tests-sdk/package-lock.json b/tests-sdk/package-lock.json index 872e8da..7a76e43 100644 --- a/tests-sdk/package-lock.json +++ b/tests-sdk/package-lock.json @@ -8,6 +8,7 @@ "name": "lazorkit-tests-sdk", "version": "1.0.0", "dependencies": { + "@lazorkit/sdk-legacy": "file:../../lazorkit-protocol/sdk/sdk-legacy", "@solana/web3.js": "^1.95.0", "ecdsa-secp256r1": "^1.3.3" }, @@ -17,6 +18,21 @@ "vitest": "^4.0.18" } }, + "../../lazorkit-protocol/sdk/sdk-legacy": { + "name": "@lazorkit/sdk-legacy", + "version": "0.2.0", + "license": "MIT", + "devDependencies": { + "@solana/web3.js": "^1.95.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@solana/web3.js": "^1.95.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", @@ -26,31 +42,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -69,6 +60,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@lazorkit/sdk-legacy": { + "resolved": "../../lazorkit-protocol/sdk/sdk-legacy", + "link": true + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", @@ -791,21 +786,6 @@ "ieee754": "^1.2.1" } }, - "node_modules/bufferutil": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", - "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1533,21 +1513,6 @@ "@types/node": "*" } }, - "node_modules/rpc-websockets/node_modules/utf-8-validate": { - "version": "6.0.6", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz", - "integrity": "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/rpc-websockets/node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -1744,21 +1709,6 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/tests-sdk/package.json b/tests-sdk/package.json index 8f1f03b..ba543a8 100644 --- a/tests-sdk/package.json +++ b/tests-sdk/package.json @@ -11,6 +11,7 @@ "pretest": "echo 'Ensure solana-test-validator is running with the program loaded'" }, "dependencies": { + "@lazorkit/sdk-legacy": "file:../../lazorkit-protocol/sdk/sdk-legacy", "@solana/web3.js": "^1.95.0", "ecdsa-secp256r1": "^1.3.3" }, diff --git a/tests-sdk/tests/01-wallet.test.ts b/tests-sdk/tests/01-wallet.test.ts index 3ddba4b..bb17233 100644 --- a/tests-sdk/tests/01-wallet.test.ts +++ b/tests-sdk/tests/01-wallet.test.ts @@ -1,15 +1,14 @@ import { describe, it, expect, beforeAll } from 'vitest'; import { Keypair } from '@solana/web3.js'; import * as crypto from 'crypto'; -import { setupTest, sendTx, type TestContext } from './common'; +import { setupTest, sendTx, PROGRAM_ID, type TestContext } from './common'; import { generateMockSecp256r1Key } from './secp256r1Utils'; import { LazorKitClient, AUTH_TYPE_ED25519, AUTH_TYPE_SECP256R1, - PROGRAM_ID, -} from '../../sdk/solita-client/src'; -import { AuthorityAccount } from '../../sdk/solita-client/src/generated/accounts'; + AuthorityAccount, +} from '@lazorkit/sdk-legacy'; describe('CreateWallet', () => { let ctx: TestContext; @@ -24,7 +23,7 @@ describe('CreateWallet', () => { const ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); - const { instructions, walletPda, authorityPda } = client.createWallet({ + const { instructions, walletPda, authorityPda } = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, @@ -52,7 +51,7 @@ describe('CreateWallet', () => { const key = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const { instructions, authorityPda } = client.createWallet({ + const { instructions, authorityPda } = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { @@ -79,7 +78,7 @@ describe('CreateWallet', () => { const ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); - const { instructions } = client.createWallet({ + const { instructions } = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, diff --git a/tests-sdk/tests/02-authority.test.ts b/tests-sdk/tests/02-authority.test.ts index d539ff5..dc75989 100644 --- a/tests-sdk/tests/02-authority.test.ts +++ b/tests-sdk/tests/02-authority.test.ts @@ -16,8 +16,8 @@ import { ROLE_SPENDER, ed25519, secp256r1, -} from '../../sdk/solita-client/src'; -import { AuthorityAccount } from '../../sdk/solita-client/src/generated/accounts'; +} from '@lazorkit/sdk-legacy'; +import { AuthorityAccount } from '@lazorkit/sdk-legacy'; describe('Authority Management', () => { let ctx: TestContext; @@ -37,7 +37,7 @@ describe('Authority Management', () => { ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, @@ -152,7 +152,7 @@ describe('Authority Management', () => { ownerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { diff --git a/tests-sdk/tests/03-execute.test.ts b/tests-sdk/tests/03-execute.test.ts index 0fbebb3..832edea 100644 --- a/tests-sdk/tests/03-execute.test.ts +++ b/tests-sdk/tests/03-execute.test.ts @@ -12,7 +12,7 @@ import { LazorKitClient, ed25519, secp256r1, -} from '../../sdk/solita-client/src'; +} from '@lazorkit/sdk-legacy'; describe('Execute', () => { let ctx: TestContext; @@ -33,7 +33,7 @@ describe('Execute', () => { ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, @@ -86,7 +86,7 @@ describe('Execute', () => { ownerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { diff --git a/tests-sdk/tests/04-session.test.ts b/tests-sdk/tests/04-session.test.ts index 4d5f66d..dbd0480 100644 --- a/tests-sdk/tests/04-session.test.ts +++ b/tests-sdk/tests/04-session.test.ts @@ -8,8 +8,8 @@ import { getSlot, type TestContext, } from './common'; -import { LazorKitClient, ed25519 } from '../../sdk/solita-client/src'; -import { SessionAccount } from '../../sdk/solita-client/src/generated/accounts'; +import { LazorKitClient, ed25519 } from '@lazorkit/sdk-legacy'; +import { SessionAccount } from '@lazorkit/sdk-legacy'; describe('CreateSession', () => { let ctx: TestContext; @@ -25,7 +25,7 @@ describe('CreateSession', () => { ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, diff --git a/tests-sdk/tests/05-replay.test.ts b/tests-sdk/tests/05-replay.test.ts index 5f2351b..50770e8 100644 --- a/tests-sdk/tests/05-replay.test.ts +++ b/tests-sdk/tests/05-replay.test.ts @@ -25,7 +25,7 @@ import { computeAccountsHash, AUTH_TYPE_SECP256R1, DISC_EXECUTE, -} from '../../sdk/solita-client/src'; +} from '@lazorkit/sdk-legacy'; describe('Replay Prevention (Odometer)', () => { let ctx: TestContext; diff --git a/tests-sdk/tests/06-counter.test.ts b/tests-sdk/tests/06-counter.test.ts index febc83f..6504cad 100644 --- a/tests-sdk/tests/06-counter.test.ts +++ b/tests-sdk/tests/06-counter.test.ts @@ -24,8 +24,8 @@ import { ROLE_SPENDER, DISC_ADD_AUTHORITY, DISC_EXECUTE, -} from '../../sdk/solita-client/src'; -import { AuthorityAccount } from '../../sdk/solita-client/src/generated/accounts'; +} from '@lazorkit/sdk-legacy'; +import { AuthorityAccount } from '@lazorkit/sdk-legacy'; describe('Counter Edge Cases', () => { let ctx: TestContext; diff --git a/tests-sdk/tests/07-e2e.test.ts b/tests-sdk/tests/07-e2e.test.ts index 7f2de23..3c59492 100644 --- a/tests-sdk/tests/07-e2e.test.ts +++ b/tests-sdk/tests/07-e2e.test.ts @@ -15,8 +15,8 @@ import { ROLE_SPENDER, ed25519, secp256r1, -} from '../../sdk/solita-client/src'; -import { AuthorityAccount } from '../../sdk/solita-client/src/generated/accounts'; +} from '@lazorkit/sdk-legacy'; +import { AuthorityAccount } from '@lazorkit/sdk-legacy'; /** * E2E Company Workflow: @@ -53,7 +53,7 @@ describe('E2E Company Workflow', () => { it('Step 1: CEO creates wallet with passkey', async () => { const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { diff --git a/tests-sdk/tests/08-deferred.test.ts b/tests-sdk/tests/08-deferred.test.ts index 41ffa7b..9baed7d 100644 --- a/tests-sdk/tests/08-deferred.test.ts +++ b/tests-sdk/tests/08-deferred.test.ts @@ -11,6 +11,7 @@ import { sendTx, sendTxExpectError, getSlot, + PROGRAM_ID, type TestContext, } from './common'; import { generateMockSecp256r1Key, signSecp256r1 } from './secp256r1Utils'; @@ -30,8 +31,7 @@ import { buildSecp256r1Challenge, AUTH_TYPE_SECP256R1, DISC_AUTHORIZE, - PROGRAM_ID, -} from '../../sdk/solita-client/src'; +} from '@lazorkit/sdk-legacy'; describe('Deferred Execution', () => { let ctx: TestContext; diff --git a/tests-sdk/tests/09-permissions.test.ts b/tests-sdk/tests/09-permissions.test.ts index 41338de..9d94d7a 100644 --- a/tests-sdk/tests/09-permissions.test.ts +++ b/tests-sdk/tests/09-permissions.test.ts @@ -26,7 +26,7 @@ import { ROLE_SPENDER, ed25519, secp256r1, -} from '../../sdk/solita-client/src'; +} from '@lazorkit/sdk-legacy'; describe('Permission Boundaries', () => { let ctx: TestContext; @@ -50,7 +50,7 @@ describe('Permission Boundaries', () => { ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); - const walletResult = client.createWallet({ + const walletResult = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, @@ -220,7 +220,7 @@ describe('Permission Boundaries', () => { secpOwnerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { diff --git a/tests-sdk/tests/10-session-execute.test.ts b/tests-sdk/tests/10-session-execute.test.ts index 46625f9..45c6ab1 100644 --- a/tests-sdk/tests/10-session-execute.test.ts +++ b/tests-sdk/tests/10-session-execute.test.ts @@ -25,7 +25,7 @@ import { LazorKitClient, ed25519, session, -} from '../../sdk/solita-client/src'; +} from '@lazorkit/sdk-legacy'; describe('Session Execute', () => { let ctx: TestContext; @@ -43,7 +43,7 @@ describe('Session Execute', () => { ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, diff --git a/tests-sdk/tests/11-security.test.ts b/tests-sdk/tests/11-security.test.ts index 7e8a072..a583355 100644 --- a/tests-sdk/tests/11-security.test.ts +++ b/tests-sdk/tests/11-security.test.ts @@ -41,7 +41,7 @@ import { packCompactInstructions, computeAccountsHash, DISC_EXECUTE, -} from '../../sdk/solita-client/src'; +} from '@lazorkit/sdk-legacy'; describe('Security', () => { let ctx: TestContext; @@ -63,7 +63,7 @@ describe('Security', () => { ownerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { @@ -154,7 +154,7 @@ describe('Security', () => { const ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, @@ -194,7 +194,7 @@ describe('Security', () => { // Create wallet A const ownerA = Keypair.generate(); const seedA = crypto.randomBytes(32); - const resultA = client.createWallet({ + const resultA = await client.createWallet({ payer: ctx.payer.publicKey, userSeed: seedA, owner: { type: 'ed25519', publicKey: ownerA.publicKey }, @@ -206,7 +206,7 @@ describe('Security', () => { // Create wallet B const ownerB = Keypair.generate(); const seedB = crypto.randomBytes(32); - const resultB = client.createWallet({ + const resultB = await client.createWallet({ payer: ctx.payer.publicKey, userSeed: seedB, owner: { type: 'ed25519', publicKey: ownerB.publicKey }, @@ -238,7 +238,7 @@ describe('Security', () => { const ownerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const result = client.createWallet({ + const result = await client.createWallet({ payer: ctx.payer.publicKey, userSeed, owner: { @@ -292,7 +292,7 @@ describe('Security', () => { // Build layout with recipientA const fixedAccounts = [ctx.payer.publicKey, result.walletPda, authorityPda, result.vaultPda, SYSVAR_INSTRUCTIONS_PUBKEY]; const { compactInstructions, remainingAccounts } = - (await import('../../sdk/solita-client/src/utils/compact')).buildCompactLayout(fixedAccounts, [transferIx]); + (await import('@lazorkit/sdk-legacy')).buildCompactLayout(fixedAccounts, [transferIx]); const packed = packCompactInstructions(compactInstructions); // Compute accounts hash with recipientA (the one we sign) @@ -306,7 +306,7 @@ describe('Security', () => { ]; const accountsHash = computeAccountsHash(allAccountMetas, compactInstructions); - const { concatParts } = await import('../../sdk/solita-client/src/utils/signing'); + const { concatParts } = await import('@lazorkit/sdk-legacy'); const signedPayload = concatParts([packed, accountsHash]); // Sign with the correct data (recipientA in accounts hash) diff --git a/tests-sdk/tests/benchmark.ts b/tests-sdk/tests/benchmark.ts index 9c14f81..7b071fe 100644 --- a/tests-sdk/tests/benchmark.ts +++ b/tests-sdk/tests/benchmark.ts @@ -43,7 +43,7 @@ import { createAuthorizeIx, createExecuteDeferredIx, computeInstructionsHash, -} from '../../sdk/solita-client/src'; +} from '@lazorkit/sdk-legacy'; import { generateMockSecp256r1Key, signSecp256r1 } from './secp256r1Utils'; const RPC_URL = process.env.RPC_URL || 'http://127.0.0.1:8899'; diff --git a/tests-sdk/tests/devnet-smoke.ts b/tests-sdk/tests/devnet-smoke.ts index 35b162b..295971c 100644 --- a/tests-sdk/tests/devnet-smoke.ts +++ b/tests-sdk/tests/devnet-smoke.ts @@ -24,7 +24,7 @@ import { session, ROLE_ADMIN, ROLE_SPENDER, -} from '../../sdk/solita-client/src'; +} from '@lazorkit/sdk-legacy'; import { generateMockSecp256r1Key, createMockSigner } from './secp256r1Utils'; const RPC_URL = process.env.RPC_URL || 'https://api.devnet.solana.com'; @@ -125,7 +125,7 @@ async function main() { { ed25519OwnerKp = Keypair.generate(); const balBefore = await connection.getBalance(payer.publicKey); - const { instructions, walletPda, vaultPda, authorityPda } = client.createWallet({ + const { instructions, walletPda, vaultPda, authorityPda } = await client.createWallet({ payer: payer.publicKey, userSeed: crypto.randomBytes(32), owner: { type: 'ed25519', publicKey: ed25519OwnerKp.publicKey }, @@ -144,7 +144,7 @@ async function main() { { secpOwnerKey = await generateMockSecp256r1Key('lazorkit.app'); const balBefore = await connection.getBalance(payer.publicKey); - const { instructions, walletPda, vaultPda, authorityPda } = client.createWallet({ + const { instructions, walletPda, vaultPda, authorityPda } = await client.createWallet({ payer: payer.publicKey, userSeed: crypto.randomBytes(32), owner: { diff --git a/tests-sdk/tests/secp256r1Utils.ts b/tests-sdk/tests/secp256r1Utils.ts index afbf989..aba0550 100644 --- a/tests-sdk/tests/secp256r1Utils.ts +++ b/tests-sdk/tests/secp256r1Utils.ts @@ -10,7 +10,7 @@ import { buildSecp256r1Challenge, generateAuthenticatorData, type Secp256r1Signer, -} from '../../sdk/solita-client/src/utils/secp256r1'; +} from '@lazorkit/sdk-legacy'; import { PROGRAM_ID } from './common'; const SECP256R1_PROGRAM_ID = new PublicKey('Secp256r1SigVerify1111111111111111111111111'); diff --git a/tests-sdk/tsconfig.json b/tests-sdk/tsconfig.json index 0e007c2..880eff3 100644 --- a/tests-sdk/tsconfig.json +++ b/tests-sdk/tsconfig.json @@ -10,5 +10,5 @@ "resolveJsonModule": true, "noEmit": true }, - "include": ["tests/**/*", "../sdk/solita-client/src/**/*"] + "include": ["tests/**/*"] } From e81f12658c4d00da5c885757b918202606fcce71 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Mon, 4 May 2026 14:27:50 +0700 Subject: [PATCH 13/25] refactor(tests-sdk): thread programId through call sites + adapt secp256r1 mocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final pass of the migration to @lazorkit/sdk-legacy. tsc now passes (0 errors). Three classes of fixes: 1. PDA finders (find{Wallet,Vault,Authority,Session,DeferredExec}Pda): sdk-legacy requires explicit programId after lazorkit-protocol PR #9. Threaded `PROGRAM_ID` (from ./common) through all call sites. 2. Instruction builders (createCreateWalletIx, createExecuteIx, etc.): Same — added `programId: PROGRAM_ID` to the object args of every call. 3. secp256r1 mock signer: sdk-legacy's WebAuthn signing flow embeds clientDataJson directly into the auth payload (vs solita-client's older typeAndFlags shortcut). Replaced secp256r1Utils.ts with the version from lazorkit-protocol's tests-sdk and imported the helpers from @lazorkit/sdk-legacy. Added backwards-compat aliases (createMockSigner = createMockRawSigner, signSecp256r1 = signSecp256r1Raw) so existing test code is unchanged. Other touch-ups: - tests-sdk/package.json description updated. - benchmark.ts: PROGRAM_ID moved to ./common import. - devnet-smoke.ts: missing await on client.executeDeferredFromPayload. The sdk/solita-client directory can now be deleted in a follow-up commit once vitest is run end-to-end against the migrated tests (requires a local-validator + the program-v2 SBF binary). --- tests-sdk/package.json | 2 +- tests-sdk/tests/05-replay.test.ts | 8 ++- tests-sdk/tests/06-counter.test.ts | 21 ++++--- tests-sdk/tests/08-deferred.test.ts | 51 ++++++++++++---- tests-sdk/tests/11-security.test.ts | 1 + tests-sdk/tests/benchmark.ts | 77 ++++++++++++++--------- tests-sdk/tests/devnet-smoke.ts | 2 +- tests-sdk/tests/secp256r1Utils.ts | 95 +++++++++++++++++++++-------- 8 files changed, 178 insertions(+), 79 deletions(-) diff --git a/tests-sdk/package.json b/tests-sdk/package.json index ba543a8..4d8f79c 100644 --- a/tests-sdk/package.json +++ b/tests-sdk/package.json @@ -2,7 +2,7 @@ "name": "lazorkit-tests-sdk", "version": "1.0.0", "private": true, - "description": "LazorKit SDK integration tests (solita-client + @solana/web3.js v1)", + "description": "LazorKit SDK integration tests (@lazorkit/sdk-legacy + @solana/web3.js v1)", "scripts": { "test": "vitest run --fileParallelism=false", "test:watch": "vitest --fileParallelism=false", diff --git a/tests-sdk/tests/05-replay.test.ts b/tests-sdk/tests/05-replay.test.ts index 50770e8..855a1f4 100644 --- a/tests-sdk/tests/05-replay.test.ts +++ b/tests-sdk/tests/05-replay.test.ts @@ -12,6 +12,7 @@ import { sendTx, sendTxExpectError, getSlot, + PROGRAM_ID, type TestContext, } from './common'; import { generateMockSecp256r1Key, signSecp256r1 } from './secp256r1Utils'; @@ -92,6 +93,7 @@ describe('Replay Prevention (Odometer)', () => { { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }); return { precompileIx, ix }; @@ -103,11 +105,12 @@ describe('Replay Prevention (Odometer)', () => { ownerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - [walletPda] = findWalletPda(userSeed); - [vaultPda] = findVaultPda(walletPda); + [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); const [authPda, authBump] = findAuthorityPda( walletPda, ownerKey.credentialIdHash, + PROGRAM_ID, ); ownerAuthorityPda = authPda; @@ -123,6 +126,7 @@ describe('Replay Prevention (Odometer)', () => { credentialOrPubkey: ownerKey.credentialIdHash, secp256r1Pubkey: ownerKey.publicKeyBytes, rpId: ownerKey.rpId, + programId: PROGRAM_ID, }), ]); diff --git a/tests-sdk/tests/06-counter.test.ts b/tests-sdk/tests/06-counter.test.ts index 6504cad..61ff9cd 100644 --- a/tests-sdk/tests/06-counter.test.ts +++ b/tests-sdk/tests/06-counter.test.ts @@ -7,7 +7,7 @@ import { type AccountMeta, } from '@solana/web3.js'; import * as crypto from 'crypto'; -import { setupTest, sendTx, getSlot, type TestContext } from './common'; +import { setupTest, sendTx, getSlot, PROGRAM_ID, type TestContext } from './common'; import { generateMockSecp256r1Key, signSecp256r1 } from './secp256r1Utils'; import { findWalletPda, @@ -39,11 +39,12 @@ describe('Counter Edge Cases', () => { const ownerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); const [ownerAuthPda, authBump] = findAuthorityPda( walletPda, ownerKey.credentialIdHash, + PROGRAM_ID, ); await sendTx(ctx, [ @@ -58,6 +59,7 @@ describe('Counter Edge Cases', () => { credentialOrPubkey: ownerKey.credentialIdHash, secp256r1Pubkey: ownerKey.publicKeyBytes, rpId: ownerKey.rpId, + programId: PROGRAM_ID, }), ]); @@ -73,7 +75,7 @@ describe('Counter Edge Cases', () => { // 1. AddAuthority (counter becomes 1) const adminKp = Keypair.generate(); const adminPubkey = adminKp.publicKey.toBytes(); - const [adminAuthPda] = findAuthorityPda(walletPda, adminPubkey); + const [adminAuthPda] = findAuthorityPda(walletPda, adminPubkey, PROGRAM_ID); const slot1 = await getSlot(ctx); const dataPayload = Buffer.concat([ @@ -108,6 +110,7 @@ describe('Counter Edge Cases', () => { newRole: ROLE_ADMIN, credentialOrPubkey: adminPubkey, authPayload: ap1, + programId: PROGRAM_ID, }), ]); @@ -174,6 +177,7 @@ describe('Counter Edge Cases', () => { }, { pubkey: execRecipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }), ]); @@ -190,11 +194,12 @@ describe('Counter Edge Cases', () => { const key2 = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); const [auth1Pda, auth1Bump] = findAuthorityPda( walletPda, key1.credentialIdHash, + PROGRAM_ID, ); // Create wallet with key1 as owner @@ -210,11 +215,12 @@ describe('Counter Edge Cases', () => { credentialOrPubkey: key1.credentialIdHash, secp256r1Pubkey: key1.publicKeyBytes, rpId: key1.rpId, + programId: PROGRAM_ID, }), ]); // Add key2 as spender via key1 (counter1 goes to 1) - const [auth2Pda] = findAuthorityPda(walletPda, key2.credentialIdHash); + const [auth2Pda] = findAuthorityPda(walletPda, key2.credentialIdHash, PROGRAM_ID); const slot = await getSlot(ctx); const rpIdBytes = Buffer.from(key2.rpId, 'utf-8'); @@ -255,6 +261,7 @@ describe('Counter Edge Cases', () => { secp256r1Pubkey: key2.publicKeyBytes, rpId: key2.rpId, authPayload, + programId: PROGRAM_ID, }), ]); diff --git a/tests-sdk/tests/08-deferred.test.ts b/tests-sdk/tests/08-deferred.test.ts index 9baed7d..777045c 100644 --- a/tests-sdk/tests/08-deferred.test.ts +++ b/tests-sdk/tests/08-deferred.test.ts @@ -50,12 +50,13 @@ describe('Deferred Execution', () => { ownerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - [walletPda] = findWalletPda(userSeed); - [vaultPda] = findVaultPda(walletPda); + [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); const [authPda, authBump] = findAuthorityPda( walletPda, ownerKey.credentialIdHash, - ); + PROGRAM_ID, + ); ownerAuthorityPda = authPda; await sendTx(ctx, [ @@ -70,6 +71,7 @@ describe('Deferred Execution', () => { credentialOrPubkey: ownerKey.credentialIdHash, secp256r1Pubkey: ownerKey.publicKeyBytes, rpId: ownerKey.rpId, + programId: PROGRAM_ID, }), ]); @@ -133,7 +135,8 @@ describe('Deferred Execution', () => { walletPda, ownerAuthorityPda, 1, - ); + PROGRAM_ID, + ); // === TX1: Authorize === const authorizeIx = createAuthorizeIx({ @@ -145,6 +148,7 @@ describe('Deferred Execution', () => { accountsHash, expiryOffset: 300, authPayload, + programId: PROGRAM_ID, }); await sendTx(ctx, [precompileIx, authorizeIx]); @@ -173,6 +177,7 @@ describe('Deferred Execution', () => { }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }); const balanceBefore = await ctx.connection.getBalance(recipient); @@ -252,7 +257,8 @@ describe('Deferred Execution', () => { walletPda, ownerAuthorityPda, 2, - ); + PROGRAM_ID, + ); // TX1: Authorize await sendTx(ctx, [ @@ -266,6 +272,7 @@ describe('Deferred Execution', () => { accountsHash, expiryOffset: 300, authPayload, + programId: PROGRAM_ID, }), ]); @@ -289,6 +296,7 @@ describe('Deferred Execution', () => { { pubkey: recipient2, isSigner: false, isWritable: true }, { pubkey: recipient3, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }), ]); @@ -312,12 +320,13 @@ describe('Deferred Execution', () => { ownerKey = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - [walletPda] = findWalletPda(userSeed); - [vaultPda] = findVaultPda(walletPda); + [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); const [authPda, authBump] = findAuthorityPda( walletPda, ownerKey.credentialIdHash, - ); + PROGRAM_ID, + ); ownerAuthorityPda = authPda; await sendTx(ctx, [ @@ -332,6 +341,7 @@ describe('Deferred Execution', () => { credentialOrPubkey: ownerKey.credentialIdHash, secp256r1Pubkey: ownerKey.publicKeyBytes, rpId: ownerKey.rpId, + programId: PROGRAM_ID, }), ]); @@ -386,7 +396,8 @@ describe('Deferred Execution', () => { walletPda, ownerAuthorityPda, 1, - ); + PROGRAM_ID, + ); // TX1: Authorize await sendTx(ctx, [ @@ -400,6 +411,7 @@ describe('Deferred Execution', () => { accountsHash, expiryOffset: 300, authPayload, + programId: PROGRAM_ID, }), ]); @@ -435,6 +447,7 @@ describe('Deferred Execution', () => { }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }), ], [], @@ -459,6 +472,7 @@ describe('Deferred Execution', () => { }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }), ]); }); @@ -506,7 +520,8 @@ describe('Deferred Execution', () => { walletPda, ownerAuthorityPda, 2, - ); + PROGRAM_ID, + ); // TX1: Authorize await sendTx(ctx, [ @@ -520,6 +535,7 @@ describe('Deferred Execution', () => { accountsHash, expiryOffset: 300, authPayload, + programId: PROGRAM_ID, }), ]); @@ -540,6 +556,7 @@ describe('Deferred Execution', () => { }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }); await sendTx(ctx, [executeDeferredIx]); @@ -561,6 +578,7 @@ describe('Deferred Execution', () => { }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }), ]); }); @@ -608,7 +626,8 @@ describe('Deferred Execution', () => { walletPda, ownerAuthorityPda, 3, - ); + PROGRAM_ID, + ); // TX1: Authorize with max expiry await sendTx(ctx, [ @@ -622,6 +641,7 @@ describe('Deferred Execution', () => { accountsHash, expiryOffset: 9000, // ~1 hour authPayload, + programId: PROGRAM_ID, }), ]); @@ -633,6 +653,7 @@ describe('Deferred Execution', () => { payer: ctx.payer.publicKey, deferredExecPda, refundDestination: ctx.payer.publicKey, + programId: PROGRAM_ID, }), ], [], @@ -657,6 +678,7 @@ describe('Deferred Execution', () => { }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }), ]); }); @@ -704,7 +726,8 @@ describe('Deferred Execution', () => { walletPda, ownerAuthorityPda, 4, - ); + PROGRAM_ID, + ); // TX1: Authorize with short expiry await sendTx(ctx, [ @@ -718,6 +741,7 @@ describe('Deferred Execution', () => { accountsHash, expiryOffset: 10, // minimum expiry authPayload, + programId: PROGRAM_ID, }), ]); @@ -742,6 +766,7 @@ describe('Deferred Execution', () => { payer: wrongPayer.publicKey, deferredExecPda, refundDestination: wrongPayer.publicKey, + programId: PROGRAM_ID, }), ], [wrongPayer], @@ -769,6 +794,7 @@ describe('Deferred Execution', () => { }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }), ]); } catch { @@ -778,6 +804,7 @@ describe('Deferred Execution', () => { payer: ctx.payer.publicKey, deferredExecPda, refundDestination: ctx.payer.publicKey, + programId: PROGRAM_ID, }), ]); } diff --git a/tests-sdk/tests/11-security.test.ts b/tests-sdk/tests/11-security.test.ts index a583355..269eb75 100644 --- a/tests-sdk/tests/11-security.test.ts +++ b/tests-sdk/tests/11-security.test.ts @@ -336,6 +336,7 @@ describe('Security', () => { packedInstructions: packed, authPayload, remainingAccounts: tamperedRemaining, + programId: PROGRAM_ID, }); // Should fail — accounts hash won't match diff --git a/tests-sdk/tests/benchmark.ts b/tests-sdk/tests/benchmark.ts index 7b071fe..c91b4e0 100644 --- a/tests-sdk/tests/benchmark.ts +++ b/tests-sdk/tests/benchmark.ts @@ -39,11 +39,11 @@ import { DISC_EXECUTE, DISC_AUTHORIZE, ROLE_ADMIN, - PROGRAM_ID, createAuthorizeIx, createExecuteDeferredIx, computeInstructionsHash, } from '@lazorkit/sdk-legacy'; +import { PROGRAM_ID } from './common'; import { generateMockSecp256r1Key, signSecp256r1 } from './secp256r1Utils'; const RPC_URL = process.env.RPC_URL || 'http://127.0.0.1:8899'; @@ -133,9 +133,9 @@ async function benchCreateWalletEd25519(connection: Connection, payer: Keypair): const userSeed = crypto.randomBytes(32); const pubkeyBytes = ownerKp.publicKey.toBytes(); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); - const [authPda, authBump] = findAuthorityPda(walletPda, pubkeyBytes); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); + const [authPda, authBump] = findAuthorityPda(walletPda, pubkeyBytes, PROGRAM_ID); const ix = createCreateWalletIx({ payer: payer.publicKey, @@ -146,6 +146,7 @@ async function benchCreateWalletEd25519(connection: Connection, payer: Keypair): authType: AUTH_TYPE_ED25519, authBump, credentialOrPubkey: pubkeyBytes, + programId: PROGRAM_ID, }); const result = await sendAndMeasure(connection, payer, [ix]); @@ -163,9 +164,9 @@ async function benchCreateWalletSecp256r1(connection: Connection, payer: Keypair const key = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); - const [authPda, authBump] = findAuthorityPda(walletPda, key.credentialIdHash); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); + const [authPda, authBump] = findAuthorityPda(walletPda, key.credentialIdHash, PROGRAM_ID); const ix = createCreateWalletIx({ payer: payer.publicKey, @@ -178,6 +179,7 @@ async function benchCreateWalletSecp256r1(connection: Connection, payer: Keypair credentialOrPubkey: key.credentialIdHash, secp256r1Pubkey: key.publicKeyBytes, rpId: key.rpId, + programId: PROGRAM_ID, }); const result = await sendAndMeasure(connection, payer, [ix]); @@ -197,9 +199,9 @@ async function benchAddAuthorityEd25519(connection: Connection, payer: Keypair): const userSeed = crypto.randomBytes(32); const pubkeyBytes = ownerKp.publicKey.toBytes(); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); - const [authPda, authBump] = findAuthorityPda(walletPda, pubkeyBytes); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); + const [authPda, authBump] = findAuthorityPda(walletPda, pubkeyBytes, PROGRAM_ID); await sendAndMeasure(connection, payer, [createCreateWalletIx({ payer: payer.publicKey, @@ -210,12 +212,13 @@ async function benchAddAuthorityEd25519(connection: Connection, payer: Keypair): authType: AUTH_TYPE_ED25519, authBump, credentialOrPubkey: pubkeyBytes, + programId: PROGRAM_ID, })]); // Now add a new Ed25519 authority (admin adds spender) const newKp = Keypair.generate(); const newPubkey = newKp.publicKey.toBytes(); - const [newAuthPda] = findAuthorityPda(walletPda, newPubkey); + const [newAuthPda] = findAuthorityPda(walletPda, newPubkey, PROGRAM_ID); const ix = createAddAuthorityIx({ payer: payer.publicKey, @@ -226,6 +229,7 @@ async function benchAddAuthorityEd25519(connection: Connection, payer: Keypair): newRole: ROLE_ADMIN, credentialOrPubkey: newPubkey, authorizerSigner: ownerKp.publicKey, + programId: PROGRAM_ID, }); const result = await sendAndMeasure(connection, payer, [ix], [ownerKp]); @@ -244,9 +248,9 @@ async function benchExecuteSecp256r1(connection: Connection, payer: Keypair): Pr const key = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); - const [authPda, authBump] = findAuthorityPda(walletPda, key.credentialIdHash); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); + const [authPda, authBump] = findAuthorityPda(walletPda, key.credentialIdHash, PROGRAM_ID); await sendAndMeasure(connection, payer, [createCreateWalletIx({ payer: payer.publicKey, @@ -259,6 +263,7 @@ async function benchExecuteSecp256r1(connection: Connection, payer: Keypair): Pr credentialOrPubkey: key.credentialIdHash, secp256r1Pubkey: key.publicKeyBytes, rpId: key.rpId, + programId: PROGRAM_ID, })]); // Fund vault @@ -318,6 +323,7 @@ async function benchExecuteSecp256r1(connection: Connection, payer: Keypair): Pr { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }); const result = await sendAndMeasure(connection, payer, [precompileIx, ix]); @@ -337,9 +343,9 @@ async function benchCreateSession(connection: Connection, payer: Keypair): Promi const userSeed = crypto.randomBytes(32); const pubkeyBytes = ownerKp.publicKey.toBytes(); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); - const [authPda, authBump] = findAuthorityPda(walletPda, pubkeyBytes); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); + const [authPda, authBump] = findAuthorityPda(walletPda, pubkeyBytes, PROGRAM_ID); await sendAndMeasure(connection, payer, [createCreateWalletIx({ payer: payer.publicKey, @@ -350,11 +356,12 @@ async function benchCreateSession(connection: Connection, payer: Keypair): Promi authType: AUTH_TYPE_ED25519, authBump, credentialOrPubkey: pubkeyBytes, + programId: PROGRAM_ID, })]); const sessionKp = Keypair.generate(); const sessionKeyBytes = sessionKp.publicKey.toBytes(); - const [sessionPda] = findSessionPda(walletPda, sessionKeyBytes); + const [sessionPda] = findSessionPda(walletPda, sessionKeyBytes, PROGRAM_ID); const currentSlot = await getSlot(connection); const expiresAt = currentSlot + 9000n; @@ -367,6 +374,7 @@ async function benchCreateSession(connection: Connection, payer: Keypair): Promi sessionKey: sessionKeyBytes, expiresAt, authorizerSigner: ownerKp.publicKey, + programId: PROGRAM_ID, }); const result = await sendAndMeasure(connection, payer, [ix], [ownerKp]); @@ -386,9 +394,9 @@ async function benchExecuteSession(connection: Connection, payer: Keypair): Prom const userSeed = crypto.randomBytes(32); const pubkeyBytes = ownerKp.publicKey.toBytes(); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); - const [authPda, authBump] = findAuthorityPda(walletPda, pubkeyBytes); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); + const [authPda, authBump] = findAuthorityPda(walletPda, pubkeyBytes, PROGRAM_ID); await sendAndMeasure(connection, payer, [createCreateWalletIx({ payer: payer.publicKey, @@ -399,12 +407,13 @@ async function benchExecuteSession(connection: Connection, payer: Keypair): Prom authType: AUTH_TYPE_ED25519, authBump, credentialOrPubkey: pubkeyBytes, + programId: PROGRAM_ID, })]); // Create session const sessionKp = Keypair.generate(); const sessionKeyBytes = sessionKp.publicKey.toBytes(); - const [sessionPda] = findSessionPda(walletPda, sessionKeyBytes); + const [sessionPda] = findSessionPda(walletPda, sessionKeyBytes, PROGRAM_ID); const currentSlot = await getSlot(connection); const expiresAt = currentSlot + 9000n; @@ -417,6 +426,7 @@ async function benchExecuteSession(connection: Connection, payer: Keypair): Prom sessionKey: sessionKeyBytes, expiresAt, authorizerSigner: ownerKp.publicKey, + programId: PROGRAM_ID, })], [ownerKp]); // Fund vault @@ -450,6 +460,7 @@ async function benchExecuteSession(connection: Connection, payer: Keypair): Prom { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }); const result = await sendAndMeasure(connection, payer, [ix], [sessionKp]); @@ -473,9 +484,9 @@ async function benchDeferredExecution( const key = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); - const [authPda, authBump] = findAuthorityPda(walletPda, key.credentialIdHash); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); + const [authPda, authBump] = findAuthorityPda(walletPda, key.credentialIdHash, PROGRAM_ID); await sendAndMeasure(connection, payer, [createCreateWalletIx({ payer: payer.publicKey, @@ -488,6 +499,7 @@ async function benchDeferredExecution( credentialOrPubkey: key.credentialIdHash, secp256r1Pubkey: key.publicKeyBytes, rpId: key.rpId, + programId: PROGRAM_ID, })]); // Fund vault @@ -536,7 +548,7 @@ async function benchDeferredExecution( sysvarIxIndex: 6, }); - const [deferredExecPda] = findDeferredExecPda(walletPda, authPda, 1); + const [deferredExecPda] = findDeferredExecPda(walletPda, authPda, 1, PROGRAM_ID); // === TX1: Authorize === const authorizeIx = createAuthorizeIx({ @@ -548,6 +560,7 @@ async function benchDeferredExecution( accountsHash, expiryOffset: 300, authPayload, + programId: PROGRAM_ID, }); const authResult = await sendAndMeasure(connection, payer, [precompileIx, authorizeIx]); @@ -573,6 +586,7 @@ async function benchDeferredExecution( { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, { pubkey: recipient, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }); const execResult = await sendAndMeasure(connection, payer, [executeDeferredIx]); @@ -596,9 +610,9 @@ async function benchDeferredMultiInstruction( const key = await generateMockSecp256r1Key(); const userSeed = crypto.randomBytes(32); - const [walletPda] = findWalletPda(userSeed); - const [vaultPda] = findVaultPda(walletPda); - const [authPda, authBump] = findAuthorityPda(walletPda, key.credentialIdHash); + const [walletPda] = findWalletPda(userSeed, PROGRAM_ID); + const [vaultPda] = findVaultPda(walletPda, PROGRAM_ID); + const [authPda, authBump] = findAuthorityPda(walletPda, key.credentialIdHash, PROGRAM_ID); await sendAndMeasure(connection, payer, [createCreateWalletIx({ payer: payer.publicKey, @@ -611,6 +625,7 @@ async function benchDeferredMultiInstruction( credentialOrPubkey: key.credentialIdHash, secp256r1Pubkey: key.publicKeyBytes, rpId: key.rpId, + programId: PROGRAM_ID, })]); // Fund vault @@ -663,7 +678,7 @@ async function benchDeferredMultiInstruction( sysvarIxIndex: 6, }); - const [deferredExecPda] = findDeferredExecPda(walletPda, authPda, 1); + const [deferredExecPda] = findDeferredExecPda(walletPda, authPda, 1, PROGRAM_ID); // TX1: Authorize const authorizeIx = createAuthorizeIx({ @@ -675,6 +690,7 @@ async function benchDeferredMultiInstruction( accountsHash, expiryOffset: 300, authPayload, + programId: PROGRAM_ID, }); const authResult = await sendAndMeasure(connection, payer, [precompileIx, authorizeIx]); @@ -702,6 +718,7 @@ async function benchDeferredMultiInstruction( { pubkey: recipient2, isSigner: false, isWritable: true }, { pubkey: recipient3, isSigner: false, isWritable: true }, ], + programId: PROGRAM_ID, }); const execResult = await sendAndMeasure(connection, payer, [executeDeferredIx]); diff --git a/tests-sdk/tests/devnet-smoke.ts b/tests-sdk/tests/devnet-smoke.ts index 295971c..866dd0f 100644 --- a/tests-sdk/tests/devnet-smoke.ts +++ b/tests-sdk/tests/devnet-smoke.ts @@ -449,7 +449,7 @@ async function main() { record('Authorize (Deferred TX1, Secp256r1)', r1); // TX2: ExecuteDeferred - const { instructions: execIxs } = client.executeDeferredFromPayload({ + const { instructions: execIxs } = await client.executeDeferredFromPayload({ payer: payer.publicKey, deferredPayload, }); diff --git a/tests-sdk/tests/secp256r1Utils.ts b/tests-sdk/tests/secp256r1Utils.ts index aba0550..4ff7e47 100644 --- a/tests-sdk/tests/secp256r1Utils.ts +++ b/tests-sdk/tests/secp256r1Utils.ts @@ -7,9 +7,11 @@ import { } from '@solana/web3.js'; import { buildAuthPayload, + buildAuthPayloadPrefix, buildSecp256r1Challenge, generateAuthenticatorData, type Secp256r1Signer, + type WebAuthnResponse, } from '@lazorkit/sdk-legacy'; import { PROGRAM_ID } from './common'; @@ -70,15 +72,10 @@ function bytesToBase64UrlNoPad(bytes: Uint8Array): string { } /** - * Creates a Secp256r1Signer from a mock key, compatible with LazorKitClient. - * - * The signer implements the full WebAuthn-compatible signing flow: - * 1. Generates authenticatorData from rpId - * 2. Builds clientDataJSON with the challenge - * 3. Signs authenticatorData + SHA256(clientDataJSON) - * 4. Returns { signature (low-S), authenticatorData, clientDataJsonHash } + * Creates a Secp256r1Signer that returns raw clientDataJSON — the only + * supported auth mode. Simulates a real browser authenticator. */ -export function createMockSigner(key: MockSecp256r1Key): Secp256r1Signer { +export function createMockRawSigner(key: MockSecp256r1Key): Secp256r1Signer { return { publicKeyBytes: key.publicKeyBytes, credentialIdHash: key.credentialIdHash, @@ -92,19 +89,49 @@ export function createMockSigner(key: MockSecp256r1Key): Secp256r1Signer { origin: `https://${key.rpId}`, crossOrigin: false, }); + const clientDataJsonBytes = new Uint8Array(Buffer.from(clientDataJson, 'utf-8')); const clientDataJsonHash = new Uint8Array( - crypto.createHash('sha256').update(clientDataJson).digest(), + crypto.createHash('sha256').update(clientDataJsonBytes).digest(), ); const messageToSign = Buffer.concat([authenticatorData, clientDataJsonHash]); const signatureBase64 = await key.privateKey.sign(Buffer.from(messageToSign)); const signature = enforceLowS(new Uint8Array(Buffer.from(signatureBase64, 'base64'))); - return { signature, authenticatorData, clientDataJsonHash }; + return { signature, authenticatorData, clientDataJsonHash, clientDataJson: clientDataJsonBytes }; }, }; } +/** + * Simulates what navigator.credentials.get() returns. + * Takes a challenge and returns a WebAuthn response — use this with the + * prepare/finalize flow to fake the browser authenticator step. + */ +export async function fakeWebAuthnSign( + key: MockSecp256r1Key, + challenge: Uint8Array, +): Promise { + const authenticatorData = generateAuthenticatorData(key.rpId); + + const clientDataJson = JSON.stringify({ + type: 'webauthn.get', + challenge: bytesToBase64UrlNoPad(challenge), + origin: `https://${key.rpId}`, + crossOrigin: false, + }); + const clientDataJsonBytes = new Uint8Array(Buffer.from(clientDataJson, 'utf-8')); + const clientDataJsonHash = new Uint8Array( + crypto.createHash('sha256').update(clientDataJsonBytes).digest(), + ); + + const messageToSign = Buffer.concat([authenticatorData, clientDataJsonHash]); + const signatureBase64 = await key.privateKey.sign(Buffer.from(messageToSign)); + const signature = enforceLowS(new Uint8Array(Buffer.from(signatureBase64, 'base64'))); + + return { signature, authenticatorData, clientDataJsonHash, clientDataJson: clientDataJsonBytes }; +} + /** * Full Secp256r1 signing flow for low-level tests. * Builds auth payload, computes challenge hash, signs it via WebAuthn-compatible @@ -112,7 +139,12 @@ export function createMockSigner(key: MockSecp256r1Key): Secp256r1Signer { * * Use `createMockSigner()` + `LazorKitClient` for simpler tests. */ -export async function signSecp256r1(params: { +/** + * Full Secp256r1 signing flow (raw clientDataJSON) for low-level tests. + * Uses the 14-byte prefix for challenge computation, then builds the full + * auth payload with authenticatorData + raw clientDataJSON. + */ +export async function signSecp256r1Raw(params: { key: MockSecp256r1Key; discriminator: Uint8Array; signedPayload: Uint8Array; @@ -126,21 +158,18 @@ export async function signSecp256r1(params: { precompileIx: TransactionInstruction; }> { const pid = params.programId ?? PROGRAM_ID; - const authenticatorData = generateAuthenticatorData(params.key.rpId); - // Build auth payload (optimized: no rpId, no slotHashes index, u32 counter) - const authPayload = buildAuthPayload({ + // Mode 1: Use only the 14-byte prefix for challenge computation + const challengePrefix = buildAuthPayloadPrefix({ slot: params.slot, counter: params.counter, sysvarIxIndex: params.sysvarIxIndex, - typeAndFlags: 0x10, // webauthn.get + https - authenticatorData, }); - // Compute challenge hash (7 elements) + // Compute challenge hash with the prefix (not the full payload) const challengeHash = buildSecp256r1Challenge({ discriminator: params.discriminator, - authPayload, + authPayload: challengePrefix, signedPayload: params.signedPayload, slot: params.slot, payer: params.payer, @@ -148,24 +177,34 @@ export async function signSecp256r1(params: { programId: pid, }); - // Build clientDataJSON and compute the actual message to sign + // Build authenticatorData and clientDataJSON + const authenticatorData = generateAuthenticatorData(params.key.rpId); + const clientDataJson = JSON.stringify({ type: 'webauthn.get', challenge: bytesToBase64UrlNoPad(challengeHash), origin: `https://${params.key.rpId}`, crossOrigin: false, }); - const clientDataJsonHash = crypto.createHash('sha256').update(clientDataJson).digest(); - - const messageToSign = Buffer.concat([ - authenticatorData, - clientDataJsonHash, - ]); + const clientDataJsonBytes = new Uint8Array(Buffer.from(clientDataJson, 'utf-8')); + const clientDataJsonHash = new Uint8Array( + crypto.createHash('sha256').update(clientDataJsonBytes).digest(), + ); - // Sign with ecdsa-secp256r1 + // Sign: authenticatorData || clientDataJsonHash + const messageToSign = Buffer.concat([authenticatorData, clientDataJsonHash]); const signatureBase64 = await params.key.privateKey.sign(Buffer.from(messageToSign)); const rawSig = enforceLowS(new Uint8Array(Buffer.from(signatureBase64, 'base64'))); + // Build full auth payload + const authPayload = buildAuthPayload({ + slot: params.slot, + counter: params.counter, + sysvarIxIndex: params.sysvarIxIndex, + authenticatorData, + clientDataJson: clientDataJsonBytes, + }); + // Build precompile instruction const precompileIx = buildPrecompileIx( params.key.publicKeyBytes, @@ -213,3 +252,7 @@ function buildPrecompileIx( data, }); } + +// ─── Backwards-compat aliases (program-v2 tests use older names) ───────── +export const createMockSigner = createMockRawSigner; +export const signSecp256r1 = signSecp256r1Raw; From 781f89e97db7aa429d97c4c90142757f2a609649 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Mon, 4 May 2026 14:28:37 +0700 Subject: [PATCH 14/25] chore: delete sdk/solita-client (replaced by @lazorkit/sdk-legacy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Solita-generated client is no longer needed. tests-sdk now depends on @lazorkit/sdk-legacy (file: link to ../../lazorkit-protocol/sdk/sdk-legacy during local dev; npm-published version after release). Anyone targeting program-v2 from TypeScript should: npm install @lazorkit/sdk-legacy The SDK probes the on-chain ProtocolConfig PDA on first use: - foundation binary at the slot → no PDA → no fee accounts → no fee - commercial binary at the slot → PDA present → fee accounts appended - same SDK code works against either binary, transparently scripts/build-all.sh and DEVELOPMENT.md still reference solita-client in places — separate cleanup commit follows. --- sdk/solita-client/README.md | 465 ----- sdk/solita-client/generate.mjs | 141 -- sdk/solita-client/idl-enriched.json | 680 ------- sdk/solita-client/package-lock.json | 1577 ----------------- sdk/solita-client/package.json | 21 - .../generated/accounts/AuthorityAccount.ts | 205 --- .../src/generated/accounts/SessionAccount.ts | 200 --- .../src/generated/accounts/WalletAccount.ts | 177 -- .../src/generated/accounts/index.ts | 9 - .../src/generated/errors/index.ts | 318 ---- sdk/solita-client/src/generated/index.ts | 21 - .../generated/instructions/AddAuthority.ts | 126 -- .../generated/instructions/CreateSession.ts | 122 -- .../generated/instructions/CreateWallet.ts | 116 -- .../src/generated/instructions/Execute.ts | 114 -- .../generated/instructions/RemoveAuthority.ts | 109 -- .../instructions/TransferOwnership.ts | 124 -- .../src/generated/instructions/index.ts | 6 - .../generated/types/AccountDiscriminator.ts | 24 - .../src/generated/types/AuthorityType.ts | 22 - sdk/solita-client/src/generated/types/Role.ts | 23 - .../src/generated/types/index.ts | 3 - sdk/solita-client/src/index.ts | 2 - sdk/solita-client/src/utils/client.ts | 694 -------- sdk/solita-client/src/utils/compact.ts | 66 - sdk/solita-client/src/utils/ed25519.ts | 10 - sdk/solita-client/src/utils/errors.ts | 56 - sdk/solita-client/src/utils/index.ts | 10 - sdk/solita-client/src/utils/instructions.ts | 531 ------ sdk/solita-client/src/utils/packing.ts | 77 - sdk/solita-client/src/utils/pdas.ts | 63 - sdk/solita-client/src/utils/secp256r1.ts | 128 -- sdk/solita-client/src/utils/signing.ts | 180 -- sdk/solita-client/src/utils/types.ts | 77 - sdk/solita-client/src/utils/wrapper.ts | 10 - sdk/solita-client/tsconfig.json | 17 - 36 files changed, 6524 deletions(-) delete mode 100644 sdk/solita-client/README.md delete mode 100644 sdk/solita-client/generate.mjs delete mode 100644 sdk/solita-client/idl-enriched.json delete mode 100644 sdk/solita-client/package-lock.json delete mode 100644 sdk/solita-client/package.json delete mode 100644 sdk/solita-client/src/generated/accounts/AuthorityAccount.ts delete mode 100644 sdk/solita-client/src/generated/accounts/SessionAccount.ts delete mode 100644 sdk/solita-client/src/generated/accounts/WalletAccount.ts delete mode 100644 sdk/solita-client/src/generated/accounts/index.ts delete mode 100644 sdk/solita-client/src/generated/errors/index.ts delete mode 100644 sdk/solita-client/src/generated/index.ts delete mode 100644 sdk/solita-client/src/generated/instructions/AddAuthority.ts delete mode 100644 sdk/solita-client/src/generated/instructions/CreateSession.ts delete mode 100644 sdk/solita-client/src/generated/instructions/CreateWallet.ts delete mode 100644 sdk/solita-client/src/generated/instructions/Execute.ts delete mode 100644 sdk/solita-client/src/generated/instructions/RemoveAuthority.ts delete mode 100644 sdk/solita-client/src/generated/instructions/TransferOwnership.ts delete mode 100644 sdk/solita-client/src/generated/instructions/index.ts delete mode 100644 sdk/solita-client/src/generated/types/AccountDiscriminator.ts delete mode 100644 sdk/solita-client/src/generated/types/AuthorityType.ts delete mode 100644 sdk/solita-client/src/generated/types/Role.ts delete mode 100644 sdk/solita-client/src/generated/types/index.ts delete mode 100644 sdk/solita-client/src/index.ts delete mode 100644 sdk/solita-client/src/utils/client.ts delete mode 100644 sdk/solita-client/src/utils/compact.ts delete mode 100644 sdk/solita-client/src/utils/ed25519.ts delete mode 100644 sdk/solita-client/src/utils/errors.ts delete mode 100644 sdk/solita-client/src/utils/index.ts delete mode 100644 sdk/solita-client/src/utils/instructions.ts delete mode 100644 sdk/solita-client/src/utils/packing.ts delete mode 100644 sdk/solita-client/src/utils/pdas.ts delete mode 100644 sdk/solita-client/src/utils/secp256r1.ts delete mode 100644 sdk/solita-client/src/utils/signing.ts delete mode 100644 sdk/solita-client/src/utils/types.ts delete mode 100644 sdk/solita-client/src/utils/wrapper.ts delete mode 100644 sdk/solita-client/tsconfig.json diff --git a/sdk/solita-client/README.md b/sdk/solita-client/README.md deleted file mode 100644 index 03206c5..0000000 --- a/sdk/solita-client/README.md +++ /dev/null @@ -1,465 +0,0 @@ -# @lazorkit/solita-client - -TypeScript SDK for the LazorKit smart wallet program on Solana. Built with `@solana/web3.js` v1 and Solita-generated instruction builders. - -## Installation - -```bash -npm install @lazorkit/solita-client -``` - -## Quick Start - -```typescript -import { Connection, Keypair, Transaction, sendAndConfirmTransaction } from '@solana/web3.js'; -import { LazorKitClient, ed25519, secp256r1, session, ROLE_ADMIN } from '@lazorkit/solita-client'; -import * as crypto from 'crypto'; - -const connection = new Connection('https://api.devnet.solana.com', 'confirmed'); -const client = new LazorKitClient(connection); - -// Create a wallet with Ed25519 owner -const owner = Keypair.generate(); -const { instructions, walletPda, vaultPda, authorityPda } = client.createWallet({ - payer: payer.publicKey, - userSeed: crypto.randomBytes(32), - owner: { type: 'ed25519', publicKey: owner.publicKey }, -}); -await sendAndConfirmTransaction(connection, new Transaction().add(...instructions), [payer]); - -// Or with Secp256r1 (passkey) owner -const { instructions: ixs2 } = client.createWallet({ - payer: payer.publicKey, - userSeed: crypto.randomBytes(32), - owner: { - type: 'secp256r1', - credentialIdHash, // 32-byte SHA256 of WebAuthn credential ID - compressedPubkey, // 33-byte compressed public key - rpId: 'your-app.com', - }, -}); -``` - -## API Reference - -### PDA Helpers - -```typescript -import { findWalletPda, findVaultPda, findAuthorityPda, findSessionPda, findDeferredExecPda } from '@lazorkit/solita-client'; - -// Derive wallet PDA from user seed -const [walletPda, walletBump] = findWalletPda(userSeed); - -// Derive vault PDA from wallet -const [vaultPda, vaultBump] = findVaultPda(walletPda); - -// Derive authority PDA from wallet + credential hash (or pubkey for Ed25519) -const [authorityPda, authBump] = findAuthorityPda(walletPda, credentialIdHash); - -// Derive session PDA from wallet + session key -const [sessionPda, sessionBump] = findSessionPda(walletPda, sessionKeyBytes); - -// Derive deferred execution PDA from wallet + authority + counter -const [deferredPda, deferredBump] = findDeferredExecPda(walletPda, authorityPda, counter); -``` - -### Instruction Builders - -Low-level builders that return `TransactionInstruction`: - -```typescript -import { - createCreateWalletIx, - createAddAuthorityIx, - createRemoveAuthorityIx, - createTransferOwnershipIx, - createExecuteIx, - createCreateSessionIx, - createAuthorizeIx, - createExecuteDeferredIx, - createReclaimDeferredIx, -} from '@lazorkit/solita-client'; -``` - -#### createCreateWalletIx - -```typescript -const ix = createCreateWalletIx({ - payer: PublicKey, - walletPda: PublicKey, - vaultPda: PublicKey, - authorityPda: PublicKey, - userSeed: Uint8Array, // 32 bytes - authType: number, // AUTH_TYPE_ED25519 (0) or AUTH_TYPE_SECP256R1 (1) - authBump: number, - credentialOrPubkey: Uint8Array, // Ed25519: 32-byte pubkey | Secp256r1: 32-byte credential_id_hash - secp256r1Pubkey?: Uint8Array, // Secp256r1 only: 33-byte compressed pubkey - rpId?: string, // Secp256r1 only: relying party ID (e.g., "lazorkit.app") -}); -``` - -#### createExecuteIx - -```typescript -const ix = createExecuteIx({ - payer: PublicKey, - walletPda: PublicKey, - authorityPda: PublicKey, - vaultPda: PublicKey, - packedInstructions: Uint8Array, // From packCompactInstructions() - authPayload?: Uint8Array, // Secp256r1 only - remainingAccounts?: AccountMeta[], -}); -``` - -#### createAuthorizeIx (Deferred Execution TX1) - -```typescript -const ix = createAuthorizeIx({ - payer: PublicKey, - walletPda: PublicKey, - authorityPda: PublicKey, - deferredExecPda: PublicKey, - instructionsHash: Uint8Array, // 32 bytes — SHA256 of packed compact instructions - accountsHash: Uint8Array, // 32 bytes — SHA256 of all referenced account pubkeys - expiryOffset: number, // Slots until expiry (10-9000) - authPayload: Uint8Array, // Secp256r1 auth payload -}); -``` - -#### createExecuteDeferredIx (Deferred Execution TX2) - -```typescript -const ix = createExecuteDeferredIx({ - payer: PublicKey, - walletPda: PublicKey, - vaultPda: PublicKey, - deferredExecPda: PublicKey, - refundDestination: PublicKey, - packedInstructions: Uint8Array, // From packCompactInstructions() - remainingAccounts?: AccountMeta[], // Inner accounts referenced by instructions -}); -``` - -#### createReclaimDeferredIx - -```typescript -const ix = createReclaimDeferredIx({ - payer: PublicKey, - deferredExecPda: PublicKey, - refundDestination: PublicKey, -}); -``` - -#### createRevokeSessionIx - -```typescript -const ix = createRevokeSessionIx({ - payer: PublicKey, - walletPda: PublicKey, - adminAuthorityPda: PublicKey, - sessionPda: PublicKey, - refundDestination: PublicKey, - authPayload?: Uint8Array, // Secp256r1 only - authorizerSigner?: PublicKey, // Ed25519 only -}); -``` - -### Compact Instruction Packing - -Pack multiple instructions for Execute: - -```typescript -import { packCompactInstructions, computeAccountsHash, computeInstructionsHash } from '@lazorkit/solita-client'; - -// Define compact instructions with account indexes (not pubkeys) -const packed = packCompactInstructions([{ - programIdIndex: 5, // Index of SystemProgram in accounts - accountIndexes: [3, 6], // vault (from), recipient (to) - data: transferData, -}]); - -// For Secp256r1: compute accounts hash for signature binding -const accountsHash = computeAccountsHash(accountMetas, compactInstructions); - -// For deferred execution: compute instructions hash (signed in TX1, verified in TX2) -const instructionsHash = computeInstructionsHash(compactInstructions); -``` - -### Secp256r1 (Passkey) Utilities - -```typescript -import { - readAuthorityCounter, - buildAuthPayload, - buildSecp256r1Challenge, - generateAuthenticatorData, - type Secp256r1Signer, -} from '@lazorkit/solita-client'; - -// Read current counter from on-chain authority account -const counter = await readAuthorityCounter(connection, authorityPda); - -// Generate WebAuthn authenticator data from RP ID (37 bytes: rpIdHash + flags + counter) -const authenticatorData = generateAuthenticatorData('lazorkit.app'); - -// Build auth payload for Secp256r1 signing -const authPayload = buildAuthPayload({ - slot: BigInt(currentSlot), - counter: counter + 1, // number (u32), not bigint - sysvarIxIndex: 4, - typeAndFlags: 0x10, // webauthn.get + https - authenticatorData: authData, // rpId is stored on-chain, not sent per-tx -}); - -// Build challenge hash (7 elements) -const challenge = buildSecp256r1Challenge({ - discriminator: new Uint8Array([4]), // Execute - authPayload, - signedPayload, - slot: BigInt(currentSlot), - payer: payerPublicKey, - counter: counter + 1, // number (u32) -}); -``` - -#### Secp256r1Signer Interface - -```typescript -interface Secp256r1Signer { - publicKeyBytes: Uint8Array; // 33-byte compressed pubkey - credentialIdHash: Uint8Array; // 32-byte SHA256 of credential ID - rpId: string; // e.g., "lazorkit.app" - sign(challenge: Uint8Array): Promise<{ - signature: Uint8Array; // 64-byte raw signature (r||s), low-S normalized - authenticatorData: Uint8Array; // WebAuthn authenticator data - clientDataJsonHash: Uint8Array; // SHA256 of clientDataJSON - }>; -} -``` - -### Signer Types (Discriminated Unions) - -The SDK uses discriminated union types for signers. Helper constructors make creation concise: - -```typescript -import { ed25519, secp256r1, session } from '@lazorkit/solita-client'; - -// Ed25519 — Keypair signs at transaction level -const signer = ed25519(ownerKp.publicKey, authorityPda); // authorityPda optional (auto-derived) - -// Secp256r1 — passkey/WebAuthn -const signer = secp256r1(myPasskeySigner, { authorityPda, slotOverride }); // both optional - -// Session — ephemeral key -const signer = session(sessionPda, sessionKp.publicKey); -``` - -**Type unions:** -- `AdminSigner` = `Ed25519SignerConfig | Secp256r1SignerConfig` -- for admin operations (addAuthority, removeAuthority, transferOwnership, createSession, revokeSession) -- `ExecuteSigner` = above + `SessionSignerConfig` -- for execute/transferSol - -### High-Level Client API - -Every method returns `{ instructions: TransactionInstruction[]; ...extraPdas }`. The client auto-derives PDAs, auto-fetches slots, auto-reads counters, auto-packs compact instructions, and auto-computes accounts hashes. - -```typescript -import { LazorKitClient, ed25519, secp256r1, session, ROLE_ADMIN, ROLE_SPENDER } from '@lazorkit/solita-client'; - -const client = new LazorKitClient(connection); - -// ── Read counter ── -const counter = await client.readCounter(authorityPda); - -// ── Create wallet (unified for both auth types) ── -const { instructions, walletPda, vaultPda, authorityPda } = client.createWallet({ - payer: payer.publicKey, - userSeed, - owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, - // or: owner: { type: 'secp256r1', credentialIdHash, compressedPubkey, rpId: 'app.com' }, -}); - -// ── Add authority (unified — works with any admin signer) ── -const { instructions, newAuthorityPda } = await client.addAuthority({ - payer: payer.publicKey, - walletPda, - adminSigner: ed25519(ownerKp.publicKey, ownerAuthPda), // or secp256r1(signer) - newAuthority: { type: 'ed25519', publicKey: adminKp.publicKey }, - role: ROLE_ADMIN, -}); - -// ── Remove authority ── -const { instructions } = await client.removeAuthority({ - payer: payer.publicKey, - walletPda, - adminSigner: ed25519(adminKp.publicKey, adminAuthPda), - targetAuthorityPda: spenderAuthPda, - // refundDestination defaults to payer -}); - -// ── Transfer ownership ── -const { instructions, newOwnerAuthorityPda } = await client.transferOwnership({ - payer: payer.publicKey, - walletPda, - ownerSigner: secp256r1(ceoSigner), - newOwner: { type: 'secp256r1', credentialIdHash, compressedPubkey, rpId }, -}); - -// ── Execute (unified — Ed25519, Secp256r1, or Session) ── -const [vault] = client.findVault(walletPda); -const { instructions } = await client.execute({ - payer: payer.publicKey, - walletPda, - signer: secp256r1(mySigner), // or ed25519(kp.publicKey) or session(sessionPda, sessionKp.publicKey) - instructions: [ - SystemProgram.transfer({ fromPubkey: vault, toPubkey: recipient, lamports: 1_000_000 }), - ], -}); -// Note: for Ed25519 add ownerKp to tx signers, for Session add sessionKp - -// ── Transfer SOL (convenience) ── -const { instructions } = await client.transferSol({ - payer: payer.publicKey, - walletPda, - signer: secp256r1(mySigner), - recipient, - lamports: 1_000_000n, -}); - -// ── Create session ── -const { instructions, sessionPda } = await client.createSession({ - payer: payer.publicKey, - walletPda, - adminSigner: ed25519(ownerKp.publicKey, ownerAuthPda), - sessionKey: sessionKp.publicKey, - expiresAt: currentSlot + 9000n, -}); - -// ── Deferred Execution — TX1 (Authorize) ── -const { instructions, deferredExecPda, deferredPayload } = await client.authorize({ - payer: payer.publicKey, - walletPda, - signer: secp256r1(mySigner), // Secp256r1 only - instructions: [jupiterSwapIx], - expiryOffset: 300, // ~2 minutes in slots -}); - -// ── Deferred Execution — TX2 (ExecuteDeferred) ── -const { instructions: tx2Ixs } = client.executeDeferredFromPayload({ - payer: payer.publicKey, - deferredPayload, // returned from authorize() - // refundDestination defaults to payer -}); - -// ── Reclaim expired DeferredExec (refund rent) ── -const { instructions } = client.reclaimDeferred({ - payer: payer.publicKey, - deferredExecPda, - // refundDestination defaults to payer -}); - -// ── Revoke session (close early, refund rent) ── -const { instructions } = await client.revokeSession({ - payer: payer.publicKey, - walletPda, - adminSigner: ed25519(ownerKp.publicKey, ownerAuthPda), // or secp256r1(signer) - sessionPda, - // refundDestination defaults to payer -}); -``` - -All Secp256r1 signers accept an optional `slotOverride` for batching scenarios. - -### Constants - -```typescript -// Instruction discriminators -DISC_CREATE_WALLET // 0 -DISC_ADD_AUTHORITY // 1 -DISC_REMOVE_AUTHORITY // 2 -DISC_TRANSFER_OWNERSHIP // 3 -DISC_EXECUTE // 4 -DISC_CREATE_SESSION // 5 -DISC_AUTHORIZE // 6 -DISC_EXECUTE_DEFERRED // 7 -DISC_RECLAIM_DEFERRED // 8 -DISC_REVOKE_SESSION // 9 - -// Auth types -AUTH_TYPE_ED25519 // 0 -AUTH_TYPE_SECP256R1 // 1 - -// Roles -ROLE_OWNER // 0 -ROLE_ADMIN // 1 -ROLE_SPENDER // 2 - -// Program ID -PROGRAM_ID // FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao -``` - -### Error Handling - -```typescript -import { extractErrorCode, ERROR_NAMES } from '@lazorkit/solita-client'; - -try { - await sendAndConfirmTransaction(connection, tx, [payer]); -} catch (err) { - const code = extractErrorCode(err); - if (code) { - console.log(`Error: ${ERROR_NAMES[code]} (${code})`); - } -} -``` - -Error codes: -| Code | Name | Description | -|------|------|-------------| -| 3001 | InvalidAuthorityPayload | Malformed auth payload | -| 3002 | PermissionDenied | Insufficient role permissions | -| 3003 | InvalidInstruction | Precompile instruction verification failed | -| 3004 | InvalidPubkey | Public key mismatch | -| 3005 | InvalidMessageHash | Challenge hash mismatch | -| 3006 | SignatureReused | Counter mismatch (replay attempt) | -| 3007 | InvalidSignatureAge | Slot too old (>150 slots from current) | -| 3008 | InvalidSessionDuration | Session expiry out of range | -| 3009 | SessionExpired | Session past expires_at slot | -| 3010 | AuthorityDoesNotSupportSession | N/A | -| 3011 | InvalidAuthenticationKind | Unknown authority_type | -| 3012 | InvalidMessage | N/A | -| 3013 | SelfReentrancyNotAllowed | CPI back into program rejected | -| 3014 | DeferredAuthorizationExpired | DeferredExec past expires_at slot | -| 3015 | DeferredHashMismatch | Instructions or accounts hash mismatch | -| 3016 | InvalidExpiryWindow | Expiry offset out of range (10-9000 slots) | -| 3017 | UnauthorizedReclaim | Only original payer can reclaim | -| 3018 | DeferredAuthorizationNotExpired | Cannot reclaim before expiry | -| 3019 | InvalidSessionAccount | Invalid session PDA during revocation | - -### Generated Accounts - -Solita-generated account classes with `fromAccountAddress()`: - -```typescript -import { WalletAccount, AuthorityAccount, SessionAccount } from '@lazorkit/solita-client'; - -const wallet = await WalletAccount.fromAccountAddress(connection, walletPda); -const authority = await AuthorityAccount.fromAccountAddress(connection, authorityPda); -const session = await SessionAccount.fromAccountAddress(connection, sessionPda); -``` - -## SDK Regeneration - -After modifying program instructions: - -```bash -# 1. Regenerate IDL -cd program && shank idl -o . --out-filename idl.json -p FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao - -# 2. Regenerate SDK -cd sdk/solita-client && node generate.mjs -``` - -## License - -MIT diff --git a/sdk/solita-client/generate.mjs b/sdk/solita-client/generate.mjs deleted file mode 100644 index 6d00486..0000000 --- a/sdk/solita-client/generate.mjs +++ /dev/null @@ -1,141 +0,0 @@ -/** - * LazorKit Solita Code Generation Script - * - * Reads the Shank IDL, enriches it with account types, error codes, - * and enum types, then generates TypeScript via Solita. - * - * Usage: node generate.mjs - */ -import { readFileSync, writeFileSync, mkdirSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { Solita } from '@metaplex-foundation/solita'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -// ─── 1. Read Shank IDL ─────────────────────────────────────────── -const idlPath = join(__dirname, '../../program/idl.json'); -const idl = JSON.parse(readFileSync(idlPath, 'utf-8')); -console.log('Read IDL from', idlPath); - -// ─── 2. Inject program address ────────────────────────────────── -idl.metadata = idl.metadata || {}; -idl.metadata.address = 'FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao'; - -// ─── 3. Add account types ─────────────────────────────────────── -idl.accounts = [ - { - name: 'WalletAccount', - type: { - kind: 'struct', - fields: [ - { name: 'discriminator', type: 'u8' }, - { name: 'bump', type: 'u8' }, - { name: 'version', type: 'u8' }, - { name: 'padding', type: { array: ['u8', 5] } }, - ], - }, - }, - { - name: 'AuthorityAccount', - type: { - kind: 'struct', - fields: [ - { name: 'discriminator', type: 'u8' }, - { name: 'authorityType', type: 'u8' }, - { name: 'role', type: 'u8' }, - { name: 'bump', type: 'u8' }, - { name: 'version', type: 'u8' }, - { name: 'padding', type: { array: ['u8', 3] } }, - { name: 'counter', type: 'u64' }, - { name: 'wallet', type: 'publicKey' }, - ], - }, - }, - { - name: 'SessionAccount', - type: { - kind: 'struct', - fields: [ - { name: 'discriminator', type: 'u8' }, - { name: 'bump', type: 'u8' }, - { name: 'version', type: 'u8' }, - { name: 'padding', type: { array: ['u8', 5] } }, - { name: 'wallet', type: 'publicKey' }, - { name: 'sessionKey', type: 'publicKey' }, - { name: 'expiresAt', type: 'u64' }, - ], - }, - }, -]; -console.log('Added 3 account types'); - -// ─── 4. Add error codes ───────────────────────────────────────── -idl.errors = [ - { code: 3001, name: 'InvalidAuthorityPayload', msg: 'Invalid authority payload' }, - { code: 3002, name: 'PermissionDenied', msg: 'Permission denied' }, - { code: 3003, name: 'InvalidInstruction', msg: 'Invalid instruction' }, - { code: 3004, name: 'InvalidPubkey', msg: 'Invalid public key' }, - { code: 3005, name: 'InvalidMessageHash', msg: 'Invalid message hash' }, - { code: 3006, name: 'SignatureReused', msg: 'Signature has already been used (counter mismatch)' }, - { code: 3007, name: 'InvalidSignatureAge', msg: 'Signature too old (outside 150-slot window)' }, - { code: 3008, name: 'InvalidSessionDuration', msg: 'Invalid session duration' }, - { code: 3009, name: 'SessionExpired', msg: 'Session has expired' }, - { code: 3010, name: 'AuthorityDoesNotSupportSession', msg: 'Authority type does not support sessions' }, - { code: 3011, name: 'InvalidAuthenticationKind', msg: 'Invalid authentication kind' }, - { code: 3012, name: 'InvalidMessage', msg: 'Invalid message' }, - { code: 3013, name: 'SelfReentrancyNotAllowed', msg: 'Self-reentrancy is not allowed' }, -]; -console.log('Added 13 error codes'); - -// ─── 5. Add enum types ────────────────────────────────────────── -if (!idl.types) idl.types = []; -idl.types.push( - { - name: 'AuthorityType', - type: { - kind: 'enum', - variants: [ - { name: 'Ed25519' }, - { name: 'Secp256r1' }, - ], - }, - }, - { - name: 'Role', - type: { - kind: 'enum', - variants: [ - { name: 'Owner' }, - { name: 'Admin' }, - { name: 'Spender' }, - ], - }, - }, - { - name: 'AccountDiscriminator', - type: { - kind: 'enum', - variants: [ - { name: 'Uninitialized' }, - { name: 'Wallet' }, - { name: 'Authority' }, - { name: 'Session' }, - ], - }, - }, -); -console.log('Added 3 enum types'); - -// ─── 6. Write enriched IDL ────────────────────────────────────── -const enrichedPath = join(__dirname, 'idl-enriched.json'); -writeFileSync(enrichedPath, JSON.stringify(idl, null, 2)); -console.log('Wrote enriched IDL to', enrichedPath); - -// ─── 7. Generate via Solita ───────────────────────────────────── -const outputDir = join(__dirname, 'src', 'generated'); -mkdirSync(outputDir, { recursive: true }); - -const gen = new Solita(idl, { programName: 'lazorkit_program', programId: idl.metadata.address }); -await gen.renderAndWriteTo(outputDir); -console.log('Generated TypeScript to', outputDir); diff --git a/sdk/solita-client/idl-enriched.json b/sdk/solita-client/idl-enriched.json deleted file mode 100644 index 0323563..0000000 --- a/sdk/solita-client/idl-enriched.json +++ /dev/null @@ -1,680 +0,0 @@ -{ - "version": "0.1.0", - "name": "lazorkit_program", - "instructions": [ - { - "name": "CreateWallet", - "accounts": [ - { - "name": "payer", - "isMut": true, - "isSigner": true, - "docs": [ - "Payer and rent contributor" - ] - }, - { - "name": "wallet", - "isMut": true, - "isSigner": false, - "docs": [ - "Wallet PDA" - ] - }, - { - "name": "vault", - "isMut": true, - "isSigner": false, - "docs": [ - "Vault PDA" - ] - }, - { - "name": "authority", - "isMut": true, - "isSigner": false, - "docs": [ - "Initial owner authority PDA" - ] - }, - { - "name": "systemProgram", - "isMut": false, - "isSigner": false, - "docs": [ - "System Program" - ] - } - ], - "args": [ - { - "name": "userSeed", - "type": "bytes" - }, - { - "name": "authType", - "type": "u8" - }, - { - "name": "authPubkey", - "type": { - "array": [ - "u8", - 33 - ] - } - }, - { - "name": "credentialHash", - "type": { - "array": [ - "u8", - 32 - ] - } - } - ], - "discriminant": { - "type": "u8", - "value": 0 - } - }, - { - "name": "AddAuthority", - "accounts": [ - { - "name": "payer", - "isMut": false, - "isSigner": true, - "docs": [ - "Transaction payer" - ] - }, - { - "name": "wallet", - "isMut": false, - "isSigner": false, - "docs": [ - "Wallet PDA" - ] - }, - { - "name": "adminAuthority", - "isMut": false, - "isSigner": true, - "docs": [ - "Admin authority PDA authorizing this action" - ] - }, - { - "name": "newAuthority", - "isMut": true, - "isSigner": false, - "docs": [ - "New authority PDA to be created" - ] - }, - { - "name": "systemProgram", - "isMut": false, - "isSigner": false, - "docs": [ - "System Program" - ] - }, - { - "name": "authorizerSigner", - "isMut": false, - "isSigner": true, - "isOptional": true, - "docs": [ - "Optional signer for Ed25519 authentication" - ] - } - ], - "args": [ - { - "name": "newType", - "type": "u8" - }, - { - "name": "newPubkey", - "type": { - "array": [ - "u8", - 33 - ] - } - }, - { - "name": "newHash", - "type": { - "array": [ - "u8", - 32 - ] - } - }, - { - "name": "newRole", - "type": "u8" - } - ], - "discriminant": { - "type": "u8", - "value": 1 - } - }, - { - "name": "RemoveAuthority", - "accounts": [ - { - "name": "payer", - "isMut": false, - "isSigner": true, - "docs": [ - "Transaction payer" - ] - }, - { - "name": "wallet", - "isMut": false, - "isSigner": false, - "docs": [ - "Wallet PDA" - ] - }, - { - "name": "adminAuthority", - "isMut": false, - "isSigner": true, - "docs": [ - "Admin authority PDA authorizing this action" - ] - }, - { - "name": "targetAuthority", - "isMut": true, - "isSigner": false, - "docs": [ - "Authority PDA to be removed" - ] - }, - { - "name": "refundDestination", - "isMut": true, - "isSigner": false, - "docs": [ - "Account to receive rent refund" - ] - }, - { - "name": "authorizerSigner", - "isMut": false, - "isSigner": true, - "isOptional": true, - "docs": [ - "Optional signer for Ed25519 authentication" - ] - } - ], - "args": [], - "discriminant": { - "type": "u8", - "value": 2 - } - }, - { - "name": "TransferOwnership", - "accounts": [ - { - "name": "payer", - "isMut": false, - "isSigner": true, - "docs": [ - "Transaction payer" - ] - }, - { - "name": "wallet", - "isMut": false, - "isSigner": false, - "docs": [ - "Wallet PDA" - ] - }, - { - "name": "currentOwnerAuthority", - "isMut": true, - "isSigner": false, - "docs": [ - "Current owner authority PDA" - ] - }, - { - "name": "newOwnerAuthority", - "isMut": true, - "isSigner": false, - "docs": [ - "New owner authority PDA to be created" - ] - }, - { - "name": "systemProgram", - "isMut": false, - "isSigner": false, - "docs": [ - "System Program" - ] - }, - { - "name": "authorizerSigner", - "isMut": false, - "isSigner": true, - "isOptional": true, - "docs": [ - "Optional signer for Ed25519 authentication" - ] - } - ], - "args": [ - { - "name": "newType", - "type": "u8" - }, - { - "name": "newPubkey", - "type": { - "array": [ - "u8", - 33 - ] - } - }, - { - "name": "newHash", - "type": { - "array": [ - "u8", - 32 - ] - } - } - ], - "discriminant": { - "type": "u8", - "value": 3 - } - }, - { - "name": "Execute", - "accounts": [ - { - "name": "payer", - "isMut": false, - "isSigner": true, - "docs": [ - "Transaction payer" - ] - }, - { - "name": "wallet", - "isMut": false, - "isSigner": false, - "docs": [ - "Wallet PDA" - ] - }, - { - "name": "authority", - "isMut": false, - "isSigner": false, - "docs": [ - "Authority or Session PDA authorizing execution" - ] - }, - { - "name": "vault", - "isMut": false, - "isSigner": false, - "docs": [ - "Vault PDA" - ] - }, - { - "name": "sysvarInstructions", - "isMut": false, - "isSigner": false, - "isOptional": true, - "docs": [ - "Sysvar Instructions (required for Secp256r1)" - ] - } - ], - "args": [ - { - "name": "instructions", - "type": "bytes" - } - ], - "discriminant": { - "type": "u8", - "value": 4 - } - }, - { - "name": "CreateSession", - "accounts": [ - { - "name": "payer", - "isMut": false, - "isSigner": true, - "docs": [ - "Transaction payer and rent contributor" - ] - }, - { - "name": "wallet", - "isMut": false, - "isSigner": false, - "docs": [ - "Wallet PDA" - ] - }, - { - "name": "adminAuthority", - "isMut": false, - "isSigner": true, - "docs": [ - "Admin/Owner authority PDA authorizing logic" - ] - }, - { - "name": "session", - "isMut": true, - "isSigner": false, - "docs": [ - "New session PDA to be created" - ] - }, - { - "name": "systemProgram", - "isMut": false, - "isSigner": false, - "docs": [ - "System Program" - ] - }, - { - "name": "authorizerSigner", - "isMut": false, - "isSigner": true, - "isOptional": true, - "docs": [ - "Optional signer for Ed25519 authentication" - ] - } - ], - "args": [ - { - "name": "sessionKey", - "type": { - "array": [ - "u8", - 32 - ] - } - }, - { - "name": "expiresAt", - "type": "i64" - } - ], - "discriminant": { - "type": "u8", - "value": 5 - } - } - ], - "metadata": { - "origin": "shank", - "address": "FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao" - }, - "accounts": [ - { - "name": "WalletAccount", - "type": { - "kind": "struct", - "fields": [ - { - "name": "discriminator", - "type": "u8" - }, - { - "name": "bump", - "type": "u8" - }, - { - "name": "version", - "type": "u8" - }, - { - "name": "padding", - "type": { - "array": [ - "u8", - 5 - ] - } - } - ] - } - }, - { - "name": "AuthorityAccount", - "type": { - "kind": "struct", - "fields": [ - { - "name": "discriminator", - "type": "u8" - }, - { - "name": "authorityType", - "type": "u8" - }, - { - "name": "role", - "type": "u8" - }, - { - "name": "bump", - "type": "u8" - }, - { - "name": "version", - "type": "u8" - }, - { - "name": "padding", - "type": { - "array": [ - "u8", - 3 - ] - } - }, - { - "name": "counter", - "type": "u64" - }, - { - "name": "wallet", - "type": "publicKey" - } - ] - } - }, - { - "name": "SessionAccount", - "type": { - "kind": "struct", - "fields": [ - { - "name": "discriminator", - "type": "u8" - }, - { - "name": "bump", - "type": "u8" - }, - { - "name": "version", - "type": "u8" - }, - { - "name": "padding", - "type": { - "array": [ - "u8", - 5 - ] - } - }, - { - "name": "wallet", - "type": "publicKey" - }, - { - "name": "sessionKey", - "type": "publicKey" - }, - { - "name": "expiresAt", - "type": "u64" - } - ] - } - } - ], - "errors": [ - { - "code": 3001, - "name": "InvalidAuthorityPayload", - "msg": "Invalid authority payload" - }, - { - "code": 3002, - "name": "PermissionDenied", - "msg": "Permission denied" - }, - { - "code": 3003, - "name": "InvalidInstruction", - "msg": "Invalid instruction" - }, - { - "code": 3004, - "name": "InvalidPubkey", - "msg": "Invalid public key" - }, - { - "code": 3005, - "name": "InvalidMessageHash", - "msg": "Invalid message hash" - }, - { - "code": 3006, - "name": "SignatureReused", - "msg": "Signature has already been used (counter mismatch)" - }, - { - "code": 3007, - "name": "InvalidSignatureAge", - "msg": "Signature too old (outside 150-slot window)" - }, - { - "code": 3008, - "name": "InvalidSessionDuration", - "msg": "Invalid session duration" - }, - { - "code": 3009, - "name": "SessionExpired", - "msg": "Session has expired" - }, - { - "code": 3010, - "name": "AuthorityDoesNotSupportSession", - "msg": "Authority type does not support sessions" - }, - { - "code": 3011, - "name": "InvalidAuthenticationKind", - "msg": "Invalid authentication kind" - }, - { - "code": 3012, - "name": "InvalidMessage", - "msg": "Invalid message" - }, - { - "code": 3013, - "name": "SelfReentrancyNotAllowed", - "msg": "Self-reentrancy is not allowed" - } - ], - "types": [ - { - "name": "AuthorityType", - "type": { - "kind": "enum", - "variants": [ - { - "name": "Ed25519" - }, - { - "name": "Secp256r1" - } - ] - } - }, - { - "name": "Role", - "type": { - "kind": "enum", - "variants": [ - { - "name": "Owner" - }, - { - "name": "Admin" - }, - { - "name": "Spender" - } - ] - } - }, - { - "name": "AccountDiscriminator", - "type": { - "kind": "enum", - "variants": [ - { - "name": "Uninitialized" - }, - { - "name": "Wallet" - }, - { - "name": "Authority" - }, - { - "name": "Session" - } - ] - } - } - ] -} \ No newline at end of file diff --git a/sdk/solita-client/package-lock.json b/sdk/solita-client/package-lock.json deleted file mode 100644 index 9bccf38..0000000 --- a/sdk/solita-client/package-lock.json +++ /dev/null @@ -1,1577 +0,0 @@ -{ - "name": "@lazorkit/solita-client", - "version": "0.2.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@lazorkit/solita-client", - "version": "0.2.0", - "dependencies": { - "@metaplex-foundation/beet": "^0.7.2", - "@metaplex-foundation/beet-solana": "^0.4.1", - "@solana/web3.js": "^1.95.0" - }, - "devDependencies": { - "@metaplex-foundation/solita": "^0.20.1", - "typescript": "^5.9.3" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@metaplex-foundation/beet": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@metaplex-foundation/beet/-/beet-0.7.2.tgz", - "integrity": "sha512-K+g3WhyFxKPc0xIvcIjNyV1eaTVJTiuaHZpig7Xx0MuYRMoJLLvhLTnUXhFdR5Tu2l2QSyKwfyXDgZlzhULqFg==", - "license": "Apache-2.0", - "dependencies": { - "ansicolors": "^0.3.2", - "assert": "^2.1.0", - "bn.js": "^5.2.0", - "debug": "^4.3.3" - } - }, - "node_modules/@metaplex-foundation/beet-solana": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@metaplex-foundation/beet-solana/-/beet-solana-0.4.1.tgz", - "integrity": "sha512-/6o32FNUtwK8tjhotrvU/vorP7umBuRFvBZrC6XCk51aKidBHe5LPVPA5AjGPbV3oftMfRuXPNd9yAGeEqeCDQ==", - "license": "Apache-2.0", - "dependencies": { - "@metaplex-foundation/beet": ">=0.1.0", - "@solana/web3.js": "^1.56.2", - "bs58": "^5.0.0", - "debug": "^4.3.4" - } - }, - "node_modules/@metaplex-foundation/rustbin": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@metaplex-foundation/rustbin/-/rustbin-0.3.5.tgz", - "integrity": "sha512-m0wkRBEQB/8krwMwKBvFugufZtYwMXiGHud2cTDAv+aGXK4M90y0Hx67/wpu+AqqoQfdV8VM9YezUOHKD+Z5kA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.3.3", - "semver": "^7.3.7", - "text-table": "^0.2.0", - "toml": "^3.0.0" - } - }, - "node_modules/@metaplex-foundation/solita": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@metaplex-foundation/solita/-/solita-0.20.1.tgz", - "integrity": "sha512-E2bHGzT6wA/sXWBLgJ50ZQNvukPnQlH6kRU6m6lmatJdEOjNWhR1lLI7ESIk/i4ZiSdHZkc/Q6ile8eIlXOzNQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@metaplex-foundation/beet": "^0.7.1", - "@metaplex-foundation/beet-solana": "^0.3.1", - "@metaplex-foundation/rustbin": "^0.3.0", - "@solana/web3.js": "^1.56.2", - "ansi-colors": "^4.1.3", - "camelcase": "^6.2.1", - "debug": "^4.3.3", - "js-sha256": "^0.9.0", - "prettier": "^2.5.1", - "snake-case": "^3.0.4", - "spok": "^1.4.3" - }, - "bin": { - "solita": "dist/src/cli/solita.js" - } - }, - "node_modules/@metaplex-foundation/solita/node_modules/@metaplex-foundation/beet-solana": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@metaplex-foundation/beet-solana/-/beet-solana-0.3.1.tgz", - "integrity": "sha512-tgyEl6dvtLln8XX81JyBvWjIiEcjTkUwZbrM5dIobTmoqMuGewSyk9CClno8qsMsFdB5T3jC91Rjeqmu/6xk2g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@metaplex-foundation/beet": ">=0.1.0", - "@solana/web3.js": "^1.56.2", - "bs58": "^5.0.0", - "debug": "^4.3.4" - } - }, - "node_modules/@noble/curves": { - "version": "1.9.7", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.8.0" - }, - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@solana/buffer-layout": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz", - "integrity": "sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==", - "license": "MIT", - "dependencies": { - "buffer": "~6.0.3" - }, - "engines": { - "node": ">=5.10" - } - }, - "node_modules/@solana/codecs-core": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.3.0.tgz", - "integrity": "sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==", - "license": "MIT", - "dependencies": { - "@solana/errors": "2.3.0" - }, - "engines": { - "node": ">=20.18.0" - }, - "peerDependencies": { - "typescript": ">=5.3.3" - } - }, - "node_modules/@solana/codecs-numbers": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.3.0.tgz", - "integrity": "sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==", - "license": "MIT", - "dependencies": { - "@solana/codecs-core": "2.3.0", - "@solana/errors": "2.3.0" - }, - "engines": { - "node": ">=20.18.0" - }, - "peerDependencies": { - "typescript": ">=5.3.3" - } - }, - "node_modules/@solana/errors": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-2.3.0.tgz", - "integrity": "sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==", - "license": "MIT", - "dependencies": { - "chalk": "^5.4.1", - "commander": "^14.0.0" - }, - "bin": { - "errors": "bin/cli.mjs" - }, - "engines": { - "node": ">=20.18.0" - }, - "peerDependencies": { - "typescript": ">=5.3.3" - } - }, - "node_modules/@solana/web3.js": { - "version": "1.98.4", - "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz", - "integrity": "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.0", - "@noble/curves": "^1.4.2", - "@noble/hashes": "^1.4.0", - "@solana/buffer-layout": "^4.0.1", - "@solana/codecs-numbers": "^2.1.0", - "agentkeepalive": "^4.5.0", - "bn.js": "^5.2.1", - "borsh": "^0.7.0", - "bs58": "^4.0.1", - "buffer": "6.0.3", - "fast-stable-stringify": "^1.0.0", - "jayson": "^4.1.1", - "node-fetch": "^2.7.0", - "rpc-websockets": "^9.0.2", - "superstruct": "^2.0.2" - } - }, - "node_modules/@solana/web3.js/node_modules/base-x": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", - "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/@solana/web3.js/node_modules/bs58": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", - "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", - "license": "MIT", - "dependencies": { - "base-x": "^3.0.2" - } - }, - "node_modules/@swc/helpers": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", - "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/node": { - "version": "12.20.55", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", - "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", - "license": "MIT" - }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "license": "MIT" - }, - "node_modules/@types/ws": { - "version": "7.4.7", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", - "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/ansicolors": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz", - "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", - "license": "MIT" - }, - "node_modules/assert": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", - "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "is-nan": "^1.3.2", - "object-is": "^1.1.5", - "object.assign": "^4.1.4", - "util": "^0.12.5" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/base-x": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", - "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==", - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bn.js": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", - "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", - "license": "MIT" - }, - "node_modules/borsh": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/borsh/-/borsh-0.7.0.tgz", - "integrity": "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==", - "license": "Apache-2.0", - "dependencies": { - "bn.js": "^5.2.0", - "bs58": "^4.0.0", - "text-encoding-utf-8": "^1.0.2" - } - }, - "node_modules/borsh/node_modules/base-x": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", - "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/borsh/node_modules/bs58": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", - "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", - "license": "MIT", - "dependencies": { - "base-x": "^3.0.2" - } - }, - "node_modules/bs58": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", - "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", - "license": "MIT", - "dependencies": { - "base-x": "^4.0.0" - } - }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/bufferutil": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", - "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/delay": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", - "integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es6-promise": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", - "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", - "license": "MIT" - }, - "node_modules/es6-promisify": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", - "integrity": "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==", - "license": "MIT", - "dependencies": { - "es6-promise": "^4.0.3" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, - "node_modules/eyes": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", - "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==", - "engines": { - "node": "> 0.1.90" - } - }, - "node_modules/fast-stable-stringify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz", - "integrity": "sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==", - "license": "MIT" - }, - "node_modules/find-process": { - "version": "1.4.11", - "resolved": "https://registry.npmjs.org/find-process/-/find-process-1.4.11.tgz", - "integrity": "sha512-mAOh9gGk9WZ4ip5UjV0o6Vb4SrfnAmtsFNzkMRH9HQiFXVQnDyQFrSHTK5UoG6E+KV+s+cIznbtwpfN41l2nFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "~4.1.2", - "commander": "^12.1.0", - "loglevel": "^1.9.2" - }, - "bin": { - "find-process": "bin/find-process.js" - } - }, - "node_modules/find-process/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/find-process/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-nan": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", - "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isomorphic-ws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", - "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", - "license": "MIT", - "peerDependencies": { - "ws": "*" - } - }, - "node_modules/jayson": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.3.0.tgz", - "integrity": "sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==", - "license": "MIT", - "dependencies": { - "@types/connect": "^3.4.33", - "@types/node": "^12.12.54", - "@types/ws": "^7.4.4", - "commander": "^2.20.3", - "delay": "^5.0.0", - "es6-promisify": "^5.0.0", - "eyes": "^0.1.8", - "isomorphic-ws": "^4.0.1", - "json-stringify-safe": "^5.0.1", - "stream-json": "^1.9.1", - "uuid": "^8.3.2", - "ws": "^7.5.10" - }, - "bin": { - "jayson": "bin/jayson.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jayson/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, - "node_modules/js-sha256": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", - "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC" - }, - "node_modules/loglevel": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", - "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - }, - "funding": { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/loglevel" - } - }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "optional": true, - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/rpc-websockets": { - "version": "9.3.8", - "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-9.3.8.tgz", - "integrity": "sha512-7r+fm4tSJmLf9GvZfL1DJ1SJwpagpp6AazqM0FUaeV7CA+7+NYINSk1syWa4tU/6OF2CyBicLtzENGmXRJH6wQ==", - "license": "LGPL-3.0-only", - "dependencies": { - "@swc/helpers": "^0.5.11", - "@types/uuid": "^10.0.0", - "@types/ws": "^8.2.2", - "buffer": "^6.0.3", - "eventemitter3": "^5.0.1", - "uuid": "^11.0.0", - "ws": "^8.5.0" - }, - "funding": { - "type": "paypal", - "url": "https://paypal.me/kozjak" - }, - "optionalDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^6.0.0" - } - }, - "node_modules/rpc-websockets/node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/rpc-websockets/node_modules/utf-8-validate": { - "version": "6.0.6", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz", - "integrity": "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/rpc-websockets/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, - "node_modules/rpc-websockets/node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/snake-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", - "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/spok": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/spok/-/spok-1.5.5.tgz", - "integrity": "sha512-IrJIXY54sCNFASyHPOY+jEirkiJ26JDqsGiI0Dvhwcnkl0PEWi1PSsrkYql0rzDw8LFVTcA7rdUCAJdE2HE+2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansicolors": "~0.3.2", - "find-process": "^1.4.7" - } - }, - "node_modules/stream-chain": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", - "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", - "license": "BSD-3-Clause" - }, - "node_modules/stream-json": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", - "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", - "license": "BSD-3-Clause", - "dependencies": { - "stream-chain": "^2.2.5" - } - }, - "node_modules/superstruct": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", - "integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/text-encoding-utf-8": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz", - "integrity": "sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==" - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, - "node_modules/toml": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", - "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/util": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - } - } -} diff --git a/sdk/solita-client/package.json b/sdk/solita-client/package.json deleted file mode 100644 index c83a671..0000000 --- a/sdk/solita-client/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "@lazorkit/solita-client", - "version": "0.2.0", - "description": "LazorKit Smart Wallet TypeScript SDK (Solita-generated + hand-written utils)", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "generate": "node generate.mjs", - "build": "tsc", - "clean": "rm -rf dist" - }, - "dependencies": { - "@solana/web3.js": "^1.95.0", - "@metaplex-foundation/beet": "^0.7.2", - "@metaplex-foundation/beet-solana": "^0.4.1" - }, - "devDependencies": { - "@metaplex-foundation/solita": "^0.20.1", - "typescript": "^5.9.3" - } -} diff --git a/sdk/solita-client/src/generated/accounts/AuthorityAccount.ts b/sdk/solita-client/src/generated/accounts/AuthorityAccount.ts deleted file mode 100644 index 4c51ffb..0000000 --- a/sdk/solita-client/src/generated/accounts/AuthorityAccount.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -import * as web3 from '@solana/web3.js'; -import * as beetSolana from '@metaplex-foundation/beet-solana'; - - - - -/** - * Arguments used to create {@link AuthorityAccount} - * @category Accounts - * @category generated - */ -export type AuthorityAccountArgs = { - discriminator: number - authorityType: number - role: number - bump: number - version: number - padding: number[] /* size: 3 */ - counter: beet.bignum - wallet: web3.PublicKey -} - -; -/** - * Holds the data for the {@link AuthorityAccount} Account and provides de/serialization - * functionality for that data - * - * @category Accounts - * @category generated - */ -export class AuthorityAccount implements AuthorityAccountArgs { - private constructor( - readonly discriminator: number, - readonly authorityType: number, - readonly role: number, - readonly bump: number, - readonly version: number, - readonly padding: number[] /* size: 3 */, - readonly counter: beet.bignum, - readonly wallet: web3.PublicKey - ) {} - - /** - * Creates a {@link AuthorityAccount} instance from the provided args. - */ - static fromArgs(args: AuthorityAccountArgs) { - return new AuthorityAccount( - args.discriminator, - args.authorityType, - args.role, - args.bump, - args.version, - args.padding, - args.counter, - args.wallet - ); - } - - /** - * Deserializes the {@link AuthorityAccount} from the data of the provided {@link web3.AccountInfo}. - * @returns a tuple of the account data and the offset up to which the buffer was read to obtain it. - */ - static fromAccountInfo( - accountInfo: web3.AccountInfo, - offset = 0 - ): [ AuthorityAccount, number ] { - return AuthorityAccount.deserialize(accountInfo.data, offset) - } - - /** - * Retrieves the account info from the provided address and deserializes - * the {@link AuthorityAccount} from its data. - * - * @throws Error if no account info is found at the address or if deserialization fails - */ - static async fromAccountAddress( - connection: web3.Connection, - address: web3.PublicKey, - commitmentOrConfig?: web3.Commitment | web3.GetAccountInfoConfig, - ): Promise { - const accountInfo = await connection.getAccountInfo(address, commitmentOrConfig); - if (accountInfo == null) { - throw new Error(`Unable to find AuthorityAccount account at ${address}`); - } - return AuthorityAccount.fromAccountInfo(accountInfo, 0)[0]; - } - - - /** - * Provides a {@link web3.Connection.getProgramAccounts} config builder, - * to fetch accounts matching filters that can be specified via that builder. - * - * @param programId - the program that owns the accounts we are filtering - */ - static gpaBuilder(programId: web3.PublicKey = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao')) { - return beetSolana.GpaBuilder.fromStruct(programId, authorityAccountBeet) - } - - /** - * Deserializes the {@link AuthorityAccount} from the provided data Buffer. - * @returns a tuple of the account data and the offset up to which the buffer was read to obtain it. - */ - static deserialize( - buf: Buffer, - offset = 0 - ): [ AuthorityAccount, number ]{ - return authorityAccountBeet.deserialize(buf, offset); - } - - /** - * Serializes the {@link AuthorityAccount} into a Buffer. - * @returns a tuple of the created Buffer and the offset up to which the buffer was written to store it. - */ - serialize(): [ Buffer, number ] { - return authorityAccountBeet.serialize(this) - } - - /** - * Returns the byteSize of a {@link Buffer} holding the serialized data of - * {@link AuthorityAccount} - */ - static get byteSize() { - return authorityAccountBeet.byteSize; - } - - /** - * Fetches the minimum balance needed to exempt an account holding - * {@link AuthorityAccount} data from rent - * - * @param connection used to retrieve the rent exemption information - */ - static async getMinimumBalanceForRentExemption( - connection: web3.Connection, - commitment?: web3.Commitment, - ): Promise { - return connection.getMinimumBalanceForRentExemption( - AuthorityAccount.byteSize, - commitment, - ); - } - - /** - * Determines if the provided {@link Buffer} has the correct byte size to - * hold {@link AuthorityAccount} data. - */ - static hasCorrectByteSize(buf: Buffer, offset = 0) { - return buf.byteLength - offset === AuthorityAccount.byteSize; - } - - /** - * Returns a readable version of {@link AuthorityAccount} properties - * and can be used to convert to JSON and/or logging - */ - pretty() { - return { - discriminator: this.discriminator, - authorityType: this.authorityType, - role: this.role, - bump: this.bump, - version: this.version, - padding: this.padding, - counter: (() => { - const x = <{ toNumber: () => number }>this.counter - if (typeof x.toNumber === 'function') { - try { - return x.toNumber() - } catch (_) { return x } - } - return x - })(), - wallet: this.wallet.toBase58() - }; - } -} - -/** - * @category Accounts - * @category generated - */ -export const authorityAccountBeet = new beet.BeetStruct< - AuthorityAccount, - AuthorityAccountArgs ->( - [ - - ['discriminator', beet.u8], - ['authorityType', beet.u8], - ['role', beet.u8], - ['bump', beet.u8], - ['version', beet.u8], - ['padding', beet.uniformFixedSizeArray(beet.u8, 3)], - ['counter', beet.u64], - ['wallet', beetSolana.publicKey] - ], - AuthorityAccount.fromArgs, - 'AuthorityAccount' -) \ No newline at end of file diff --git a/sdk/solita-client/src/generated/accounts/SessionAccount.ts b/sdk/solita-client/src/generated/accounts/SessionAccount.ts deleted file mode 100644 index e1efaca..0000000 --- a/sdk/solita-client/src/generated/accounts/SessionAccount.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as web3 from '@solana/web3.js'; -import * as beet from '@metaplex-foundation/beet'; -import * as beetSolana from '@metaplex-foundation/beet-solana'; - - - - -/** - * Arguments used to create {@link SessionAccount} - * @category Accounts - * @category generated - */ -export type SessionAccountArgs = { - discriminator: number - bump: number - version: number - padding: number[] /* size: 5 */ - wallet: web3.PublicKey - sessionKey: web3.PublicKey - expiresAt: beet.bignum -} - -; -/** - * Holds the data for the {@link SessionAccount} Account and provides de/serialization - * functionality for that data - * - * @category Accounts - * @category generated - */ -export class SessionAccount implements SessionAccountArgs { - private constructor( - readonly discriminator: number, - readonly bump: number, - readonly version: number, - readonly padding: number[] /* size: 5 */, - readonly wallet: web3.PublicKey, - readonly sessionKey: web3.PublicKey, - readonly expiresAt: beet.bignum - ) {} - - /** - * Creates a {@link SessionAccount} instance from the provided args. - */ - static fromArgs(args: SessionAccountArgs) { - return new SessionAccount( - args.discriminator, - args.bump, - args.version, - args.padding, - args.wallet, - args.sessionKey, - args.expiresAt - ); - } - - /** - * Deserializes the {@link SessionAccount} from the data of the provided {@link web3.AccountInfo}. - * @returns a tuple of the account data and the offset up to which the buffer was read to obtain it. - */ - static fromAccountInfo( - accountInfo: web3.AccountInfo, - offset = 0 - ): [ SessionAccount, number ] { - return SessionAccount.deserialize(accountInfo.data, offset) - } - - /** - * Retrieves the account info from the provided address and deserializes - * the {@link SessionAccount} from its data. - * - * @throws Error if no account info is found at the address or if deserialization fails - */ - static async fromAccountAddress( - connection: web3.Connection, - address: web3.PublicKey, - commitmentOrConfig?: web3.Commitment | web3.GetAccountInfoConfig, - ): Promise { - const accountInfo = await connection.getAccountInfo(address, commitmentOrConfig); - if (accountInfo == null) { - throw new Error(`Unable to find SessionAccount account at ${address}`); - } - return SessionAccount.fromAccountInfo(accountInfo, 0)[0]; - } - - - /** - * Provides a {@link web3.Connection.getProgramAccounts} config builder, - * to fetch accounts matching filters that can be specified via that builder. - * - * @param programId - the program that owns the accounts we are filtering - */ - static gpaBuilder(programId: web3.PublicKey = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao')) { - return beetSolana.GpaBuilder.fromStruct(programId, sessionAccountBeet) - } - - /** - * Deserializes the {@link SessionAccount} from the provided data Buffer. - * @returns a tuple of the account data and the offset up to which the buffer was read to obtain it. - */ - static deserialize( - buf: Buffer, - offset = 0 - ): [ SessionAccount, number ]{ - return sessionAccountBeet.deserialize(buf, offset); - } - - /** - * Serializes the {@link SessionAccount} into a Buffer. - * @returns a tuple of the created Buffer and the offset up to which the buffer was written to store it. - */ - serialize(): [ Buffer, number ] { - return sessionAccountBeet.serialize(this) - } - - /** - * Returns the byteSize of a {@link Buffer} holding the serialized data of - * {@link SessionAccount} - */ - static get byteSize() { - return sessionAccountBeet.byteSize; - } - - /** - * Fetches the minimum balance needed to exempt an account holding - * {@link SessionAccount} data from rent - * - * @param connection used to retrieve the rent exemption information - */ - static async getMinimumBalanceForRentExemption( - connection: web3.Connection, - commitment?: web3.Commitment, - ): Promise { - return connection.getMinimumBalanceForRentExemption( - SessionAccount.byteSize, - commitment, - ); - } - - /** - * Determines if the provided {@link Buffer} has the correct byte size to - * hold {@link SessionAccount} data. - */ - static hasCorrectByteSize(buf: Buffer, offset = 0) { - return buf.byteLength - offset === SessionAccount.byteSize; - } - - /** - * Returns a readable version of {@link SessionAccount} properties - * and can be used to convert to JSON and/or logging - */ - pretty() { - return { - discriminator: this.discriminator, - bump: this.bump, - version: this.version, - padding: this.padding, - wallet: this.wallet.toBase58(), - sessionKey: this.sessionKey.toBase58(), - expiresAt: (() => { - const x = <{ toNumber: () => number }>this.expiresAt - if (typeof x.toNumber === 'function') { - try { - return x.toNumber() - } catch (_) { return x } - } - return x - })() - }; - } -} - -/** - * @category Accounts - * @category generated - */ -export const sessionAccountBeet = new beet.BeetStruct< - SessionAccount, - SessionAccountArgs ->( - [ - - ['discriminator', beet.u8], - ['bump', beet.u8], - ['version', beet.u8], - ['padding', beet.uniformFixedSizeArray(beet.u8, 5)], - ['wallet', beetSolana.publicKey], - ['sessionKey', beetSolana.publicKey], - ['expiresAt', beet.u64] - ], - SessionAccount.fromArgs, - 'SessionAccount' -) \ No newline at end of file diff --git a/sdk/solita-client/src/generated/accounts/WalletAccount.ts b/sdk/solita-client/src/generated/accounts/WalletAccount.ts deleted file mode 100644 index d2af9eb..0000000 --- a/sdk/solita-client/src/generated/accounts/WalletAccount.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -import * as web3 from '@solana/web3.js'; -import * as beetSolana from '@metaplex-foundation/beet-solana'; - - - - -/** - * Arguments used to create {@link WalletAccount} - * @category Accounts - * @category generated - */ -export type WalletAccountArgs = { - discriminator: number - bump: number - version: number - padding: number[] /* size: 5 */ -} - -; -/** - * Holds the data for the {@link WalletAccount} Account and provides de/serialization - * functionality for that data - * - * @category Accounts - * @category generated - */ -export class WalletAccount implements WalletAccountArgs { - private constructor( - readonly discriminator: number, - readonly bump: number, - readonly version: number, - readonly padding: number[] /* size: 5 */ - ) {} - - /** - * Creates a {@link WalletAccount} instance from the provided args. - */ - static fromArgs(args: WalletAccountArgs) { - return new WalletAccount( - args.discriminator, - args.bump, - args.version, - args.padding - ); - } - - /** - * Deserializes the {@link WalletAccount} from the data of the provided {@link web3.AccountInfo}. - * @returns a tuple of the account data and the offset up to which the buffer was read to obtain it. - */ - static fromAccountInfo( - accountInfo: web3.AccountInfo, - offset = 0 - ): [ WalletAccount, number ] { - return WalletAccount.deserialize(accountInfo.data, offset) - } - - /** - * Retrieves the account info from the provided address and deserializes - * the {@link WalletAccount} from its data. - * - * @throws Error if no account info is found at the address or if deserialization fails - */ - static async fromAccountAddress( - connection: web3.Connection, - address: web3.PublicKey, - commitmentOrConfig?: web3.Commitment | web3.GetAccountInfoConfig, - ): Promise { - const accountInfo = await connection.getAccountInfo(address, commitmentOrConfig); - if (accountInfo == null) { - throw new Error(`Unable to find WalletAccount account at ${address}`); - } - return WalletAccount.fromAccountInfo(accountInfo, 0)[0]; - } - - - /** - * Provides a {@link web3.Connection.getProgramAccounts} config builder, - * to fetch accounts matching filters that can be specified via that builder. - * - * @param programId - the program that owns the accounts we are filtering - */ - static gpaBuilder(programId: web3.PublicKey = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao')) { - return beetSolana.GpaBuilder.fromStruct(programId, walletAccountBeet) - } - - /** - * Deserializes the {@link WalletAccount} from the provided data Buffer. - * @returns a tuple of the account data and the offset up to which the buffer was read to obtain it. - */ - static deserialize( - buf: Buffer, - offset = 0 - ): [ WalletAccount, number ]{ - return walletAccountBeet.deserialize(buf, offset); - } - - /** - * Serializes the {@link WalletAccount} into a Buffer. - * @returns a tuple of the created Buffer and the offset up to which the buffer was written to store it. - */ - serialize(): [ Buffer, number ] { - return walletAccountBeet.serialize(this) - } - - /** - * Returns the byteSize of a {@link Buffer} holding the serialized data of - * {@link WalletAccount} - */ - static get byteSize() { - return walletAccountBeet.byteSize; - } - - /** - * Fetches the minimum balance needed to exempt an account holding - * {@link WalletAccount} data from rent - * - * @param connection used to retrieve the rent exemption information - */ - static async getMinimumBalanceForRentExemption( - connection: web3.Connection, - commitment?: web3.Commitment, - ): Promise { - return connection.getMinimumBalanceForRentExemption( - WalletAccount.byteSize, - commitment, - ); - } - - /** - * Determines if the provided {@link Buffer} has the correct byte size to - * hold {@link WalletAccount} data. - */ - static hasCorrectByteSize(buf: Buffer, offset = 0) { - return buf.byteLength - offset === WalletAccount.byteSize; - } - - /** - * Returns a readable version of {@link WalletAccount} properties - * and can be used to convert to JSON and/or logging - */ - pretty() { - return { - discriminator: this.discriminator, - bump: this.bump, - version: this.version, - padding: this.padding - }; - } -} - -/** - * @category Accounts - * @category generated - */ -export const walletAccountBeet = new beet.BeetStruct< - WalletAccount, - WalletAccountArgs ->( - [ - - ['discriminator', beet.u8], - ['bump', beet.u8], - ['version', beet.u8], - ['padding', beet.uniformFixedSizeArray(beet.u8, 5)] - ], - WalletAccount.fromArgs, - 'WalletAccount' -) \ No newline at end of file diff --git a/sdk/solita-client/src/generated/accounts/index.ts b/sdk/solita-client/src/generated/accounts/index.ts deleted file mode 100644 index a358a94..0000000 --- a/sdk/solita-client/src/generated/accounts/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from './AuthorityAccount'; -export * from './SessionAccount'; -export * from './WalletAccount'; - -import { WalletAccount } from './WalletAccount' -import { AuthorityAccount } from './AuthorityAccount' -import { SessionAccount } from './SessionAccount' - -export const accountProviders = { WalletAccount, AuthorityAccount, SessionAccount } \ No newline at end of file diff --git a/sdk/solita-client/src/generated/errors/index.ts b/sdk/solita-client/src/generated/errors/index.ts deleted file mode 100644 index 596c4e4..0000000 --- a/sdk/solita-client/src/generated/errors/index.ts +++ /dev/null @@ -1,318 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -type ErrorWithCode = Error & { code: number } -type MaybeErrorWithCode = ErrorWithCode | null | undefined - -const createErrorFromCodeLookup: Map ErrorWithCode> = new Map(); -const createErrorFromNameLookup: Map ErrorWithCode> = new Map(); - - -/** - * InvalidAuthorityPayload: 'Invalid authority payload' - * - * @category Errors - * @category generated - */ -export class InvalidAuthorityPayloadError extends Error { - readonly code: number = 0xbb9; - readonly name: string = 'InvalidAuthorityPayload'; - constructor() { - super('Invalid authority payload'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, InvalidAuthorityPayloadError); - } - } -} - -createErrorFromCodeLookup.set(0xbb9, () => new InvalidAuthorityPayloadError()) -createErrorFromNameLookup.set('InvalidAuthorityPayload', () => new InvalidAuthorityPayloadError()) - - - -/** - * PermissionDenied: 'Permission denied' - * - * @category Errors - * @category generated - */ -export class PermissionDeniedError extends Error { - readonly code: number = 0xbba; - readonly name: string = 'PermissionDenied'; - constructor() { - super('Permission denied'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, PermissionDeniedError); - } - } -} - -createErrorFromCodeLookup.set(0xbba, () => new PermissionDeniedError()) -createErrorFromNameLookup.set('PermissionDenied', () => new PermissionDeniedError()) - - - -/** - * InvalidInstruction: 'Invalid instruction' - * - * @category Errors - * @category generated - */ -export class InvalidInstructionError extends Error { - readonly code: number = 0xbbb; - readonly name: string = 'InvalidInstruction'; - constructor() { - super('Invalid instruction'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, InvalidInstructionError); - } - } -} - -createErrorFromCodeLookup.set(0xbbb, () => new InvalidInstructionError()) -createErrorFromNameLookup.set('InvalidInstruction', () => new InvalidInstructionError()) - - - -/** - * InvalidPubkey: 'Invalid public key' - * - * @category Errors - * @category generated - */ -export class InvalidPubkeyError extends Error { - readonly code: number = 0xbbc; - readonly name: string = 'InvalidPubkey'; - constructor() { - super('Invalid public key'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, InvalidPubkeyError); - } - } -} - -createErrorFromCodeLookup.set(0xbbc, () => new InvalidPubkeyError()) -createErrorFromNameLookup.set('InvalidPubkey', () => new InvalidPubkeyError()) - - - -/** - * InvalidMessageHash: 'Invalid message hash' - * - * @category Errors - * @category generated - */ -export class InvalidMessageHashError extends Error { - readonly code: number = 0xbbd; - readonly name: string = 'InvalidMessageHash'; - constructor() { - super('Invalid message hash'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, InvalidMessageHashError); - } - } -} - -createErrorFromCodeLookup.set(0xbbd, () => new InvalidMessageHashError()) -createErrorFromNameLookup.set('InvalidMessageHash', () => new InvalidMessageHashError()) - - - -/** - * SignatureReused: 'Signature has already been used (counter mismatch)' - * - * @category Errors - * @category generated - */ -export class SignatureReusedError extends Error { - readonly code: number = 0xbbe; - readonly name: string = 'SignatureReused'; - constructor() { - super('Signature has already been used (counter mismatch)'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, SignatureReusedError); - } - } -} - -createErrorFromCodeLookup.set(0xbbe, () => new SignatureReusedError()) -createErrorFromNameLookup.set('SignatureReused', () => new SignatureReusedError()) - - - -/** - * InvalidSignatureAge: 'Signature too old (outside 150-slot window)' - * - * @category Errors - * @category generated - */ -export class InvalidSignatureAgeError extends Error { - readonly code: number = 0xbbf; - readonly name: string = 'InvalidSignatureAge'; - constructor() { - super('Signature too old (outside 150-slot window)'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, InvalidSignatureAgeError); - } - } -} - -createErrorFromCodeLookup.set(0xbbf, () => new InvalidSignatureAgeError()) -createErrorFromNameLookup.set('InvalidSignatureAge', () => new InvalidSignatureAgeError()) - - - -/** - * InvalidSessionDuration: 'Invalid session duration' - * - * @category Errors - * @category generated - */ -export class InvalidSessionDurationError extends Error { - readonly code: number = 0xbc0; - readonly name: string = 'InvalidSessionDuration'; - constructor() { - super('Invalid session duration'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, InvalidSessionDurationError); - } - } -} - -createErrorFromCodeLookup.set(0xbc0, () => new InvalidSessionDurationError()) -createErrorFromNameLookup.set('InvalidSessionDuration', () => new InvalidSessionDurationError()) - - - -/** - * SessionExpired: 'Session has expired' - * - * @category Errors - * @category generated - */ -export class SessionExpiredError extends Error { - readonly code: number = 0xbc1; - readonly name: string = 'SessionExpired'; - constructor() { - super('Session has expired'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, SessionExpiredError); - } - } -} - -createErrorFromCodeLookup.set(0xbc1, () => new SessionExpiredError()) -createErrorFromNameLookup.set('SessionExpired', () => new SessionExpiredError()) - - - -/** - * AuthorityDoesNotSupportSession: 'Authority type does not support sessions' - * - * @category Errors - * @category generated - */ -export class AuthorityDoesNotSupportSessionError extends Error { - readonly code: number = 0xbc2; - readonly name: string = 'AuthorityDoesNotSupportSession'; - constructor() { - super('Authority type does not support sessions'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, AuthorityDoesNotSupportSessionError); - } - } -} - -createErrorFromCodeLookup.set(0xbc2, () => new AuthorityDoesNotSupportSessionError()) -createErrorFromNameLookup.set('AuthorityDoesNotSupportSession', () => new AuthorityDoesNotSupportSessionError()) - - - -/** - * InvalidAuthenticationKind: 'Invalid authentication kind' - * - * @category Errors - * @category generated - */ -export class InvalidAuthenticationKindError extends Error { - readonly code: number = 0xbc3; - readonly name: string = 'InvalidAuthenticationKind'; - constructor() { - super('Invalid authentication kind'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, InvalidAuthenticationKindError); - } - } -} - -createErrorFromCodeLookup.set(0xbc3, () => new InvalidAuthenticationKindError()) -createErrorFromNameLookup.set('InvalidAuthenticationKind', () => new InvalidAuthenticationKindError()) - - - -/** - * InvalidMessage: 'Invalid message' - * - * @category Errors - * @category generated - */ -export class InvalidMessageError extends Error { - readonly code: number = 0xbc4; - readonly name: string = 'InvalidMessage'; - constructor() { - super('Invalid message'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, InvalidMessageError); - } - } -} - -createErrorFromCodeLookup.set(0xbc4, () => new InvalidMessageError()) -createErrorFromNameLookup.set('InvalidMessage', () => new InvalidMessageError()) - - - -/** - * SelfReentrancyNotAllowed: 'Self-reentrancy is not allowed' - * - * @category Errors - * @category generated - */ -export class SelfReentrancyNotAllowedError extends Error { - readonly code: number = 0xbc5; - readonly name: string = 'SelfReentrancyNotAllowed'; - constructor() { - super('Self-reentrancy is not allowed'); - if (typeof Error.captureStackTrace === 'function') { - Error.captureStackTrace(this, SelfReentrancyNotAllowedError); - } - } -} - -createErrorFromCodeLookup.set(0xbc5, () => new SelfReentrancyNotAllowedError()) -createErrorFromNameLookup.set('SelfReentrancyNotAllowed', () => new SelfReentrancyNotAllowedError()) - - -/** - * Attempts to resolve a custom program error from the provided error code. - * @category Errors - * @category generated - */ -export function errorFromCode(code: number): MaybeErrorWithCode { - const createError = createErrorFromCodeLookup.get(code) - return createError != null ? createError() : null; -} - -/** - * Attempts to resolve a custom program error from the provided error name, i.e. 'Unauthorized'. - * @category Errors - * @category generated - */ -export function errorFromName(name: string): MaybeErrorWithCode { - const createError = createErrorFromNameLookup.get(name) - return createError != null ? createError() : null; -} \ No newline at end of file diff --git a/sdk/solita-client/src/generated/index.ts b/sdk/solita-client/src/generated/index.ts deleted file mode 100644 index d1ed2fe..0000000 --- a/sdk/solita-client/src/generated/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { PublicKey } from '@solana/web3.js' -export * from './accounts'; -export * from './errors'; -export * from './instructions'; -export * from './types'; - -/** - * Program address - * - * @category constants - * @category generated - */ -export const PROGRAM_ADDRESS = 'FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao' - -/** - * Program public key - * - * @category constants - * @category generated - */ -export const PROGRAM_ID = new PublicKey(PROGRAM_ADDRESS) \ No newline at end of file diff --git a/sdk/solita-client/src/generated/instructions/AddAuthority.ts b/sdk/solita-client/src/generated/instructions/AddAuthority.ts deleted file mode 100644 index e8ce77d..0000000 --- a/sdk/solita-client/src/generated/instructions/AddAuthority.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -import * as web3 from '@solana/web3.js'; - - -/** - * @category Instructions - * @category AddAuthority - * @category generated - */ -export type AddAuthorityInstructionArgs = { - newType: number, - newPubkey: number[] /* size: 33 */, - newHash: number[] /* size: 32 */, - newRole: number -} -/** - * @category Instructions - * @category AddAuthority - * @category generated - */ -export const AddAuthorityStruct = new beet.BeetArgsStruct( - [ - ['instructionDiscriminator', beet.u8], - ['newType', beet.u8], - ['newPubkey', beet.uniformFixedSizeArray(beet.u8, 33)], - ['newHash', beet.uniformFixedSizeArray(beet.u8, 32)], - ['newRole', beet.u8] - ], - 'AddAuthorityInstructionArgs' -) -/** - * Accounts required by the _AddAuthority_ instruction - * - * @property [**signer**] payer -* @property [] wallet -* @property [**signer**] adminAuthority -* @property [_writable_] newAuthority -* @property [**signer**] authorizerSigner (optional) - * @category Instructions - * @category AddAuthority - * @category generated - */ - export type AddAuthorityInstructionAccounts = { - payer: web3.PublicKey - wallet: web3.PublicKey - adminAuthority: web3.PublicKey - newAuthority: web3.PublicKey - systemProgram?: web3.PublicKey - authorizerSigner?: web3.PublicKey - - } - - export const addAuthorityInstructionDiscriminator = 1; - - /** - * Creates a _AddAuthority_ instruction. - * - * Optional accounts that are not provided default to the program ID since - * this was indicated in the IDL from which this instruction was generated. - * - * @param accounts that will be accessed while the instruction is processed - * @param args to provide as instruction data to the program - * - * @category Instructions - * @category AddAuthority - * @category generated - */ - export function createAddAuthorityInstruction( - accounts: AddAuthorityInstructionAccounts, -args: AddAuthorityInstructionArgs , programId = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao') - ) { - const [data] = AddAuthorityStruct.serialize({ - instructionDiscriminator: addAuthorityInstructionDiscriminator, - ...args - }); - const keys: web3.AccountMeta[] = [ - { - pubkey: accounts.payer, - isWritable: false, - isSigner: true, - }, - { - pubkey: accounts.wallet, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.adminAuthority, - isWritable: false, - isSigner: true, - }, - { - pubkey: accounts.newAuthority, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.systemProgram ?? web3.SystemProgram.programId, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.authorizerSigner ?? programId, - isWritable: false, - isSigner: accounts.authorizerSigner != null, - } - ] - - - const ix = new web3.TransactionInstruction({ - programId, - keys, - data - }); - return ix; -} \ No newline at end of file diff --git a/sdk/solita-client/src/generated/instructions/CreateSession.ts b/sdk/solita-client/src/generated/instructions/CreateSession.ts deleted file mode 100644 index 384450d..0000000 --- a/sdk/solita-client/src/generated/instructions/CreateSession.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -import * as web3 from '@solana/web3.js'; - - -/** - * @category Instructions - * @category CreateSession - * @category generated - */ -export type CreateSessionInstructionArgs = { - sessionKey: number[] /* size: 32 */, - expiresAt: beet.bignum -} -/** - * @category Instructions - * @category CreateSession - * @category generated - */ -export const CreateSessionStruct = new beet.BeetArgsStruct( - [ - ['instructionDiscriminator', beet.u8], - ['sessionKey', beet.uniformFixedSizeArray(beet.u8, 32)], - ['expiresAt', beet.i64] - ], - 'CreateSessionInstructionArgs' -) -/** - * Accounts required by the _CreateSession_ instruction - * - * @property [**signer**] payer -* @property [] wallet -* @property [**signer**] adminAuthority -* @property [_writable_] session -* @property [**signer**] authorizerSigner (optional) - * @category Instructions - * @category CreateSession - * @category generated - */ - export type CreateSessionInstructionAccounts = { - payer: web3.PublicKey - wallet: web3.PublicKey - adminAuthority: web3.PublicKey - session: web3.PublicKey - systemProgram?: web3.PublicKey - authorizerSigner?: web3.PublicKey - - } - - export const createSessionInstructionDiscriminator = 5; - - /** - * Creates a _CreateSession_ instruction. - * - * Optional accounts that are not provided default to the program ID since - * this was indicated in the IDL from which this instruction was generated. - * - * @param accounts that will be accessed while the instruction is processed - * @param args to provide as instruction data to the program - * - * @category Instructions - * @category CreateSession - * @category generated - */ - export function createCreateSessionInstruction( - accounts: CreateSessionInstructionAccounts, -args: CreateSessionInstructionArgs , programId = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao') - ) { - const [data] = CreateSessionStruct.serialize({ - instructionDiscriminator: createSessionInstructionDiscriminator, - ...args - }); - const keys: web3.AccountMeta[] = [ - { - pubkey: accounts.payer, - isWritable: false, - isSigner: true, - }, - { - pubkey: accounts.wallet, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.adminAuthority, - isWritable: false, - isSigner: true, - }, - { - pubkey: accounts.session, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.systemProgram ?? web3.SystemProgram.programId, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.authorizerSigner ?? programId, - isWritable: false, - isSigner: accounts.authorizerSigner != null, - } - ] - - - const ix = new web3.TransactionInstruction({ - programId, - keys, - data - }); - return ix; -} \ No newline at end of file diff --git a/sdk/solita-client/src/generated/instructions/CreateWallet.ts b/sdk/solita-client/src/generated/instructions/CreateWallet.ts deleted file mode 100644 index b4853e5..0000000 --- a/sdk/solita-client/src/generated/instructions/CreateWallet.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -import * as web3 from '@solana/web3.js'; - - -/** - * @category Instructions - * @category CreateWallet - * @category generated - */ -export type CreateWalletInstructionArgs = { - userSeed: Uint8Array, - authType: number, - authPubkey: number[] /* size: 33 */, - credentialHash: number[] /* size: 32 */ -} -/** - * @category Instructions - * @category CreateWallet - * @category generated - */ -export const CreateWalletStruct = new beet.FixableBeetArgsStruct( - [ - ['instructionDiscriminator', beet.u8], - ['userSeed', beet.bytes], - ['authType', beet.u8], - ['authPubkey', beet.uniformFixedSizeArray(beet.u8, 33)], - ['credentialHash', beet.uniformFixedSizeArray(beet.u8, 32)] - ], - 'CreateWalletInstructionArgs' -) -/** - * Accounts required by the _CreateWallet_ instruction - * - * @property [_writable_, **signer**] payer -* @property [_writable_] wallet -* @property [_writable_] vault -* @property [_writable_] authority - * @category Instructions - * @category CreateWallet - * @category generated - */ - export type CreateWalletInstructionAccounts = { - payer: web3.PublicKey - wallet: web3.PublicKey - vault: web3.PublicKey - authority: web3.PublicKey - systemProgram?: web3.PublicKey - - } - - export const createWalletInstructionDiscriminator = 0; - - /** - * Creates a _CreateWallet_ instruction. - * - * @param accounts that will be accessed while the instruction is processed - * @param args to provide as instruction data to the program - * - * @category Instructions - * @category CreateWallet - * @category generated - */ - export function createCreateWalletInstruction( - accounts: CreateWalletInstructionAccounts, -args: CreateWalletInstructionArgs , programId = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao') - ) { - const [data] = CreateWalletStruct.serialize({ - instructionDiscriminator: createWalletInstructionDiscriminator, - ...args - }); - const keys: web3.AccountMeta[] = [ - { - pubkey: accounts.payer, - isWritable: true, - isSigner: true, - }, - { - pubkey: accounts.wallet, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.vault, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.authority, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.systemProgram ?? web3.SystemProgram.programId, - isWritable: false, - isSigner: false, - } - ] - - - const ix = new web3.TransactionInstruction({ - programId, - keys, - data - }); - return ix; -} \ No newline at end of file diff --git a/sdk/solita-client/src/generated/instructions/Execute.ts b/sdk/solita-client/src/generated/instructions/Execute.ts deleted file mode 100644 index 16b951d..0000000 --- a/sdk/solita-client/src/generated/instructions/Execute.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -import * as web3 from '@solana/web3.js'; - - -/** - * @category Instructions - * @category Execute - * @category generated - */ -export type ExecuteInstructionArgs = { - instructions: Uint8Array -} -/** - * @category Instructions - * @category Execute - * @category generated - */ -export const ExecuteStruct = new beet.FixableBeetArgsStruct( - [ - ['instructionDiscriminator', beet.u8], - ['instructions', beet.bytes] - ], - 'ExecuteInstructionArgs' -) -/** - * Accounts required by the _Execute_ instruction - * - * @property [**signer**] payer -* @property [] wallet -* @property [] authority -* @property [] vault -* @property [] sysvarInstructions (optional) - * @category Instructions - * @category Execute - * @category generated - */ - export type ExecuteInstructionAccounts = { - payer: web3.PublicKey - wallet: web3.PublicKey - authority: web3.PublicKey - vault: web3.PublicKey - sysvarInstructions?: web3.PublicKey - - } - - export const executeInstructionDiscriminator = 4; - - /** - * Creates a _Execute_ instruction. - * - * Optional accounts that are not provided default to the program ID since - * this was indicated in the IDL from which this instruction was generated. - * - * @param accounts that will be accessed while the instruction is processed - * @param args to provide as instruction data to the program - * - * @category Instructions - * @category Execute - * @category generated - */ - export function createExecuteInstruction( - accounts: ExecuteInstructionAccounts, -args: ExecuteInstructionArgs , programId = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao') - ) { - const [data] = ExecuteStruct.serialize({ - instructionDiscriminator: executeInstructionDiscriminator, - ...args - }); - const keys: web3.AccountMeta[] = [ - { - pubkey: accounts.payer, - isWritable: false, - isSigner: true, - }, - { - pubkey: accounts.wallet, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.authority, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.vault, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.sysvarInstructions ?? programId, - isWritable: false, - isSigner: false, - } - ] - - - const ix = new web3.TransactionInstruction({ - programId, - keys, - data - }); - return ix; -} \ No newline at end of file diff --git a/sdk/solita-client/src/generated/instructions/RemoveAuthority.ts b/sdk/solita-client/src/generated/instructions/RemoveAuthority.ts deleted file mode 100644 index 39c000b..0000000 --- a/sdk/solita-client/src/generated/instructions/RemoveAuthority.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -import * as web3 from '@solana/web3.js'; - - - -/** - * @category Instructions - * @category RemoveAuthority - * @category generated - */ -export const RemoveAuthorityStruct = new beet.BeetArgsStruct<{ instructionDiscriminator: number }>( - [ - ['instructionDiscriminator', beet.u8], - - ], - 'RemoveAuthorityInstructionArgs' -) -/** - * Accounts required by the _RemoveAuthority_ instruction - * - * @property [**signer**] payer -* @property [] wallet -* @property [**signer**] adminAuthority -* @property [_writable_] targetAuthority -* @property [_writable_] refundDestination -* @property [**signer**] authorizerSigner (optional) - * @category Instructions - * @category RemoveAuthority - * @category generated - */ - export type RemoveAuthorityInstructionAccounts = { - payer: web3.PublicKey - wallet: web3.PublicKey - adminAuthority: web3.PublicKey - targetAuthority: web3.PublicKey - refundDestination: web3.PublicKey - authorizerSigner?: web3.PublicKey - - } - - export const removeAuthorityInstructionDiscriminator = 2; - - /** - * Creates a _RemoveAuthority_ instruction. - * - * Optional accounts that are not provided default to the program ID since - * this was indicated in the IDL from which this instruction was generated. - * - * @param accounts that will be accessed while the instruction is processed - * @category Instructions - * @category RemoveAuthority - * @category generated - */ - export function createRemoveAuthorityInstruction( - accounts: RemoveAuthorityInstructionAccounts, -programId = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao') - ) { - const [data] = RemoveAuthorityStruct.serialize({ - instructionDiscriminator: removeAuthorityInstructionDiscriminator, - - }); - const keys: web3.AccountMeta[] = [ - { - pubkey: accounts.payer, - isWritable: false, - isSigner: true, - }, - { - pubkey: accounts.wallet, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.adminAuthority, - isWritable: false, - isSigner: true, - }, - { - pubkey: accounts.targetAuthority, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.refundDestination, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.authorizerSigner ?? programId, - isWritable: false, - isSigner: accounts.authorizerSigner != null, - } - ] - - - const ix = new web3.TransactionInstruction({ - programId, - keys, - data - }); - return ix; -} \ No newline at end of file diff --git a/sdk/solita-client/src/generated/instructions/TransferOwnership.ts b/sdk/solita-client/src/generated/instructions/TransferOwnership.ts deleted file mode 100644 index a5943ef..0000000 --- a/sdk/solita-client/src/generated/instructions/TransferOwnership.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -import * as web3 from '@solana/web3.js'; - - -/** - * @category Instructions - * @category TransferOwnership - * @category generated - */ -export type TransferOwnershipInstructionArgs = { - newType: number, - newPubkey: number[] /* size: 33 */, - newHash: number[] /* size: 32 */ -} -/** - * @category Instructions - * @category TransferOwnership - * @category generated - */ -export const TransferOwnershipStruct = new beet.BeetArgsStruct( - [ - ['instructionDiscriminator', beet.u8], - ['newType', beet.u8], - ['newPubkey', beet.uniformFixedSizeArray(beet.u8, 33)], - ['newHash', beet.uniformFixedSizeArray(beet.u8, 32)] - ], - 'TransferOwnershipInstructionArgs' -) -/** - * Accounts required by the _TransferOwnership_ instruction - * - * @property [**signer**] payer -* @property [] wallet -* @property [_writable_] currentOwnerAuthority -* @property [_writable_] newOwnerAuthority -* @property [**signer**] authorizerSigner (optional) - * @category Instructions - * @category TransferOwnership - * @category generated - */ - export type TransferOwnershipInstructionAccounts = { - payer: web3.PublicKey - wallet: web3.PublicKey - currentOwnerAuthority: web3.PublicKey - newOwnerAuthority: web3.PublicKey - systemProgram?: web3.PublicKey - authorizerSigner?: web3.PublicKey - - } - - export const transferOwnershipInstructionDiscriminator = 3; - - /** - * Creates a _TransferOwnership_ instruction. - * - * Optional accounts that are not provided default to the program ID since - * this was indicated in the IDL from which this instruction was generated. - * - * @param accounts that will be accessed while the instruction is processed - * @param args to provide as instruction data to the program - * - * @category Instructions - * @category TransferOwnership - * @category generated - */ - export function createTransferOwnershipInstruction( - accounts: TransferOwnershipInstructionAccounts, -args: TransferOwnershipInstructionArgs , programId = new web3.PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao') - ) { - const [data] = TransferOwnershipStruct.serialize({ - instructionDiscriminator: transferOwnershipInstructionDiscriminator, - ...args - }); - const keys: web3.AccountMeta[] = [ - { - pubkey: accounts.payer, - isWritable: false, - isSigner: true, - }, - { - pubkey: accounts.wallet, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.currentOwnerAuthority, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.newOwnerAuthority, - isWritable: true, - isSigner: false, - }, - { - pubkey: accounts.systemProgram ?? web3.SystemProgram.programId, - isWritable: false, - isSigner: false, - }, - { - pubkey: accounts.authorizerSigner ?? programId, - isWritable: false, - isSigner: accounts.authorizerSigner != null, - } - ] - - - const ix = new web3.TransactionInstruction({ - programId, - keys, - data - }); - return ix; -} \ No newline at end of file diff --git a/sdk/solita-client/src/generated/instructions/index.ts b/sdk/solita-client/src/generated/instructions/index.ts deleted file mode 100644 index b125669..0000000 --- a/sdk/solita-client/src/generated/instructions/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './AddAuthority'; -export * from './CreateSession'; -export * from './CreateWallet'; -export * from './Execute'; -export * from './RemoveAuthority'; -export * from './TransferOwnership'; \ No newline at end of file diff --git a/sdk/solita-client/src/generated/types/AccountDiscriminator.ts b/sdk/solita-client/src/generated/types/AccountDiscriminator.ts deleted file mode 100644 index a769abe..0000000 --- a/sdk/solita-client/src/generated/types/AccountDiscriminator.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -/** - * @category enums - * @category generated - */ -export enum AccountDiscriminator { - Uninitialized, - Wallet, - Authority, - Session -} - -/** - * @category userTypes - * @category generated - */ -export const accountDiscriminatorBeet = beet.fixedScalarEnum(AccountDiscriminator) as beet.FixedSizeBeet \ No newline at end of file diff --git a/sdk/solita-client/src/generated/types/AuthorityType.ts b/sdk/solita-client/src/generated/types/AuthorityType.ts deleted file mode 100644 index 7a7e823..0000000 --- a/sdk/solita-client/src/generated/types/AuthorityType.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -/** - * @category enums - * @category generated - */ -export enum AuthorityType { - Ed25519, - Secp256r1 -} - -/** - * @category userTypes - * @category generated - */ -export const authorityTypeBeet = beet.fixedScalarEnum(AuthorityType) as beet.FixedSizeBeet \ No newline at end of file diff --git a/sdk/solita-client/src/generated/types/Role.ts b/sdk/solita-client/src/generated/types/Role.ts deleted file mode 100644 index 71a16f7..0000000 --- a/sdk/solita-client/src/generated/types/Role.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * This code was GENERATED using the solita package. - * Please DO NOT EDIT THIS FILE, instead rerun solita to update it or write a wrapper to add functionality. - * - * See: https://github.com/metaplex-foundation/solita - */ - -import * as beet from '@metaplex-foundation/beet'; -/** - * @category enums - * @category generated - */ -export enum Role { - Owner, - Admin, - Spender -} - -/** - * @category userTypes - * @category generated - */ -export const roleBeet = beet.fixedScalarEnum(Role) as beet.FixedSizeBeet \ No newline at end of file diff --git a/sdk/solita-client/src/generated/types/index.ts b/sdk/solita-client/src/generated/types/index.ts deleted file mode 100644 index 2a7650c..0000000 --- a/sdk/solita-client/src/generated/types/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './AccountDiscriminator'; -export * from './AuthorityType'; -export * from './Role'; \ No newline at end of file diff --git a/sdk/solita-client/src/index.ts b/sdk/solita-client/src/index.ts deleted file mode 100644 index a569a11..0000000 --- a/sdk/solita-client/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './generated'; -export * from './utils'; diff --git a/sdk/solita-client/src/utils/client.ts b/sdk/solita-client/src/utils/client.ts deleted file mode 100644 index 696b403..0000000 --- a/sdk/solita-client/src/utils/client.ts +++ /dev/null @@ -1,694 +0,0 @@ -import { - Connection, - PublicKey, - SystemProgram, - SYSVAR_INSTRUCTIONS_PUBKEY, - TransactionInstruction, -} from '@solana/web3.js'; -import { PROGRAM_ID } from '../generated'; -import { AuthorityAccount } from '../generated/accounts'; -import { findWalletPda, findVaultPda, findAuthorityPda, findSessionPda, findDeferredExecPda } from './pdas'; -import { readAuthorityCounter } from './secp256r1'; -import { packCompactInstructions, computeAccountsHash, computeInstructionsHash, type CompactInstruction } from './packing'; -import { - createCreateWalletIx, - createAddAuthorityIx, - createRemoveAuthorityIx, - createTransferOwnershipIx, - createExecuteIx, - createCreateSessionIx, - createAuthorizeIx, - createExecuteDeferredIx, - createReclaimDeferredIx, - createRevokeSessionIx, - AUTH_TYPE_ED25519, - AUTH_TYPE_SECP256R1, - DISC_ADD_AUTHORITY, - DISC_REMOVE_AUTHORITY, - DISC_TRANSFER_OWNERSHIP, - DISC_EXECUTE, - DISC_CREATE_SESSION, - DISC_AUTHORIZE, - DISC_REVOKE_SESSION, -} from './instructions'; -import { signWithSecp256r1, buildDataPayloadForAdd, buildDataPayloadForTransfer, buildDataPayloadForSession, concatParts } from './signing'; -import { buildCompactLayout } from './compact'; -import type { CreateWalletOwner, AdminSigner, ExecuteSigner, Secp256r1SignerConfig, DeferredPayload } from './types'; - -// ─── Sysvar instruction indexes (auto-computed from account layouts) ── - -const SYSVAR_IX_INDEX_ADD_AUTHORITY = 6; -const SYSVAR_IX_INDEX_REMOVE_AUTHORITY = 5; -const SYSVAR_IX_INDEX_TRANSFER_OWNERSHIP = 6; -const SYSVAR_IX_INDEX_EXECUTE = 4; -const SYSVAR_IX_INDEX_CREATE_SESSION = 6; -const SYSVAR_IX_INDEX_AUTHORIZE = 6; -const SYSVAR_IX_INDEX_REVOKE_SESSION = 5; - -// ─── Internal helpers ───────────────────────────────────────────────── - -/** Resolves a CreateWalletOwner to the low-level fields needed by IX builders */ -function resolveOwnerFields(owner: CreateWalletOwner): { - authType: number; - credentialOrPubkey: Uint8Array; - secp256r1Pubkey?: Uint8Array; - rpId?: string; -} { - if (owner.type === 'ed25519') { - return { authType: AUTH_TYPE_ED25519, credentialOrPubkey: owner.publicKey.toBytes() }; - } - return { - authType: AUTH_TYPE_SECP256R1, - credentialOrPubkey: owner.credentialIdHash, - secp256r1Pubkey: owner.compressedPubkey, - rpId: owner.rpId, - }; -} - -/** Gets the credential bytes (PDA seed) from a CreateWalletOwner */ -function ownerCredentialBytes(owner: CreateWalletOwner): Uint8Array { - return owner.type === 'ed25519' ? owner.publicKey.toBytes() : owner.credentialIdHash; -} - -export class LazorKitClient { - constructor( - public readonly connection: Connection, - public readonly programId: PublicKey = PROGRAM_ID, - ) {} - - // ─── PDA helpers ───────────────────────────────────────────────── - - findWallet(userSeed: Uint8Array) { return findWalletPda(userSeed, this.programId); } - findVault(walletPda: PublicKey) { return findVaultPda(walletPda, this.programId); } - findAuthority(walletPda: PublicKey, credIdHash: Uint8Array) { - return findAuthorityPda(walletPda, credIdHash, this.programId); - } - findSession(walletPda: PublicKey, sessionKey: Uint8Array) { - return findSessionPda(walletPda, sessionKey, this.programId); - } - findDeferredExec(walletPda: PublicKey, authorityPda: PublicKey, counter: number) { - return findDeferredExecPda(walletPda, authorityPda, counter, this.programId); - } - - // ─── Account readers ───────────────────────────────────────────── - - async fetchAuthority(authorityPda: PublicKey): Promise { - return AuthorityAccount.fromAccountAddress(this.connection, authorityPda); - } - - async readCounter(authorityPda: PublicKey): Promise { - return readAuthorityCounter(this.connection, authorityPda); - } - - // ─── CreateWallet ──────────────────────────────────────────────── - - /** - * Create a new LazorKit wallet with the given owner. - * - * @example Ed25519 owner - * ```typescript - * const { instructions, walletPda, vaultPda } = client.createWallet({ - * payer: payer.publicKey, - * userSeed: randomBytes(32), - * owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, - * }); - * ``` - * - * @example Secp256r1 (passkey) owner - * ```typescript - * const { instructions, walletPda, vaultPda } = client.createWallet({ - * payer: payer.publicKey, - * userSeed: randomBytes(32), - * owner: { - * type: 'secp256r1', - * credentialIdHash, - * compressedPubkey, - * rpId: 'example.com', - * }, - * }); - * ``` - */ - createWallet(params: { - payer: PublicKey; - userSeed: Uint8Array; - owner: CreateWalletOwner; - }): { instructions: TransactionInstruction[]; walletPda: PublicKey; vaultPda: PublicKey; authorityPda: PublicKey } { - const [walletPda] = this.findWallet(params.userSeed); - const [vaultPda] = this.findVault(walletPda); - const { authType, credentialOrPubkey, secp256r1Pubkey, rpId } = resolveOwnerFields(params.owner); - const [authorityPda, authBump] = this.findAuthority(walletPda, credentialOrPubkey); - - const ix = createCreateWalletIx({ - payer: params.payer, walletPda, vaultPda, authorityPda, - userSeed: params.userSeed, authType, authBump, - credentialOrPubkey, secp256r1Pubkey, rpId, - programId: this.programId, - }); - return { instructions: [ix], walletPda, vaultPda, authorityPda }; - } - - // ─── AddAuthority (unified) ───────────────────────────────────── - - /** - * Add a new authority to the wallet. - * - * @example Add Ed25519 admin via Ed25519 owner - * ```typescript - * const { instructions, newAuthorityPda } = await client.addAuthority({ - * payer: payer.publicKey, - * walletPda, - * adminSigner: ed25519(ownerKp.publicKey), - * newAuthority: { type: 'ed25519', publicKey: adminKp.publicKey }, - * role: ROLE_ADMIN, - * }); - * ``` - * - * @example Add Secp256r1 spender via Secp256r1 owner - * ```typescript - * const { instructions, newAuthorityPda } = await client.addAuthority({ - * payer: payer.publicKey, - * walletPda, - * adminSigner: secp256r1(ceoSigner), - * newAuthority: { type: 'secp256r1', credentialIdHash, compressedPubkey, rpId }, - * role: ROLE_SPENDER, - * }); - * ``` - */ - async addAuthority(params: { - payer: PublicKey; - walletPda: PublicKey; - adminSigner: AdminSigner; - newAuthority: CreateWalletOwner; - role: number; - }): Promise<{ instructions: TransactionInstruction[]; newAuthorityPda: PublicKey }> { - const { authType: newType, credentialOrPubkey, secp256r1Pubkey, rpId } = resolveOwnerFields(params.newAuthority); - const [newAuthorityPda] = this.findAuthority(params.walletPda, credentialOrPubkey); - const s = params.adminSigner; - - if (s.type === 'ed25519') { - const ix = createAddAuthorityIx({ - payer: params.payer, walletPda: params.walletPda, - adminAuthorityPda: s.authorityPda ?? this.findAuthority(params.walletPda, s.publicKey.toBytes())[0], - newAuthorityPda, newType, newRole: params.role, - credentialOrPubkey, secp256r1Pubkey, rpId, - authorizerSigner: s.publicKey, programId: this.programId, - }); - return { instructions: [ix], newAuthorityPda }; - } - - // Secp256r1 - const adminAuthorityPda = s.authorityPda - ?? this.findAuthority(params.walletPda, s.signer.credentialIdHash)[0]; - const slot = s.slotOverride ?? BigInt(await this.connection.getSlot()); - const counter = (await this.readCounter(adminAuthorityPda)) + 1; - - const dataPayload = buildDataPayloadForAdd( - newType, params.role, credentialOrPubkey, secp256r1Pubkey, rpId, - ); - const signedPayload = concatParts([dataPayload, params.payer.toBytes()]); - - const { authPayload, precompileIx } = await signWithSecp256r1({ - signer: s.signer, discriminator: new Uint8Array([DISC_ADD_AUTHORITY]), - signedPayload, sysvarIxIndex: SYSVAR_IX_INDEX_ADD_AUTHORITY, - slot, counter, payer: params.payer, programId: this.programId, - }); - - const ix = createAddAuthorityIx({ - payer: params.payer, walletPda: params.walletPda, adminAuthorityPda, newAuthorityPda, - newType, newRole: params.role, credentialOrPubkey, secp256r1Pubkey, rpId, - authPayload, programId: this.programId, - }); - return { instructions: [precompileIx, ix], newAuthorityPda }; - } - - // ─── RemoveAuthority (unified) ────────────────────────────────── - - async removeAuthority(params: { - payer: PublicKey; - walletPda: PublicKey; - adminSigner: AdminSigner; - targetAuthorityPda: PublicKey; - refundDestination?: PublicKey; - }): Promise<{ instructions: TransactionInstruction[] }> { - const refundDest = params.refundDestination ?? params.payer; - const s = params.adminSigner; - - if (s.type === 'ed25519') { - const ix = createRemoveAuthorityIx({ - payer: params.payer, walletPda: params.walletPda, - adminAuthorityPda: s.authorityPda ?? this.findAuthority(params.walletPda, s.publicKey.toBytes())[0], - targetAuthorityPda: params.targetAuthorityPda, refundDestination: refundDest, - authorizerSigner: s.publicKey, programId: this.programId, - }); - return { instructions: [ix] }; - } - - const adminAuthorityPda = s.authorityPda - ?? this.findAuthority(params.walletPda, s.signer.credentialIdHash)[0]; - const slot = s.slotOverride ?? BigInt(await this.connection.getSlot()); - const counter = (await this.readCounter(adminAuthorityPda)) + 1; - - const signedPayload = concatParts([ - params.targetAuthorityPda.toBytes(), refundDest.toBytes(), - ]); - - const { authPayload, precompileIx } = await signWithSecp256r1({ - signer: s.signer, discriminator: new Uint8Array([DISC_REMOVE_AUTHORITY]), - signedPayload, sysvarIxIndex: SYSVAR_IX_INDEX_REMOVE_AUTHORITY, - slot, counter, payer: params.payer, programId: this.programId, - }); - - const ix = createRemoveAuthorityIx({ - payer: params.payer, walletPda: params.walletPda, adminAuthorityPda, - targetAuthorityPda: params.targetAuthorityPda, refundDestination: refundDest, - authPayload, programId: this.programId, - }); - return { instructions: [precompileIx, ix] }; - } - - // ─── TransferOwnership (unified) ──────────────────────────────── - - /** - * Transfer wallet ownership to a new authority. - * - * @example Transfer to new Secp256r1 owner - * ```typescript - * const { instructions } = await client.transferOwnership({ - * payer: payer.publicKey, - * walletPda, - * ownerSigner: secp256r1(ceoSigner), - * newOwner: { type: 'secp256r1', credentialIdHash, compressedPubkey, rpId }, - * }); - * ``` - */ - async transferOwnership(params: { - payer: PublicKey; - walletPda: PublicKey; - ownerSigner: AdminSigner; - newOwner: CreateWalletOwner; - }): Promise<{ instructions: TransactionInstruction[]; newOwnerAuthorityPda: PublicKey }> { - const { authType: newType, credentialOrPubkey, secp256r1Pubkey, rpId } = resolveOwnerFields(params.newOwner); - const [newOwnerAuthorityPda] = this.findAuthority(params.walletPda, credentialOrPubkey); - const s = params.ownerSigner; - - if (s.type === 'ed25519') { - const ix = createTransferOwnershipIx({ - payer: params.payer, walletPda: params.walletPda, - currentOwnerAuthorityPda: s.authorityPda ?? this.findAuthority(params.walletPda, s.publicKey.toBytes())[0], - newOwnerAuthorityPda, newType, credentialOrPubkey, secp256r1Pubkey, rpId, - authorizerSigner: s.publicKey, programId: this.programId, - }); - return { instructions: [ix], newOwnerAuthorityPda }; - } - - const currentOwnerAuthorityPda = s.authorityPda - ?? this.findAuthority(params.walletPda, s.signer.credentialIdHash)[0]; - const slot = s.slotOverride ?? BigInt(await this.connection.getSlot()); - const counter = (await this.readCounter(currentOwnerAuthorityPda)) + 1; - - const dataPayload = buildDataPayloadForTransfer(newType, credentialOrPubkey, secp256r1Pubkey, rpId); - const signedPayload = concatParts([dataPayload, params.payer.toBytes()]); - - const { authPayload, precompileIx } = await signWithSecp256r1({ - signer: s.signer, discriminator: new Uint8Array([DISC_TRANSFER_OWNERSHIP]), - signedPayload, sysvarIxIndex: SYSVAR_IX_INDEX_TRANSFER_OWNERSHIP, - slot, counter, payer: params.payer, programId: this.programId, - }); - - const ix = createTransferOwnershipIx({ - payer: params.payer, walletPda: params.walletPda, - currentOwnerAuthorityPda, newOwnerAuthorityPda, newType, - credentialOrPubkey, secp256r1Pubkey, rpId, - authPayload, programId: this.programId, - }); - return { instructions: [precompileIx, ix], newOwnerAuthorityPda }; - } - - // ─── CreateSession (unified) ──────────────────────────────────── - - /** - * Create a session key for the wallet. - * - * @example - * ```typescript - * const { instructions, sessionPda } = await client.createSession({ - * payer: payer.publicKey, - * walletPda, - * adminSigner: ed25519(ownerKp.publicKey), - * sessionKey: sessionKp.publicKey, - * expiresAt: currentSlot + 9000n, - * }); - * ``` - */ - async createSession(params: { - payer: PublicKey; - walletPda: PublicKey; - adminSigner: AdminSigner; - sessionKey: PublicKey; - expiresAt: bigint; - }): Promise<{ instructions: TransactionInstruction[]; sessionPda: PublicKey }> { - const sessionKeyBytes = params.sessionKey.toBytes(); - const [sessionPda] = this.findSession(params.walletPda, sessionKeyBytes); - const s = params.adminSigner; - - if (s.type === 'ed25519') { - const ix = createCreateSessionIx({ - payer: params.payer, walletPda: params.walletPda, - adminAuthorityPda: s.authorityPda ?? this.findAuthority(params.walletPda, s.publicKey.toBytes())[0], - sessionPda, sessionKey: sessionKeyBytes, expiresAt: params.expiresAt, - authorizerSigner: s.publicKey, programId: this.programId, - }); - return { instructions: [ix], sessionPda }; - } - - const adminAuthorityPda = s.authorityPda - ?? this.findAuthority(params.walletPda, s.signer.credentialIdHash)[0]; - const slot = s.slotOverride ?? BigInt(await this.connection.getSlot()); - const counter = (await this.readCounter(adminAuthorityPda)) + 1; - - const dataPayload = buildDataPayloadForSession(sessionKeyBytes, params.expiresAt); - const signedPayload = concatParts([dataPayload, params.payer.toBytes()]); - - const { authPayload, precompileIx } = await signWithSecp256r1({ - signer: s.signer, discriminator: new Uint8Array([DISC_CREATE_SESSION]), - signedPayload, sysvarIxIndex: SYSVAR_IX_INDEX_CREATE_SESSION, - slot, counter, payer: params.payer, programId: this.programId, - }); - - const ix = createCreateSessionIx({ - payer: params.payer, walletPda: params.walletPda, adminAuthorityPda, sessionPda, - sessionKey: sessionKeyBytes, expiresAt: params.expiresAt, - authPayload, programId: this.programId, - }); - return { instructions: [precompileIx, ix], sessionPda }; - } - - // ─── Execute (unified, accepts standard TransactionInstructions) ─ - - /** - * Execute arbitrary Solana instructions via the wallet. - * - * Works with any signer type: Ed25519, Secp256r1 (passkey), or Session key. - * Pass standard `TransactionInstruction[]` — the SDK handles compact encoding, - * account indexing, and signing automatically. - * - * @example - * ```typescript - * const [vault] = client.findVault(walletPda); - * const { instructions } = await client.execute({ - * payer: payer.publicKey, - * walletPda, - * signer: secp256r1(mySigner), - * instructions: [ - * SystemProgram.transfer({ fromPubkey: vault, toPubkey: recipient, lamports: 1_000_000 }), - * ], - * }); - * await sendAndConfirmTransaction(connection, new Transaction().add(...instructions), [payer]); - * ``` - */ - async execute(params: { - payer: PublicKey; - walletPda: PublicKey; - signer: ExecuteSigner; - instructions: TransactionInstruction[]; - }): Promise<{ instructions: TransactionInstruction[] }> { - const [vaultPda] = this.findVault(params.walletPda); - const s = params.signer; - - switch (s.type) { - case 'ed25519': { - const authorityPda = s.authorityPda - ?? this.findAuthority(params.walletPda, s.publicKey.toBytes())[0]; - // Ed25519: signer at index 4 (program expects it there) - const fixedAccounts = [params.payer, params.walletPda, authorityPda, vaultPda, s.publicKey]; - const { compactInstructions, remainingAccounts } = buildCompactLayout(fixedAccounts, params.instructions); - const packed = packCompactInstructions(compactInstructions); - const ix = createExecuteIx({ - payer: params.payer, walletPda: params.walletPda, - authorityPda, vaultPda, packedInstructions: packed, - authorizerSigner: s.publicKey, - remainingAccounts, programId: this.programId, - }); - return { instructions: [ix] }; - } - - case 'secp256r1': { - const authorityPda = s.authorityPda - ?? this.findAuthority(params.walletPda, s.signer.credentialIdHash)[0]; - const slot = s.slotOverride ?? BigInt(await this.connection.getSlot()); - const counter = (await this.readCounter(authorityPda)) + 1; - - // Secp256r1: sysvar_instructions at index 4 - const fixedAccounts = [params.payer, params.walletPda, authorityPda, vaultPda, SYSVAR_INSTRUCTIONS_PUBKEY]; - const { compactInstructions, remainingAccounts } = buildCompactLayout(fixedAccounts, params.instructions); - const packed = packCompactInstructions(compactInstructions); - - // Compute accounts hash for signature binding - const allAccountMetas = [ - { pubkey: params.payer, isSigner: true, isWritable: false }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: authorityPda, isSigner: false, isWritable: true }, - { pubkey: vaultPda, isSigner: false, isWritable: true }, - { pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }, - ...remainingAccounts, - ]; - const accountsHash = computeAccountsHash(allAccountMetas, compactInstructions); - const signedPayload = concatParts([packed, accountsHash]); - - const { authPayload, precompileIx } = await signWithSecp256r1({ - signer: s.signer, discriminator: new Uint8Array([DISC_EXECUTE]), - signedPayload, sysvarIxIndex: SYSVAR_IX_INDEX_EXECUTE, - slot, counter, payer: params.payer, programId: this.programId, - }); - - const ix = createExecuteIx({ - payer: params.payer, walletPda: params.walletPda, - authorityPda, vaultPda, packedInstructions: packed, - authPayload, remainingAccounts, programId: this.programId, - }); - return { instructions: [precompileIx, ix] }; - } - - case 'session': { - // Session: sessionKey as signer is included in fixed accounts for index mapping - const fixedAccounts = [params.payer, params.walletPda, s.sessionPda, vaultPda, s.sessionKeyPubkey]; - const { compactInstructions, remainingAccounts } = buildCompactLayout(fixedAccounts, params.instructions); - const packed = packCompactInstructions(compactInstructions); - - // Session key must be prepended to remaining accounts as a signer - const sessionKeyMeta = { pubkey: s.sessionKeyPubkey, isSigner: true, isWritable: false }; - const allRemaining = [sessionKeyMeta, ...remainingAccounts]; - - const ix = createExecuteIx({ - payer: params.payer, walletPda: params.walletPda, - authorityPda: s.sessionPda, vaultPda, packedInstructions: packed, - remainingAccounts: allRemaining, programId: this.programId, - }); - return { instructions: [ix] }; - } - } - } - - // ─── TransferSol (convenience) ────────────────────────────────── - - /** - * Transfer SOL from the wallet vault to a recipient. - * Works with any signer type. - * - * @example - * ```typescript - * const { instructions } = await client.transferSol({ - * payer: payer.publicKey, - * walletPda, - * signer: secp256r1(mySigner), - * recipient: destination, - * lamports: 1_000_000n, - * }); - * ``` - */ - async transferSol(params: { - payer: PublicKey; - walletPda: PublicKey; - signer: ExecuteSigner; - recipient: PublicKey; - lamports: bigint | number; - }): Promise<{ instructions: TransactionInstruction[] }> { - const [vaultPda] = this.findVault(params.walletPda); - const amount = typeof params.lamports === 'bigint' ? Number(params.lamports) : params.lamports; - - return this.execute({ - payer: params.payer, walletPda: params.walletPda, signer: params.signer, - instructions: [SystemProgram.transfer({ fromPubkey: vaultPda, toPubkey: params.recipient, lamports: amount })], - }); - } - - // ─── Authorize (deferred execution TX1) ───────────────────────── - - /** - * Authorize deferred execution. Pass standard TransactionInstructions - * — the SDK handles compact encoding and hash computation. - * - * Returns pre-computed `deferredPayload` for TX2. - */ - async authorize(params: { - payer: PublicKey; - walletPda: PublicKey; - signer: Secp256r1SignerConfig; - /** Standard instructions to defer */ - instructions: TransactionInstruction[]; - /** Expiry offset in slots (default 300 = ~2 minutes) */ - expiryOffset?: number; - }): Promise<{ - instructions: TransactionInstruction[]; - deferredExecPda: PublicKey; - counter: number; - deferredPayload: DeferredPayload; - }> { - const [vaultPda] = this.findVault(params.walletPda); - const s = params.signer; - const authorityPda = s.authorityPda - ?? this.findAuthority(params.walletPda, s.signer.credentialIdHash)[0]; - const slot = s.slotOverride ?? BigInt(await this.connection.getSlot()); - const counter = (await this.readCounter(authorityPda)) + 1; - const expiryOffset = params.expiryOffset ?? 300; - - const [deferredExecPda] = this.findDeferredExec(params.walletPda, authorityPda, counter); - - // TX2 fixed accounts: payer, wallet, vault, deferred, refund_destination (=payer) - const tx2FixedAccounts = [params.payer, params.walletPda, vaultPda, deferredExecPda, params.payer]; - const { compactInstructions, remainingAccounts } = buildCompactLayout(tx2FixedAccounts, params.instructions); - - // Compute hashes - const instructionsHash = computeInstructionsHash(compactInstructions); - const tx2AccountMetas = [ - { pubkey: params.payer, isSigner: true, isWritable: true }, - { pubkey: params.walletPda, isSigner: false, isWritable: true }, - { pubkey: vaultPda, isSigner: false, isWritable: true }, - { pubkey: deferredExecPda, isSigner: false, isWritable: true }, - { pubkey: params.payer, isSigner: false, isWritable: true }, // refund dest - ...remainingAccounts, - ]; - const accountsHash = computeAccountsHash(tx2AccountMetas, compactInstructions); - const expiryOffsetBuf = new Uint8Array(2); - expiryOffsetBuf[0] = expiryOffset & 0xff; - expiryOffsetBuf[1] = (expiryOffset >> 8) & 0xff; - const signedPayload = concatParts([instructionsHash, accountsHash, expiryOffsetBuf]); - - const { authPayload, precompileIx } = await signWithSecp256r1({ - signer: s.signer, discriminator: new Uint8Array([DISC_AUTHORIZE]), - signedPayload, sysvarIxIndex: SYSVAR_IX_INDEX_AUTHORIZE, - slot, counter, payer: params.payer, programId: this.programId, - }); - - const authorizeIx = createAuthorizeIx({ - payer: params.payer, walletPda: params.walletPda, authorityPda, deferredExecPda, - instructionsHash, accountsHash, expiryOffset, authPayload, - programId: this.programId, - }); - - return { - instructions: [precompileIx, authorizeIx], - deferredExecPda, - counter, - deferredPayload: { walletPda: params.walletPda, deferredExecPda, compactInstructions, remainingAccounts }, - }; - } - - // ─── ExecuteDeferred (from payload) ───────────────────────────── - - /** - * Build TX2 from the payload returned by `authorize()`. - */ - executeDeferredFromPayload(params: { - payer: PublicKey; - deferredPayload: DeferredPayload; - refundDestination?: PublicKey; - }): { instructions: TransactionInstruction[] } { - const [vaultPda] = this.findVault(params.deferredPayload.walletPda); - const refundDest = params.refundDestination ?? params.payer; - const packed = packCompactInstructions(params.deferredPayload.compactInstructions); - const ix = createExecuteDeferredIx({ - payer: params.payer, walletPda: params.deferredPayload.walletPda, vaultPda, - deferredExecPda: params.deferredPayload.deferredExecPda, - refundDestination: refundDest, packedInstructions: packed, - remainingAccounts: params.deferredPayload.remainingAccounts, - programId: this.programId, - }); - return { instructions: [ix] }; - } - - // ─── ReclaimDeferred ──────────────────────────────────────────── - - reclaimDeferred(params: { - payer: PublicKey; - deferredExecPda: PublicKey; - refundDestination?: PublicKey; - }): { instructions: TransactionInstruction[] } { - const ix = createReclaimDeferredIx({ - payer: params.payer, - deferredExecPda: params.deferredExecPda, - refundDestination: params.refundDestination ?? params.payer, - programId: this.programId, - }); - return { instructions: [ix] }; - } - - // ─── RevokeSession ───────────────────────────────────────────── - - /** - * Revoke a session key early (before expiry). - * Only Owner or Admin can revoke. Refunds session rent. - * - * @example Revoke with Ed25519 admin - * ```typescript - * const { instructions } = await client.revokeSession({ - * payer: payer.publicKey, - * walletPda, - * adminSigner: ed25519(adminKp.publicKey, adminAuthorityPda), - * sessionPda, - * }); - * ``` - */ - async revokeSession(params: { - payer: PublicKey; - walletPda: PublicKey; - adminSigner: AdminSigner; - sessionPda: PublicKey; - refundDestination?: PublicKey; - }): Promise<{ instructions: TransactionInstruction[] }> { - const refundDest = params.refundDestination ?? params.payer; - const s = params.adminSigner; - - if (s.type === 'ed25519') { - const ix = createRevokeSessionIx({ - payer: params.payer, walletPda: params.walletPda, - adminAuthorityPda: s.authorityPda ?? this.findAuthority(params.walletPda, s.publicKey.toBytes())[0], - sessionPda: params.sessionPda, refundDestination: refundDest, - authorizerSigner: s.publicKey, programId: this.programId, - }); - return { instructions: [ix] }; - } - - const adminAuthorityPda = s.authorityPda - ?? this.findAuthority(params.walletPda, s.signer.credentialIdHash)[0]; - const slot = s.slotOverride ?? BigInt(await this.connection.getSlot()); - const counter = (await this.readCounter(adminAuthorityPda)) + 1; - - const signedPayload = concatParts([ - params.sessionPda.toBytes(), refundDest.toBytes(), - ]); - - const { authPayload, precompileIx } = await signWithSecp256r1({ - signer: s.signer, discriminator: new Uint8Array([DISC_REVOKE_SESSION]), - signedPayload, sysvarIxIndex: SYSVAR_IX_INDEX_REVOKE_SESSION, - slot, counter, payer: params.payer, programId: this.programId, - }); - - const ix = createRevokeSessionIx({ - payer: params.payer, walletPda: params.walletPda, adminAuthorityPda, - sessionPda: params.sessionPda, refundDestination: refundDest, - authPayload, programId: this.programId, - }); - return { instructions: [precompileIx, ix] }; - } -} diff --git a/sdk/solita-client/src/utils/compact.ts b/sdk/solita-client/src/utils/compact.ts deleted file mode 100644 index ffe078d..0000000 --- a/sdk/solita-client/src/utils/compact.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { PublicKey, TransactionInstruction } from '@solana/web3.js'; -import type { CompactInstruction } from './packing'; - -/** - * Converts standard Solana TransactionInstructions into the compact format - * expected by the Execute instruction. Automatically computes account indexes - * and builds the remaining accounts list. - * - * @param fixedAccounts - Accounts already in the instruction layout - * (e.g., payer, wallet, authority, vault, sysvar) - * @param instructions - Standard Solana TransactionInstructions to convert - */ -export function buildCompactLayout( - fixedAccounts: PublicKey[], - instructions: TransactionInstruction[], -): { - compactInstructions: CompactInstruction[]; - remainingAccounts: { pubkey: PublicKey; isSigner: boolean; isWritable: boolean }[]; -} { - // Index map: pubkey base58 -> index in the full account layout - const indexMap = new Map(); - for (let i = 0; i < fixedAccounts.length; i++) { - indexMap.set(fixedAccounts[i].toBase58(), i); - } - - // Collect remaining accounts (unique, preserving insertion order) - const remainingMap = new Map(); - - for (const ix of instructions) { - // Program ID (never signer, never writable) - const progKey = ix.programId.toBase58(); - if (!indexMap.has(progKey) && !remainingMap.has(progKey)) { - remainingMap.set(progKey, { pubkey: ix.programId, isSigner: false, isWritable: false }); - } - // Account keys - for (const key of ix.keys) { - const k = key.pubkey.toBase58(); - if (!indexMap.has(k)) { - if (remainingMap.has(k)) { - // Merge: most permissive flags win - const existing = remainingMap.get(k)!; - existing.isSigner = existing.isSigner || key.isSigner; - existing.isWritable = existing.isWritable || key.isWritable; - } else { - remainingMap.set(k, { pubkey: key.pubkey, isSigner: key.isSigner, isWritable: key.isWritable }); - } - } - } - } - - // Assign indexes to remaining accounts (after fixed accounts) - const remainingAccounts = Array.from(remainingMap.values()); - let nextIndex = fixedAccounts.length; - for (const acc of remainingAccounts) { - indexMap.set(acc.pubkey.toBase58(), nextIndex++); - } - - // Convert each instruction to compact format - const compactInstructions: CompactInstruction[] = instructions.map(ix => ({ - programIdIndex: indexMap.get(ix.programId.toBase58())!, - accountIndexes: ix.keys.map(k => indexMap.get(k.pubkey.toBase58())!), - data: new Uint8Array(ix.data), - })); - - return { compactInstructions, remainingAccounts }; -} diff --git a/sdk/solita-client/src/utils/ed25519.ts b/sdk/solita-client/src/utils/ed25519.ts deleted file mode 100644 index 1855e55..0000000 --- a/sdk/solita-client/src/utils/ed25519.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { PublicKey } from '@solana/web3.js'; - -/** - * Callback interface for Ed25519 signing. The SDK never touches private keys. - * Implementors provide their own signing logic (e.g. Keypair.sign, hardware wallet). - */ -export interface Ed25519Signer { - publicKey: PublicKey; - sign(message: Uint8Array): Promise; -} diff --git a/sdk/solita-client/src/utils/errors.ts b/sdk/solita-client/src/utils/errors.ts deleted file mode 100644 index 87de488..0000000 --- a/sdk/solita-client/src/utils/errors.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Friendly error code map for LazorKit program errors. - * Re-exports Solita-generated error classes and provides a lookup utility. - */ -export { - errorFromCode, - errorFromName, - InvalidAuthorityPayloadError, - PermissionDeniedError, - InvalidInstructionError, - InvalidPubkeyError, - InvalidMessageHashError, - SignatureReusedError, - InvalidSignatureAgeError, - InvalidSessionDurationError, - SessionExpiredError, - AuthorityDoesNotSupportSessionError, - InvalidAuthenticationKindError, - InvalidMessageError, - SelfReentrancyNotAllowedError, -} from '../generated/errors'; - -/** Map of error code → human-readable name */ -export const ERROR_NAMES: Record = { - 3001: 'InvalidAuthorityPayload', - 3002: 'PermissionDenied', - 3003: 'InvalidInstruction', - 3004: 'InvalidPubkey', - 3005: 'InvalidMessageHash', - 3006: 'SignatureReused', - 3007: 'InvalidSignatureAge', - 3008: 'InvalidSessionDuration', - 3009: 'SessionExpired', - 3010: 'AuthorityDoesNotSupportSession', - 3011: 'InvalidAuthenticationKind', - 3012: 'InvalidMessage', - 3013: 'SelfReentrancyNotAllowed', - 3014: 'DeferredAuthorizationExpired', - 3015: 'DeferredHashMismatch', - 3016: 'InvalidExpiryWindow', - 3017: 'UnauthorizedReclaim', - 3018: 'DeferredAuthorizationNotExpired', -}; - -/** - * Extracts the custom program error code from a Solana SendTransactionError. - * Returns null if the error is not a custom program error. - */ -export function extractErrorCode(err: unknown): number | null { - const msg = String(err); - const match = msg.match(/custom program error: 0x([0-9a-fA-F]+)/); - if (match) return parseInt(match[1], 16); - const match2 = msg.match(/Custom\((\d+)\)/); - if (match2) return parseInt(match2[1], 10); - return null; -} diff --git a/sdk/solita-client/src/utils/index.ts b/sdk/solita-client/src/utils/index.ts deleted file mode 100644 index f5315de..0000000 --- a/sdk/solita-client/src/utils/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export * from './pdas'; -export * from './secp256r1'; -export * from './ed25519'; -export * from './packing'; -export * from './errors'; -export * from './instructions'; -export * from './types'; -export * from './signing'; -export * from './compact'; -export * from './client'; diff --git a/sdk/solita-client/src/utils/instructions.ts b/sdk/solita-client/src/utils/instructions.ts deleted file mode 100644 index 5094892..0000000 --- a/sdk/solita-client/src/utils/instructions.ts +++ /dev/null @@ -1,531 +0,0 @@ -/** - * Hand-written instruction builders that produce the exact raw binary format - * the LazorKit program expects. Solita-generated builders use beet which adds - * length prefixes to `bytes` fields, causing a mismatch. - */ -import { - PublicKey, - TransactionInstruction, - SystemProgram, - SYSVAR_INSTRUCTIONS_PUBKEY, - SYSVAR_RENT_PUBKEY, -} from '@solana/web3.js'; -import { PROGRAM_ID } from '../generated'; - -// ─── Discriminators ────────────────────────────────────────────────── -export const DISC_CREATE_WALLET = 0; -export const DISC_ADD_AUTHORITY = 1; -export const DISC_REMOVE_AUTHORITY = 2; -export const DISC_TRANSFER_OWNERSHIP = 3; -export const DISC_EXECUTE = 4; -export const DISC_CREATE_SESSION = 5; -export const DISC_AUTHORIZE = 6; -export const DISC_EXECUTE_DEFERRED = 7; -export const DISC_RECLAIM_DEFERRED = 8; -export const DISC_REVOKE_SESSION = 9; - -// ─── Authority types ───────────────────────────────────────────────── -export const AUTH_TYPE_ED25519 = 0; -export const AUTH_TYPE_SECP256R1 = 1; - -// ─── Roles ─────────────────────────────────────────────────────────── -export const ROLE_OWNER = 0; -export const ROLE_ADMIN = 1; -export const ROLE_SPENDER = 2; - -// ─── CreateWallet ──────────────────────────────────────────────────── -/** - * Instruction data layout (after discriminator): - * [user_seed(32)][auth_type(1)][auth_bump(1)][padding(6)] - * Ed25519: [pubkey(32)] - * Secp256r1: [credential_id_hash(32)][pubkey(33)][rpIdLen(1)][rpId(N)] - */ -export function createCreateWalletIx(params: { - payer: PublicKey; - walletPda: PublicKey; - vaultPda: PublicKey; - authorityPda: PublicKey; - userSeed: Uint8Array; - authType: number; - authBump: number; - /** Ed25519: 32-byte pubkey. Secp256r1: 32-byte credential_id_hash */ - credentialOrPubkey: Uint8Array; - /** Secp256r1 only: 33-byte compressed pubkey */ - secp256r1Pubkey?: Uint8Array; - /** Secp256r1 only: RP ID string (stored on-chain for per-tx savings) */ - rpId?: string; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const parts: Uint8Array[] = [ - new Uint8Array([DISC_CREATE_WALLET]), - params.userSeed, - new Uint8Array([params.authType, params.authBump]), - new Uint8Array(6), // padding - params.credentialOrPubkey, - ]; - if (params.authType === AUTH_TYPE_SECP256R1 && params.secp256r1Pubkey) { - parts.push(params.secp256r1Pubkey); - if (params.rpId) { - const rpIdBytes = Buffer.from(params.rpId, 'utf-8'); - parts.push(new Uint8Array([rpIdBytes.length])); - parts.push(new Uint8Array(rpIdBytes)); - } - } - - return new TransactionInstruction({ - programId: pid, - keys: [ - { pubkey: params.payer, isSigner: true, isWritable: true }, - { pubkey: params.walletPda, isSigner: false, isWritable: true }, - { pubkey: params.vaultPda, isSigner: false, isWritable: true }, - { pubkey: params.authorityPda, isSigner: false, isWritable: true }, - { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, - { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, - ], - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── AddAuthority ──────────────────────────────────────────────────── -/** - * Instruction data layout (after discriminator): - * [auth_type(1)][new_role(1)][padding(6)] - * Ed25519: [pubkey(32)] - * Secp256r1: [credential_id_hash(32)][pubkey(33)][rpIdLen(1)][rpId(N)] + [auth_payload(...)] - */ -export function createAddAuthorityIx(params: { - payer: PublicKey; - walletPda: PublicKey; - adminAuthorityPda: PublicKey; - newAuthorityPda: PublicKey; - newType: number; - newRole: number; - /** Ed25519: 32-byte pubkey. Secp256r1: 32-byte credential_id_hash */ - credentialOrPubkey: Uint8Array; - /** Secp256r1 only: 33-byte compressed pubkey */ - secp256r1Pubkey?: Uint8Array; - /** Secp256r1 only: RP ID string for the new authority */ - rpId?: string; - /** Auth payload for Secp256r1 admin authentication */ - authPayload?: Uint8Array; - /** For Ed25519 admin: the signer pubkey */ - authorizerSigner?: PublicKey; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const parts: Uint8Array[] = [ - new Uint8Array([DISC_ADD_AUTHORITY]), - new Uint8Array([params.newType, params.newRole]), - new Uint8Array(6), // padding - params.credentialOrPubkey, - ]; - if (params.newType === AUTH_TYPE_SECP256R1 && params.secp256r1Pubkey) { - parts.push(params.secp256r1Pubkey); - if (params.rpId) { - const rpIdBytes = Buffer.from(params.rpId, 'utf-8'); - parts.push(new Uint8Array([rpIdBytes.length])); - parts.push(new Uint8Array(rpIdBytes)); - } - } - if (params.authPayload) { - parts.push(params.authPayload); - } - - const keys = [ - { pubkey: params.payer, isSigner: true, isWritable: false }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: params.adminAuthorityPda, isSigner: false, isWritable: true }, - { pubkey: params.newAuthorityPda, isSigner: false, isWritable: true }, - { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, - { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, - ]; - - // Secp256r1 auth needs sysvar instructions; Ed25519 needs the signer - if (params.authorizerSigner) { - keys.push({ pubkey: params.authorizerSigner, isSigner: true, isWritable: false }); - } else if (params.authPayload) { - keys.push({ pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }); - } - - return new TransactionInstruction({ - programId: pid, - keys, - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── RemoveAuthority ───────────────────────────────────────────────── -/** - * Instruction data layout (after discriminator): - * Secp256r1: [auth_payload(...)] - * Ed25519: empty (auth is via signer) - */ -export function createRemoveAuthorityIx(params: { - payer: PublicKey; - walletPda: PublicKey; - adminAuthorityPda: PublicKey; - targetAuthorityPda: PublicKey; - refundDestination: PublicKey; - authPayload?: Uint8Array; - authorizerSigner?: PublicKey; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const parts: Uint8Array[] = [new Uint8Array([DISC_REMOVE_AUTHORITY])]; - if (params.authPayload) { - parts.push(params.authPayload); - } - - const keys = [ - { pubkey: params.payer, isSigner: true, isWritable: false }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: params.adminAuthorityPda, isSigner: false, isWritable: true }, - { pubkey: params.targetAuthorityPda, isSigner: false, isWritable: true }, - { pubkey: params.refundDestination, isSigner: false, isWritable: true }, - ]; - - if (params.authorizerSigner) { - keys.push({ pubkey: params.authorizerSigner, isSigner: true, isWritable: false }); - } else if (params.authPayload) { - keys.push({ pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }); - } - - return new TransactionInstruction({ - programId: pid, - keys, - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── TransferOwnership ────────────────────────────────────────────── -/** - * Instruction data layout (after discriminator): - * [auth_type(1)] - * Ed25519: [pubkey(32)] - * Secp256r1: [credential_id_hash(32)][pubkey(33)][rpIdLen(1)][rpId(N)] + [auth_payload(...)] - */ -export function createTransferOwnershipIx(params: { - payer: PublicKey; - walletPda: PublicKey; - currentOwnerAuthorityPda: PublicKey; - newOwnerAuthorityPda: PublicKey; - newType: number; - /** Ed25519: 32-byte pubkey. Secp256r1: 32-byte credential_id_hash */ - credentialOrPubkey: Uint8Array; - /** Secp256r1 only: 33-byte compressed pubkey */ - secp256r1Pubkey?: Uint8Array; - /** Secp256r1 only: RP ID string for the new owner */ - rpId?: string; - authPayload?: Uint8Array; - authorizerSigner?: PublicKey; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const parts: Uint8Array[] = [ - new Uint8Array([DISC_TRANSFER_OWNERSHIP]), - new Uint8Array([params.newType]), - params.credentialOrPubkey, - ]; - if (params.newType === AUTH_TYPE_SECP256R1 && params.secp256r1Pubkey) { - parts.push(params.secp256r1Pubkey); - if (params.rpId) { - const rpIdBytes = Buffer.from(params.rpId, 'utf-8'); - parts.push(new Uint8Array([rpIdBytes.length])); - parts.push(new Uint8Array(rpIdBytes)); - } - } - if (params.authPayload) { - parts.push(params.authPayload); - } - - const keys = [ - { pubkey: params.payer, isSigner: true, isWritable: false }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: params.currentOwnerAuthorityPda, isSigner: false, isWritable: true }, - { pubkey: params.newOwnerAuthorityPda, isSigner: false, isWritable: true }, - { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, - { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, - ]; - - if (params.authorizerSigner) { - keys.push({ pubkey: params.authorizerSigner, isSigner: true, isWritable: false }); - } else if (params.authPayload) { - keys.push({ pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }); - } - - return new TransactionInstruction({ - programId: pid, - keys, - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── Execute ───────────────────────────────────────────────────────── -/** - * Instruction data layout (after discriminator): - * [compact_instructions(variable)] - * Secp256r1: [auth_payload(variable)] - */ -export function createExecuteIx(params: { - payer: PublicKey; - walletPda: PublicKey; - authorityPda: PublicKey; - vaultPda: PublicKey; - packedInstructions: Uint8Array; - authPayload?: Uint8Array; - /** For Ed25519 auth: the signer pubkey (placed at account index 4) */ - authorizerSigner?: PublicKey; - /** Additional account metas for the inner CPI instructions */ - remainingAccounts?: { pubkey: PublicKey; isSigner: boolean; isWritable: boolean }[]; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const parts: Uint8Array[] = [ - new Uint8Array([DISC_EXECUTE]), - params.packedInstructions, - ]; - if (params.authPayload) { - parts.push(params.authPayload); - } - - const keys = [ - { pubkey: params.payer, isSigner: true, isWritable: false }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: params.authorityPda, isSigner: false, isWritable: true }, - { pubkey: params.vaultPda, isSigner: false, isWritable: true }, - ]; - - // Ed25519 needs the signer at index 4; Secp256r1 needs sysvar instructions - if (params.authorizerSigner) { - keys.push({ pubkey: params.authorizerSigner, isSigner: true, isWritable: false }); - } else if (params.authPayload) { - keys.push({ pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }); - } - - // Remaining accounts for CPI targets - if (params.remainingAccounts) { - keys.push(...params.remainingAccounts); - } - - return new TransactionInstruction({ - programId: pid, - keys, - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── CreateSession ─────────────────────────────────────────────────── -/** - * Instruction data layout (after discriminator): - * [session_key(32)][expires_at(8)] - * Secp256r1: [auth_payload(variable)] - */ -export function createCreateSessionIx(params: { - payer: PublicKey; - walletPda: PublicKey; - adminAuthorityPda: PublicKey; - sessionPda: PublicKey; - sessionKey: Uint8Array; - expiresAt: bigint; - authPayload?: Uint8Array; - authorizerSigner?: PublicKey; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const expiresAtBuf = Buffer.alloc(8); - expiresAtBuf.writeBigInt64LE(params.expiresAt); - - const parts: Uint8Array[] = [ - new Uint8Array([DISC_CREATE_SESSION]), - params.sessionKey, - new Uint8Array(expiresAtBuf), - ]; - if (params.authPayload) { - parts.push(params.authPayload); - } - - const keys = [ - { pubkey: params.payer, isSigner: true, isWritable: false }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: params.adminAuthorityPda, isSigner: false, isWritable: true }, - { pubkey: params.sessionPda, isSigner: false, isWritable: true }, - { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, - { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, - ]; - - if (params.authorizerSigner) { - keys.push({ pubkey: params.authorizerSigner, isSigner: true, isWritable: false }); - } else if (params.authPayload) { - keys.push({ pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }); - } - - return new TransactionInstruction({ - programId: pid, - keys, - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── Authorize (Deferred Execution tx1) ───────────────────────────── -/** - * Instruction data layout (after discriminator): - * [instructions_hash(32)][accounts_hash(32)][expiry_offset(2)][auth_payload(variable)] - */ -export function createAuthorizeIx(params: { - payer: PublicKey; - walletPda: PublicKey; - authorityPda: PublicKey; - deferredExecPda: PublicKey; - instructionsHash: Uint8Array; - accountsHash: Uint8Array; - expiryOffset: number; - authPayload: Uint8Array; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const expiryBuf = Buffer.alloc(2); - expiryBuf.writeUInt16LE(params.expiryOffset); - - const parts: Uint8Array[] = [ - new Uint8Array([DISC_AUTHORIZE]), - params.instructionsHash, - params.accountsHash, - new Uint8Array(expiryBuf), - params.authPayload, - ]; - - return new TransactionInstruction({ - programId: pid, - keys: [ - { pubkey: params.payer, isSigner: true, isWritable: true }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: params.authorityPda, isSigner: false, isWritable: true }, - { pubkey: params.deferredExecPda, isSigner: false, isWritable: true }, - { pubkey: SystemProgram.programId, isSigner: false, isWritable: false }, - { pubkey: SYSVAR_RENT_PUBKEY, isSigner: false, isWritable: false }, - { pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }, - ], - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── ExecuteDeferred (Deferred Execution tx2) ─────────────────────── -/** - * Instruction data layout (after discriminator): - * [compact_instructions(variable)] - */ -export function createExecuteDeferredIx(params: { - payer: PublicKey; - walletPda: PublicKey; - vaultPda: PublicKey; - deferredExecPda: PublicKey; - refundDestination: PublicKey; - packedInstructions: Uint8Array; - /** Additional account metas for the inner CPI instructions */ - remainingAccounts?: { pubkey: PublicKey; isSigner: boolean; isWritable: boolean }[]; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const parts: Uint8Array[] = [ - new Uint8Array([DISC_EXECUTE_DEFERRED]), - params.packedInstructions, - ]; - - const keys = [ - { pubkey: params.payer, isSigner: true, isWritable: true }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: params.vaultPda, isSigner: false, isWritable: true }, - { pubkey: params.deferredExecPda, isSigner: false, isWritable: true }, - { pubkey: params.refundDestination, isSigner: false, isWritable: true }, - ]; - - if (params.remainingAccounts) { - keys.push(...params.remainingAccounts); - } - - return new TransactionInstruction({ - programId: pid, - keys, - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── ReclaimDeferred ──────────────────────────────────────────────── -/** - * Closes an expired DeferredExec account and refunds rent. - * Instruction data: discriminator only (no payload). - */ -export function createReclaimDeferredIx(params: { - payer: PublicKey; - deferredExecPda: PublicKey; - refundDestination: PublicKey; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - - return new TransactionInstruction({ - programId: pid, - keys: [ - { pubkey: params.payer, isSigner: true, isWritable: false }, - { pubkey: params.deferredExecPda, isSigner: false, isWritable: true }, - { pubkey: params.refundDestination, isSigner: false, isWritable: true }, - ], - data: Buffer.from([DISC_RECLAIM_DEFERRED]), - }); -} - -// ─── RevokeSession ────────────────────────────────────────────────── -/** - * Revoke a session key early (before expiry). - * Only Owner or Admin can revoke. - * Instruction data: [discriminator(1)][auth_payload(...) for Secp256r1 | empty for Ed25519] - */ -export function createRevokeSessionIx(params: { - payer: PublicKey; - walletPda: PublicKey; - adminAuthorityPda: PublicKey; - sessionPda: PublicKey; - refundDestination: PublicKey; - authPayload?: Uint8Array; - authorizerSigner?: PublicKey; - programId?: PublicKey; -}): TransactionInstruction { - const pid = params.programId ?? PROGRAM_ID; - const parts: Uint8Array[] = [new Uint8Array([DISC_REVOKE_SESSION])]; - if (params.authPayload) { - parts.push(params.authPayload); - } - - const keys = [ - { pubkey: params.payer, isSigner: true, isWritable: false }, - { pubkey: params.walletPda, isSigner: false, isWritable: false }, - { pubkey: params.adminAuthorityPda, isSigner: false, isWritable: true }, - { pubkey: params.sessionPda, isSigner: false, isWritable: true }, - { pubkey: params.refundDestination, isSigner: false, isWritable: true }, - ]; - - if (params.authorizerSigner) { - keys.push({ pubkey: params.authorizerSigner, isSigner: true, isWritable: false }); - } else if (params.authPayload) { - keys.push({ pubkey: SYSVAR_INSTRUCTIONS_PUBKEY, isSigner: false, isWritable: false }); - } - - return new TransactionInstruction({ - programId: pid, - keys, - data: Buffer.from(concatBytes(parts)), - }); -} - -// ─── Helper ────────────────────────────────────────────────────────── -function concatBytes(arrays: Uint8Array[]): Uint8Array { - const totalLen = arrays.reduce((s, a) => s + a.length, 0); - const out = new Uint8Array(totalLen); - let offset = 0; - for (const a of arrays) { - out.set(a, offset); - offset += a.length; - } - return out; -} diff --git a/sdk/solita-client/src/utils/packing.ts b/sdk/solita-client/src/utils/packing.ts deleted file mode 100644 index 2f26aa9..0000000 --- a/sdk/solita-client/src/utils/packing.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { type AccountMeta } from '@solana/web3.js'; -import { createHash } from 'crypto'; - -export interface CompactInstruction { - programIdIndex: number; - accountIndexes: number[]; - data: Uint8Array; -} - -/** - * Packs a list of compact instructions into the binary format expected - * by LazorKit's Execute instruction. - * - * Format: - * [num_instructions: u8] - * for each instruction: - * [program_id_index: u8] - * [num_accounts: u8] - * [account_indexes: u8[]] - * [data_len: u16 LE] - * [data: u8[]] - */ -export function packCompactInstructions(instructions: CompactInstruction[]): Uint8Array { - const parts: Uint8Array[] = []; - parts.push(new Uint8Array([instructions.length])); - - for (const ix of instructions) { - parts.push(new Uint8Array([ix.programIdIndex, ix.accountIndexes.length])); - parts.push(new Uint8Array(ix.accountIndexes)); - const len = ix.data.length; - parts.push(new Uint8Array([len & 0xff, (len >> 8) & 0xff])); - parts.push(ix.data); - } - - return concatBytes(parts); -} - -/** - * Computes the SHA-256 hash of all account pubkeys referenced by compact instructions. - * Must match the on-chain `compute_accounts_hash`. - */ -export function computeAccountsHash( - accountMetas: AccountMeta[], - instructions: CompactInstruction[], -): Uint8Array { - const parts: Uint8Array[] = []; - for (const ix of instructions) { - parts.push(accountMetas[ix.programIdIndex].pubkey.toBytes()); - for (const idx of ix.accountIndexes) { - parts.push(accountMetas[idx].pubkey.toBytes()); - } - } - const data = concatBytes(parts); - return new Uint8Array(createHash('sha256').update(data).digest()); -} - -/** - * Computes the SHA-256 hash of packed compact instructions. - * Used for deferred execution — the hash is signed in tx1 and verified in tx2. - */ -export function computeInstructionsHash( - instructions: CompactInstruction[], -): Uint8Array { - const packed = packCompactInstructions(instructions); - return new Uint8Array(createHash('sha256').update(packed).digest()); -} - -function concatBytes(arrays: Uint8Array[]): Uint8Array { - const totalLen = arrays.reduce((s, a) => s + a.length, 0); - const out = new Uint8Array(totalLen); - let offset = 0; - for (const a of arrays) { - out.set(a, offset); - offset += a.length; - } - return out; -} diff --git a/sdk/solita-client/src/utils/pdas.ts b/sdk/solita-client/src/utils/pdas.ts deleted file mode 100644 index a38b876..0000000 --- a/sdk/solita-client/src/utils/pdas.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { PublicKey } from '@solana/web3.js'; -import { PROGRAM_ID } from '../generated'; - -export function findWalletPda( - userSeed: Uint8Array, - programId: PublicKey = PROGRAM_ID, -): [PublicKey, number] { - return PublicKey.findProgramAddressSync( - [Buffer.from('wallet'), userSeed], - programId, - ); -} - -export function findVaultPda( - walletPda: PublicKey, - programId: PublicKey = PROGRAM_ID, -): [PublicKey, number] { - return PublicKey.findProgramAddressSync( - [Buffer.from('vault'), walletPda.toBuffer()], - programId, - ); -} - -export function findAuthorityPda( - walletPda: PublicKey, - credentialIdHash: Uint8Array, - programId: PublicKey = PROGRAM_ID, -): [PublicKey, number] { - return PublicKey.findProgramAddressSync( - [Buffer.from('authority'), walletPda.toBuffer(), credentialIdHash], - programId, - ); -} - -export function findSessionPda( - walletPda: PublicKey, - sessionKey: Uint8Array, - programId: PublicKey = PROGRAM_ID, -): [PublicKey, number] { - return PublicKey.findProgramAddressSync( - [Buffer.from('session'), walletPda.toBuffer(), sessionKey], - programId, - ); -} - -export function findDeferredExecPda( - walletPda: PublicKey, - authorityPda: PublicKey, - counter: number, - programId: PublicKey = PROGRAM_ID, -): [PublicKey, number] { - const counterBuf = Buffer.alloc(4); - counterBuf.writeUInt32LE(counter); - return PublicKey.findProgramAddressSync( - [ - Buffer.from('deferred'), - walletPda.toBuffer(), - authorityPda.toBuffer(), - counterBuf, - ], - programId, - ); -} diff --git a/sdk/solita-client/src/utils/secp256r1.ts b/sdk/solita-client/src/utils/secp256r1.ts deleted file mode 100644 index 062fb02..0000000 --- a/sdk/solita-client/src/utils/secp256r1.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { Connection, PublicKey } from '@solana/web3.js'; -import { createHash } from 'crypto'; -import { PROGRAM_ID } from '../generated'; - -/** - * Generates WebAuthn authenticator data for a given RP ID. - * - * Format: rpIdHash(32) + flags(1) + counter(4) = 37 bytes - * - Flags: 0x01 (User Present) - * - Counter: 0 (LazorKit uses its own odometer counter, not WebAuthn counter) - */ -export function generateAuthenticatorData(rpId: string): Uint8Array { - const rpIdHash = createHash('sha256').update(rpId).digest(); - const data = new Uint8Array(37); - data.set(rpIdHash, 0); - data[32] = 0x01; // User Present flag - // Counter bytes (33-36) stay 0 - return data; -} - -/** - * Callback interface for Secp256r1 (passkey/WebAuthn) signing. - * The SDK never touches private keys. - * - * The sign() method receives a SHA-256 challenge and must: - * 1. Build clientDataJSON: `{ type: "webauthn.get", challenge: base64url(challenge), origin: "https://", crossOrigin: false }` - * 2. Compute clientDataJsonHash = SHA256(clientDataJSON) - * 3. Sign: signature = ECDSA_SIGN(authenticatorData || clientDataJsonHash) - * 4. Return { signature (64-byte raw r||s, low-S normalized), authenticatorData, clientDataJsonHash } - */ -export interface Secp256r1Signer { - /** Compressed public key (33 bytes) */ - publicKeyBytes: Uint8Array; - /** SHA256 of the credential ID (32 bytes) — used as PDA seed */ - credentialIdHash: Uint8Array; - /** RP ID string (e.g. "lazorkit.app") */ - rpId: string; - /** - * Signs the SHA-256 challenge with the passkey. - * Returns { signature, authenticatorData, clientDataJsonHash }. - */ - sign(challenge: Uint8Array): Promise<{ - /** 64-byte raw ECDSA signature (r || s), low-S normalized */ - signature: Uint8Array; - /** WebAuthn authenticator data bytes */ - authenticatorData: Uint8Array; - /** SHA256 of the clientDataJSON */ - clientDataJsonHash: Uint8Array; - }>; -} - -/** - * Reads the current odometer counter from an on-chain authority account. - * The counter is a u32 LE at offset 8 of the AuthorityAccountHeader. - */ -export async function readAuthorityCounter( - connection: Connection, - authorityPda: PublicKey, -): Promise { - const info = await connection.getAccountInfo(authorityPda); - if (!info) throw new Error(`Authority account not found: ${authorityPda.toBase58()}`); - if (info.data.length < 12) throw new Error('Authority account data too short'); - const view = new DataView(info.data.buffer, info.data.byteOffset); - return view.getUint32(8, true); // offset 8, little-endian, u32 -} - -/** - * Builds the auth_payload bytes for a Secp256r1 operation. - * - * Layout (optimized — rpId stored on-chain, counter is u32): - * [slot(8)][counter(4)][sysvarIxIdx(1)][typeAndFlags(1)][authenticatorData(M)] - */ -export function buildAuthPayload(params: { - slot: bigint; - counter: number; - sysvarIxIndex: number; - typeAndFlags: number; - authenticatorData: Uint8Array; -}): Uint8Array { - const totalLen = 8 + 4 + 1 + 1 + params.authenticatorData.length; - const buf = Buffer.alloc(totalLen); - let offset = 0; - - buf.writeBigUInt64LE(params.slot, offset); - offset += 8; - buf.writeUInt32LE(params.counter, offset); - offset += 4; - buf.writeUInt8(params.sysvarIxIndex, offset); - offset += 1; - buf.writeUInt8(params.typeAndFlags, offset); - offset += 1; - Buffer.from(params.authenticatorData).copy(buf, offset); - - return new Uint8Array(buf); -} - -/** - * Computes the SHA-256 challenge hash that must be signed by the passkey. - * - * Hash = SHA256(discriminator || auth_payload || signed_payload || slot_le || payer || counter_le(4) || program_id) - * - * This must exactly match the on-chain `sol_sha256` call in secp256r1/mod.rs. - */ -export function buildSecp256r1Challenge(params: { - discriminator: Uint8Array; - authPayload: Uint8Array; - signedPayload: Uint8Array; - slot: bigint; - payer: PublicKey; - counter: number; - programId?: PublicKey; -}): Uint8Array { - const pid = params.programId ?? PROGRAM_ID; - const slotBuf = Buffer.alloc(8); - slotBuf.writeBigUInt64LE(params.slot); - const counterBuf = Buffer.alloc(4); - counterBuf.writeUInt32LE(params.counter); - - const hash = createHash('sha256'); - hash.update(params.discriminator); - hash.update(params.authPayload); - hash.update(params.signedPayload); - hash.update(slotBuf); - hash.update(params.payer.toBuffer()); - hash.update(counterBuf); - hash.update(pid.toBuffer()); - return new Uint8Array(hash.digest()); -} diff --git a/sdk/solita-client/src/utils/signing.ts b/sdk/solita-client/src/utils/signing.ts deleted file mode 100644 index dceb31d..0000000 --- a/sdk/solita-client/src/utils/signing.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { PublicKey, TransactionInstruction } from '@solana/web3.js'; -import { - buildAuthPayload, - buildSecp256r1Challenge, - generateAuthenticatorData, - type Secp256r1Signer, -} from './secp256r1'; -import { AUTH_TYPE_SECP256R1 } from './instructions'; - -// ─── Secp256r1 signing flow ───────────────────────────────────────── - -/** - * Full Secp256r1 signing: build auth payload, compute challenge, sign, build precompile ix. - */ -export async function signWithSecp256r1(params: { - signer: Secp256r1Signer; - discriminator: Uint8Array; - signedPayload: Uint8Array; - sysvarIxIndex: number; - slot: bigint; - counter: number; - payer: PublicKey; - programId: PublicKey; -}): Promise<{ - authPayload: Uint8Array; - precompileIx: TransactionInstruction; -}> { - const authenticatorData = generateAuthenticatorData(params.signer.rpId); - - const authPayload = buildAuthPayload({ - slot: params.slot, - counter: params.counter, - sysvarIxIndex: params.sysvarIxIndex, - typeAndFlags: 0x10, // webauthn.get + https - authenticatorData, - }); - - const challenge = buildSecp256r1Challenge({ - discriminator: params.discriminator, - authPayload, - signedPayload: params.signedPayload, - slot: params.slot, - payer: params.payer, - counter: params.counter, - programId: params.programId, - }); - - const { signature, authenticatorData: signerAuthData, clientDataJsonHash } = - await params.signer.sign(challenge); - - const precompileMessage = concatParts([signerAuthData, clientDataJsonHash]); - const precompileIx = buildSecp256r1PrecompileIx( - params.signer.publicKeyBytes, - precompileMessage, - signature, - ); - - return { authPayload, precompileIx }; -} - -// ─── Data payload builders ────────────────────────────────────────── - -/** - * AddAuthority data payload: - * [type(1)][role(1)][padding(6)][credential(32)][secp256r1Pubkey?(33)][rpIdLen?(1)][rpId?(N)] - */ -export function buildDataPayloadForAdd( - newType: number, - newRole: number, - credentialOrPubkey: Uint8Array, - secp256r1Pubkey?: Uint8Array, - rpId?: string, -): Uint8Array { - const parts: Uint8Array[] = [ - new Uint8Array([newType, newRole]), - new Uint8Array(6), // padding - credentialOrPubkey, - ]; - if (newType === AUTH_TYPE_SECP256R1 && secp256r1Pubkey) { - parts.push(secp256r1Pubkey); - if (rpId) { - const rpIdBytes = Buffer.from(rpId, 'utf-8'); - parts.push(new Uint8Array([rpIdBytes.length])); - parts.push(new Uint8Array(rpIdBytes)); - } - } - return concatParts(parts); -} - -/** - * TransferOwnership data payload: [auth_type(1)][full_auth_data] - */ -export function buildDataPayloadForTransfer( - newType: number, - credentialOrPubkey: Uint8Array, - secp256r1Pubkey?: Uint8Array, - rpId?: string, -): Uint8Array { - const parts: Uint8Array[] = [ - new Uint8Array([newType]), - credentialOrPubkey, - ]; - if (newType === AUTH_TYPE_SECP256R1 && secp256r1Pubkey) { - parts.push(secp256r1Pubkey); - if (rpId) { - const rpIdBytes = Buffer.from(rpId, 'utf-8'); - parts.push(new Uint8Array([rpIdBytes.length])); - parts.push(new Uint8Array(rpIdBytes)); - } - } - return concatParts(parts); -} - -/** - * CreateSession data payload: [session_key(32)][expires_at(8)] - */ -export function buildDataPayloadForSession( - sessionKey: Uint8Array, - expiresAt: bigint, -): Uint8Array { - const buf = new Uint8Array(40); - buf.set(sessionKey, 0); - const expiresAtBuf = Buffer.alloc(8); - expiresAtBuf.writeBigInt64LE(expiresAt); - buf.set(new Uint8Array(expiresAtBuf), 32); - return buf; -} - -// ─── Helpers ──────────────────────────────────────────────────────── - -export function concatParts(parts: Uint8Array[]): Uint8Array { - const totalLen = parts.reduce((s, a) => s + a.length, 0); - const out = new Uint8Array(totalLen); - let offset = 0; - for (const a of parts) { - out.set(a, offset); - offset += a.length; - } - return out; -} - -/** - * Builds the Secp256r1 precompile verify instruction. - * Program: Secp256r1SigVerify111111111111111111111111111 - */ -export function buildSecp256r1PrecompileIx( - publicKey: Uint8Array, - message: Uint8Array, - signature: Uint8Array, -): TransactionInstruction { - const SECP256R1_PROGRAM_ID = new PublicKey('Secp256r1SigVerify1111111111111111111111111'); - - const HEADER_SIZE = 16; - const sigOffset = HEADER_SIZE; - const pubkeyOffset = sigOffset + 64; - const msgOffset = pubkeyOffset + 33 + 1; // 1-byte alignment padding - - const data = Buffer.alloc(HEADER_SIZE + 64 + 33 + 1 + message.length); - let off = 0; - - data.writeUInt8(1, off); off += 1; - data.writeUInt8(0, off); off += 1; - data.writeUInt16LE(sigOffset, off); off += 2; - data.writeUInt16LE(0xFFFF, off); off += 2; - data.writeUInt16LE(pubkeyOffset, off); off += 2; - data.writeUInt16LE(0xFFFF, off); off += 2; - data.writeUInt16LE(msgOffset, off); off += 2; - data.writeUInt16LE(message.length, off); off += 2; - data.writeUInt16LE(0xFFFF, off); off += 2; - - Buffer.from(signature).copy(data, sigOffset); - Buffer.from(publicKey).copy(data, pubkeyOffset); - Buffer.from(message).copy(data, msgOffset); - - return new TransactionInstruction({ - programId: SECP256R1_PROGRAM_ID, - keys: [], - data, - }); -} diff --git a/sdk/solita-client/src/utils/types.ts b/sdk/solita-client/src/utils/types.ts deleted file mode 100644 index 7942298..0000000 --- a/sdk/solita-client/src/utils/types.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { PublicKey } from '@solana/web3.js'; -import type { Secp256r1Signer } from './secp256r1'; - -// ─── CreateWallet owner types ──────────────────────────────────────── - -export interface CreateWalletEd25519 { - type: 'ed25519'; - publicKey: PublicKey; -} - -export interface CreateWalletSecp256r1 { - type: 'secp256r1'; - credentialIdHash: Uint8Array; - compressedPubkey: Uint8Array; - rpId: string; -} - -/** Owner union for createWallet() */ -export type CreateWalletOwner = CreateWalletEd25519 | CreateWalletSecp256r1; - -// ─── Discriminated union signer types ───────────────────────────────── - -/** Ed25519 signer — the Keypair signs at transaction level */ -export interface Ed25519SignerConfig { - type: 'ed25519'; - publicKey: PublicKey; - /** Pre-derived authority PDA (auto-derived from publicKey if omitted) */ - authorityPda?: PublicKey; -} - -/** Secp256r1 (passkey / WebAuthn) signer */ -export interface Secp256r1SignerConfig { - type: 'secp256r1'; - signer: Secp256r1Signer; - /** Pre-derived authority PDA (auto-derived from credentialIdHash if omitted) */ - authorityPda?: PublicKey; - /** Override slot (auto-fetched from connection if omitted) */ - slotOverride?: bigint; -} - -/** Session key signer */ -export interface SessionSignerConfig { - type: 'session'; - sessionPda: PublicKey; - sessionKeyPubkey: PublicKey; -} - -/** Signer union for admin operations (authority/ownership/session management) */ -export type AdminSigner = Ed25519SignerConfig | Secp256r1SignerConfig; - -/** Signer union for execute operations (includes session keys) */ -export type ExecuteSigner = Ed25519SignerConfig | Secp256r1SignerConfig | SessionSignerConfig; - -/** Pre-computed data from authorize() needed by executeDeferredFromPayload() */ -export interface DeferredPayload { - walletPda: PublicKey; - deferredExecPda: PublicKey; - compactInstructions: { programIdIndex: number; accountIndexes: number[]; data: Uint8Array }[]; - remainingAccounts: { pubkey: PublicKey; isSigner: boolean; isWritable: boolean }[]; -} - -// ─── Helper constructors ────────────────────────────────────────────── - -export function ed25519(publicKey: PublicKey, authorityPda?: PublicKey): Ed25519SignerConfig { - return { type: 'ed25519', publicKey, authorityPda }; -} - -export function secp256r1( - signer: Secp256r1Signer, - opts?: { authorityPda?: PublicKey; slotOverride?: bigint }, -): Secp256r1SignerConfig { - return { type: 'secp256r1', signer, ...opts }; -} - -export function session(sessionPda: PublicKey, sessionKeyPubkey: PublicKey): SessionSignerConfig { - return { type: 'session', sessionPda, sessionKeyPubkey }; -} diff --git a/sdk/solita-client/src/utils/wrapper.ts b/sdk/solita-client/src/utils/wrapper.ts deleted file mode 100644 index 83289b3..0000000 --- a/sdk/solita-client/src/utils/wrapper.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @deprecated Import from './client' instead. This file is a backward-compat shim. - * - * The old monolithic wrapper has been split into focused modules: - * - client.ts — LazorKitClient (unified API with discriminated signer types) - * - signing.ts — Secp256r1 signing helpers & data payload builders - * - compact.ts — buildCompactLayout (TransactionInstruction → CompactInstruction) - * - types.ts — Signer type definitions (AdminSigner, ExecuteSigner, etc.) - */ -export { LazorKitClient } from './client'; diff --git a/sdk/solita-client/tsconfig.json b/sdk/solita-client/tsconfig.json deleted file mode 100644 index 90fa74d..0000000 --- a/sdk/solita-client/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020"], - "declaration": true, - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "resolveJsonModule": true, - "moduleResolution": "node" - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] -} From db8909a2ac612ec81294f3b676d9b30f92b6bdc4 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Mon, 4 May 2026 14:30:38 +0700 Subject: [PATCH 15/25] docs: scrub solita-client references; point to @lazorkit/sdk-legacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Followup to deleting sdk/solita-client. Updated: - README.md: install/usage examples + project structure now reference @lazorkit/sdk-legacy. Added a one-liner explaining the SDK's flavor-blind probing behavior so foundation + commercial users see the same DX. - DEVELOPMENT.md: removed solita-client from project structure, removed the SDK regeneration workflow (the SDK is hand-written upstream), added a note on local file: link setup. - SECURITY.md: in-scope SDK reference updated. - CHANGELOG.md: replaced the v0.1.0 line claiming Solita codegen with a pointer to the published @lazorkit/sdk-legacy. - docs/Architecture.md: removed the in-tree SDK module tree from the layout diagram, added a pointer to the sibling repo. - scripts/build-all.sh: removed step 3 (Solita SDK regeneration). Build is now Rust + IDL only. Only remaining "solita-client" mention is in the CHANGELOG entry that documents this removal — intentional. --- CHANGELOG.md | 2 +- DEVELOPMENT.md | 27 ++++++++++++++++++++------- README.md | 15 ++++++--------- SECURITY.md | 2 +- docs/Architecture.md | 18 +++++------------- scripts/build-all.sh | 19 +++++++++---------- 6 files changed, 42 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fee6a5..174077c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Odometer counter replay protection for Secp256r1 (monotonic u32 per authority) - program_id included in challenge hash (cross-program replay prevention) - rpId stored on authority account at creation (saves ~14 bytes per transaction) -- TypeScript SDK (`sdk/solita-client`) with Solita code generation +- TypeScript SDK: standardised on `@lazorkit/sdk-legacy` (lives in sibling `lazorkit-protocol` repo); the in-tree `sdk/solita-client` has been removed - Integration + security test suite (`tests-sdk/`) with 56 tests across 11 files - Benchmark script for CU and transaction size measurements - CompactInstructions accounts hash for anti-reordering protection diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 862f8d3..6591d89 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -13,12 +13,17 @@ This document outlines the standard procedures for building, deploying, and test ``` /program Rust smart contract (pinocchio, zero-copy) -/sdk/solita-client TypeScript SDK (Solita-generated + hand-written utils) -/tests-sdk Integration tests (vitest, @solana/web3.js v1) +/tests-sdk Integration tests (vitest, @lazorkit/sdk-legacy) /scripts Build/deploy automation /audits Audit reports /no-padding Custom NoPadding derive macro /assertions Custom assertion helpers + +The TypeScript SDK lives in the sibling `lazorkit-protocol` repo at +`sdk/sdk-legacy/` and is published to npm as `@lazorkit/sdk-legacy`. The +same SDK transparently handles both this build (program-v2, no fees) and +the commercial build — it probes ProtocolConfig on first use and +conditionally appends fee accounts. ``` ## Core Workflows @@ -38,8 +43,9 @@ cargo build-sbf --features devnet cargo build-sbf --features mainnet ``` -The convenience script `./scripts/build-all.sh ` builds, generates -the IDL, and regenerates the SDK in one shot. +The convenience script `./scripts/build-all.sh ` builds and +regenerates the IDL in one shot. SDK regeneration is no longer needed — +`@lazorkit/sdk-legacy` is hand-written and lives in the sibling repo. ### B. Run Rust Tests @@ -60,13 +66,20 @@ 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) +### D. SDK + +`@lazorkit/sdk-legacy` is hand-written (no codegen) and lives in the +sibling `lazorkit-protocol` repo at `sdk/sdk-legacy/`. To use it locally: ```bash -cd sdk/solita-client && node generate.mjs +# In a sibling checkout: /Users/.../lazorkit-protocol/sdk/sdk-legacy +npm install && npm run build + +# Then in this repo's tests-sdk (already configured via `file:` link): +cd tests-sdk && npm install ``` -The generate.mjs script reads the Shank IDL, enriches it with accounts/errors/types, and runs Solita to produce TypeScript code in `src/generated/`. +Once published to npm, consumers do `npm install @lazorkit/sdk-legacy`. ### E. Running Integration Tests diff --git a/README.md b/README.md index 1cbe96d..c9f9219 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,7 @@ program/src/ Rust smart contract (pinocchio, zero-copy) auth/ Ed25519 + Secp256r1/WebAuthn authentication processor/ 9 instruction handlers state/ Account data structures (NoPadding) -sdk/solita-client/ TypeScript SDK (Solita-generated + hand-written utils) - src/generated/ Auto-generated instructions, accounts, errors - src/utils/ Instruction builders, PDA helpers, signing utils -tests-sdk/ Integration tests (vitest, 56 tests) +tests-sdk/ Integration tests (vitest, 56 tests, uses @lazorkit/sdk-legacy) docs/ Architecture, cost analysis audits/ Audit reports ``` @@ -120,14 +117,14 @@ cargo build-sbf ### Install SDK ```bash -npm install @lazorkit/solita-client +npm install @lazorkit/sdk-legacy ``` ### Create a Wallet ```typescript import { Connection } from '@solana/web3.js'; -import { LazorKitClient } from '@lazorkit/solita-client'; +import { LazorKitClient } from '@lazorkit/sdk-legacy'; import * as crypto from 'crypto'; const connection = new Connection('https://api.devnet.solana.com'); @@ -148,7 +145,7 @@ const { instructions, walletPda, vaultPda } = client.createWallet({ ### Transfer SOL ```typescript -import { secp256r1 } from '@lazorkit/solita-client'; +import { secp256r1 } from '@lazorkit/sdk-legacy'; // Just payer, wallet, signer, recipient, amount -- nothing else const { instructions } = await client.transferSol({ @@ -174,7 +171,7 @@ const { instructions } = await client.execute({ }); ``` -See [sdk/solita-client/README.md](sdk/solita-client/README.md) for full API reference. +See the [@lazorkit/sdk-legacy README](https://github.com/lazor-kit/lazorkit-protocol/tree/main/sdk/sdk-legacy) for full API reference. The same SDK transparently handles both this build (program-v2, no fees) and the commercial build (lazorkit-protocol, with fees) — it probes ProtocolConfig on first use and conditionally appends fee accounts. --- @@ -222,7 +219,7 @@ Report vulnerabilities via [SECURITY.md](SECURITY.md). |---|---| | [Architecture](docs/Architecture.md) | Account structures, security mechanisms, instruction reference | | [Costs](docs/Costs.md) | CU benchmarks, rent costs, transaction size analysis | -| [SDK API](sdk/solita-client/README.md) | TypeScript SDK reference | +| [@lazorkit/sdk-legacy](https://github.com/lazor-kit/lazorkit-protocol/tree/main/sdk/sdk-legacy) | TypeScript SDK reference | | [Development](DEVELOPMENT.md) | Build, test, deploy workflow | | [Contributing](CONTRIBUTING.md) | How to contribute | | [Security](SECURITY.md) | Vulnerability reporting | diff --git a/SECURITY.md b/SECURITY.md index 87afe53..e672c44 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,7 +19,7 @@ If you discover a security vulnerability in LazorKit, please report it responsib The following are in scope: - On-chain Solana program (`program/src/`) -- TypeScript SDK (`sdk/solita-client/`) +- TypeScript SDK (`@lazorkit/sdk-legacy`, lives in sibling `lazorkit-protocol` repo) - PDA derivation and signature verification logic - Replay protection mechanisms diff --git a/docs/Architecture.md b/docs/Architecture.md index 7fb9db4..3dc4b9b 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -335,18 +335,10 @@ program/ utils.rs PDA initialization, stack_height check error.rs AuthError enum (3001-3018) entrypoint.rs Instruction routing -sdk/solita-client/ - src/ - generated/ Solita-generated instructions, accounts, errors - utils/ - instructions.ts Low-level instruction builders - client.ts LazorKitClient high-level API (unified) - types.ts Discriminated union signer types + helper constructors - signing.ts Secp256r1 signing utilities - compact.ts CompactInstruction layout builder - pdas.ts PDA derivation helpers - secp256r1.ts Challenge hash + auth payload builders - packing.ts CompactInstruction packing - errors.ts Error code mapping tests-sdk/ Integration + security tests (vitest, 56 tests) ``` + +The TypeScript SDK lives outside this repo: `@lazorkit/sdk-legacy` (in +sibling `lazorkit-protocol` repo at `sdk/sdk-legacy/`). Same SDK works +against this build (foundation, no fee) and the commercial build — +probes ProtocolConfig at runtime and conditionally appends fee accounts. diff --git a/scripts/build-all.sh b/scripts/build-all.sh index 29a9953..0f41d01 100755 --- a/scripts/build-all.sh +++ b/scripts/build-all.sh @@ -1,6 +1,6 @@ #!/bin/bash -# Build the Rust program for a chosen cluster, derive the program ID from the -# resulting keypair, regenerate IDL + SDK against that ID. +# Build the Rust program for a chosen cluster, derive the program ID from +# the resulting keypair, regenerate IDL. # # Usage: # ./scripts/build-all.sh devnet # builds with --features devnet (FLb7...) @@ -8,12 +8,16 @@ # # After this script the .so + keypair live at target/deploy/. Deploy with: # solana program deploy target/deploy/lazorkit_program.so -u +# +# The TypeScript SDK lives in the sibling lazorkit-protocol repo at +# sdk/sdk-legacy/ and is published to npm as @lazorkit/sdk-legacy. No +# SDK regeneration step here — the SDK is hand-written and works +# unchanged against either cluster (it probes ProtocolConfig at runtime). set -e CLUSTER=$1 ROOT_DIR=$(pwd) PROGRAM_DIR="$ROOT_DIR/program" -SDK_DIR="$ROOT_DIR/sdk/solita-client" if [ "$CLUSTER" != "mainnet" ] && [ "$CLUSTER" != "devnet" ]; then echo "Usage: $0 " @@ -24,13 +28,13 @@ echo "--- 🚀 LazorKit build (cluster: $CLUSTER) ---" # 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)..." +echo "[1/2] Building Rust Program (cargo build-sbf --features $CLUSTER)..." cd "$PROGRAM_DIR" 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..." +echo "[2/2] 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 @@ -40,10 +44,5 @@ else exit 1 fi -# Step 3: Regenerate SDK with Solita. -echo "[3/3] Regenerating Solita SDK..." -cd "$SDK_DIR" -node generate.mjs - echo "--- ✅ Done ($CLUSTER) ---" echo "Deploy: solana program deploy ../target/deploy/lazorkit_program.so -u $([ "$CLUSTER" = "mainnet" ] && echo m || echo d)" From 85abe826c943baf1cbfff86e5c054674f05e2076 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Mon, 4 May 2026 14:42:04 +0700 Subject: [PATCH 16/25] test(actions): E2E session-action enforcement against live validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9 vitest cases dogfooding @lazorkit/sdk-legacy's Actions builder against program-v2's session enforcement engine. All pass against solana-test-validator with the foundation binary loaded. Coverage: - session without actions = unrestricted (baseline) - ProgramWhitelist: allow whitelisted, reject non-whitelisted (3021) - ProgramBlacklist: allow non-blacklisted, reject blacklisted (3022) - SolMaxPerTx: allow at-cap, reject over-cap (3023) - SolLimit (lifetime): allow within budget, reject when exhausted (3024), then accept exact remaining - Combined ProgramWhitelist + SolMaxPerTx: both rules enforced Asserts vault-balance delta rather than recipient balance (recipient is the test payer, an existing funded account, to sidestep the rent-exempt minimum that fresh accounts hit on a tiny SOL transfer). Action checks fire before the inner CPI, so this doesn't weaken what's being tested. Also fixes tests/common.ts to resolve PROGRAM_ID dynamically: 1. PROGRAM_ID env var (CI override) 2. Pubkey of target/deploy/lazorkit_program-keypair.json (matches what `npm run validator:start` loads the program at) 3. FLb7… fallback (typecheck-only) Previously hardcoded to FLb7…, which broke for any locally built binary because cargo build-sbf generates a fresh keypair on first build. LazorKitClient is constructed with `new LazorKitClient(connection, PROGRAM_ID)` to override its URL-based auto-inference (which defaults localhost to the commercial 4h3X… ID). Verified end-to-end: built program-v2 SBF binary, ran solana-test-validator, ran `npx vitest run tests/12-actions.test.ts` → 9/9 pass in ~9 seconds. --- tests-sdk/tests/12-actions.test.ts | 316 +++++++++++++++++++++++++++++ tests-sdk/tests/common.ts | 35 +++- 2 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 tests-sdk/tests/12-actions.test.ts diff --git a/tests-sdk/tests/12-actions.test.ts b/tests-sdk/tests/12-actions.test.ts new file mode 100644 index 0000000..78e314a --- /dev/null +++ b/tests-sdk/tests/12-actions.test.ts @@ -0,0 +1,316 @@ +/** + * Session action enforcement (E2E). + * + * P1c added the action enforcement engine on-chain (program-v2 has been + * exercising it via litesvm Rust tests). This file is the TypeScript-side + * dogfood: every assertion goes through @lazorkit/sdk-legacy's Actions + * builder and serialiser, then submits a real tx and watches what the + * program does to it. + * + * Coverage: + * - ProgramWhitelist: allow whitelisted CPI, reject non-whitelisted (3021) + * - ProgramBlacklist: reject blacklisted CPI (3022) + * - SolMaxPerTx: allow ≤ max, reject > max (3023) + * - SolLimit (lifetime): allow within budget, reject after exhaust (3024) + * - Bonus: session WITHOUT actions is unrestricted (sanity baseline) + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { + Keypair, + PublicKey, + SystemProgram, + LAMPORTS_PER_SOL, +} from '@solana/web3.js'; +import * as crypto from 'crypto'; +import { + setupTest, + sendTx, + sendTxExpectError, + getSlot, + PROGRAM_ID, + type TestContext, +} from './common'; +import { Actions, LazorKitClient, ed25519, session } from '@lazorkit/sdk-legacy'; + +// MEMO program — innocuous CPI target for whitelist tests +const MEMO_PROGRAM_ID = new PublicKey( + 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr', +); + +describe('Session Actions (E2E enforcement)', () => { + let ctx: TestContext; + let client: LazorKitClient; + + let walletPda: PublicKey; + let vaultPda: PublicKey; + let ownerKp: Keypair; + let ownerAuthPda: PublicKey; + + beforeAll(async () => { + ctx = await setupTest(); + // Pass PROGRAM_ID explicitly — sdk-legacy's URL-based auto-infer + // defaults localhost to 4h3X… (commercial devnet), but the validator + // here loads program-v2's foundation binary at PROGRAM_ID (resolved + // from target/deploy/lazorkit_program-keypair.json). + client = new LazorKitClient(ctx.connection, PROGRAM_ID); + + ownerKp = Keypair.generate(); + const userSeed = crypto.randomBytes(32); + + const result = await client.createWallet({ + payer: ctx.payer.publicKey, + userSeed, + owner: { type: 'ed25519', publicKey: ownerKp.publicKey }, + }); + walletPda = result.walletPda; + vaultPda = result.vaultPda; + ownerAuthPda = result.authorityPda; + + await sendTx(ctx, result.instructions); + + // Fund the vault generously so spending caps are the constraint, not balance + const sig = await ctx.connection.requestAirdrop( + vaultPda, + 10 * LAMPORTS_PER_SOL, + ); + await ctx.connection.confirmTransaction(sig, 'confirmed'); + }); + + /** + * Helper: create a session with the given actions, return its (sessionPda, sessionKp). + */ + async function createSessionWithActions( + actions: Parameters[0]['actions'], + ): Promise<{ sessionPda: PublicKey; sessionKp: Keypair }> { + const sessionKp = Keypair.generate(); + const currentSlot = await getSlot(ctx); + const expiresAt = currentSlot + 9000n; // ~1h + + const { instructions, sessionPda } = await client.createSession({ + payer: ctx.payer.publicKey, + walletPda, + adminSigner: ed25519(ownerKp.publicKey, ownerAuthPda), + sessionKey: sessionKp.publicKey, + expiresAt, + actions, + }); + await sendTx(ctx, instructions, [ownerKp]); + + return { sessionPda, sessionKp }; + } + + /** + * Helper: build an execute tx that transfers `lamports` from vault to the + * test payer (an existing funded account, so SystemProgram.transfer doesn't + * trip the rent-exempt minimum check that fresh accounts hit). The action + * enforcement we're testing kicks in BEFORE the inner transfer runs, so + * the recipient choice doesn't affect what we're verifying. + */ + async function buildVaultTransferIxs( + sessionPda: PublicKey, + sessionKey: PublicKey, + lamports: number, + ): Promise<{ ixs: Awaited>['instructions']; recipient: PublicKey }> { + const recipient = ctx.payer.publicKey; + const { instructions } = await client.execute({ + payer: ctx.payer.publicKey, + walletPda, + signer: session(sessionPda, sessionKey), + instructions: [ + SystemProgram.transfer({ + fromPubkey: vaultPda, + toPubkey: recipient, + lamports, + }), + ], + }); + return { ixs: instructions, recipient }; + } + + /** Snapshot vault balance, run sendTx, return vault delta (positive = outflow). */ + async function txAndVaultDelta( + ixs: Awaited>['instructions'], + sessionKp: Keypair, + ): Promise { + const before = await ctx.connection.getBalance(vaultPda); + await sendTx(ctx, ixs, [sessionKp]); + const after = await ctx.connection.getBalance(vaultPda); + return before - after; + } + + // ─── Baseline ────────────────────────────────────────────────────── + + it('session without actions is unrestricted (baseline)', async () => { + // No actions arg → unrestricted session, any execute should pass + const { sessionPda, sessionKp } = await createSessionWithActions(undefined); + + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 500_000, + ); + expect(await txAndVaultDelta(ixs, sessionKp)).toBe(500_000); + }); + + // ─── ProgramWhitelist (action type 10) ───────────────────────────── + + it('ProgramWhitelist: allows CPI to whitelisted program', async () => { + // Whitelist System Program → SOL transfer (which CPIs to System Program) should succeed + const { sessionPda, sessionKp } = await createSessionWithActions([ + Actions.programWhitelist(SystemProgram.programId), + ]); + + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 300_000, + ); + expect(await txAndVaultDelta(ixs, sessionKp)).toBe(300_000); + }); + + it('ProgramWhitelist: rejects CPI to non-whitelisted program (3021)', async () => { + // Whitelist ONLY Memo program → SOL transfer (System Program CPI) must fail + const { sessionPda, sessionKp } = await createSessionWithActions([ + Actions.programWhitelist(MEMO_PROGRAM_ID), + ]); + + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 100_000, + ); + // 3021 = ActionProgramNotWhitelisted + await sendTxExpectError(ctx, ixs, [sessionKp], 3021); + }); + + // ─── ProgramBlacklist (action type 11) ───────────────────────────── + + it('ProgramBlacklist: rejects CPI to blacklisted program (3022)', async () => { + // Blacklist System Program → SOL transfer must fail + const { sessionPda, sessionKp } = await createSessionWithActions([ + Actions.programBlacklist(SystemProgram.programId), + ]); + + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 100_000, + ); + // 3022 = ActionProgramBlacklisted + await sendTxExpectError(ctx, ixs, [sessionKp], 3022); + }); + + it('ProgramBlacklist: allows CPI to non-blacklisted program', async () => { + // Blacklist Memo → SOL transfer (System Program CPI) is fine + const { sessionPda, sessionKp } = await createSessionWithActions([ + Actions.programBlacklist(MEMO_PROGRAM_ID), + ]); + + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 200_000, + ); + expect(await txAndVaultDelta(ixs, sessionKp)).toBe(200_000); + }); + + // ─── SolMaxPerTx (action type 3) ─────────────────────────────────── + + it('SolMaxPerTx: allows transfer at the cap', async () => { + const { sessionPda, sessionKp } = await createSessionWithActions([ + Actions.solMaxPerTx(500_000n), + ]); + + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 500_000, // exactly the cap + ); + expect(await txAndVaultDelta(ixs, sessionKp)).toBe(500_000); + }); + + it('SolMaxPerTx: rejects transfer above the cap (3023)', async () => { + const { sessionPda, sessionKp } = await createSessionWithActions([ + Actions.solMaxPerTx(500_000n), + ]); + + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 500_001, // 1 lamport over + ); + // 3023 = ActionSolMaxPerTxExceeded + await sendTxExpectError(ctx, ixs, [sessionKp], 3023); + }); + + // ─── SolLimit (lifetime cap, action type 1) ──────────────────────── + + it('SolLimit: allows spending within lifetime budget then rejects when exhausted (3024)', async () => { + // Lifetime budget = 1_000_000. First tx spends 700k → OK. + // Second tx tries 400k → would exceed remaining 300k → reject 3024. + const { sessionPda, sessionKp } = await createSessionWithActions([ + Actions.solLimit(1_000_000n), + ]); + + // 1st: spend 700_000 — fits + { + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 700_000, + ); + expect(await txAndVaultDelta(ixs, sessionKp)).toBe(700_000); + } + + // 2nd: spend 400_000 — exceeds remaining 300_000 → 3024 + { + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 400_000, + ); + // 3024 = ActionSolLimitExceeded + await sendTxExpectError(ctx, ixs, [sessionKp], 3024); + } + + // 3rd: spend exactly remaining 300_000 — should succeed + { + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 300_000, + ); + expect(await txAndVaultDelta(ixs, sessionKp)).toBe(300_000); + } + }); + + // ─── Combined actions ────────────────────────────────────────────── + + it('Combined: ProgramWhitelist + SolMaxPerTx both enforced', async () => { + // Whitelist System Program, cap per-tx at 250_000 + const { sessionPda, sessionKp } = await createSessionWithActions([ + Actions.programWhitelist(SystemProgram.programId), + Actions.solMaxPerTx(250_000n), + ]); + + // Within both rules → OK + { + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 250_000, + ); + expect(await txAndVaultDelta(ixs, sessionKp)).toBe(250_000); + } + + // Exceed SolMaxPerTx but System Program allowed → 3023 + { + const { ixs } = await buildVaultTransferIxs( + sessionPda, + sessionKp.publicKey, + 250_001, + ); + await sendTxExpectError(ctx, ixs, [sessionKp], 3023); + } + }); +}); diff --git a/tests-sdk/tests/common.ts b/tests-sdk/tests/common.ts index 7886587..d3fb124 100644 --- a/tests-sdk/tests/common.ts +++ b/tests-sdk/tests/common.ts @@ -1,3 +1,5 @@ +import * as fs from 'fs'; +import * as path from 'path'; import { Connection, Keypair, @@ -9,7 +11,38 @@ import { type Signer, } from '@solana/web3.js'; -export const PROGRAM_ID = new PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao'); +/** + * Resolve the program ID the test suite should target. Order: + * + * 1. `PROGRAM_ID` env var (CI / explicit override) + * 2. The pubkey of `target/deploy/lazorkit_program-keypair.json` — this is + * the address `solana-test-validator --bpf-program $(solana-keygen pubkey ...)` + * loaded the binary at, so PDA derivations on the client side match + * what the program sees at runtime. + * 3. Foundation devnet fallback (`FLb7…`) — used when neither env nor + * keypair file exist. Won't actually work end-to-end without a real + * validator setup, but lets type-check / static tooling proceed. + * + * Prior to this, PROGRAM_ID was hardcoded to FLb7 — broken for any locally + * built binary because cargo build-sbf generates a fresh keypair on first + * build (unless one is already present at the target path). + */ +function loadProgramId(): PublicKey { + if (process.env.PROGRAM_ID) { + return new PublicKey(process.env.PROGRAM_ID); + } + const keypairPath = path.resolve( + __dirname, + '../../target/deploy/lazorkit_program-keypair.json', + ); + if (fs.existsSync(keypairPath)) { + const secret = JSON.parse(fs.readFileSync(keypairPath, 'utf-8')); + return Keypair.fromSecretKey(new Uint8Array(secret)).publicKey; + } + return new PublicKey('FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao'); +} + +export const PROGRAM_ID = loadProgramId(); export const RPC_URL = process.env.RPC_URL || 'http://127.0.0.1:8899'; export interface TestContext { From 07bc8241a4ea96030222bc2a5de5475e42ebbaa3 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Mon, 4 May 2026 15:01:10 +0700 Subject: [PATCH 17/25] fix(tests-sdk): pass PROGRAM_ID explicitly to LazorKitClient constructor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sdk-legacy's LazorKitClient infers programId from the RPC URL when the second arg is omitted, defaulting localhost to the commercial devnet ID (4h3X…). Tests target the program-v2 binary loaded at the keypair pubkey, so the inference returns the wrong ID and all txs fail with "Attempt to load a program that does not exist". Pass PROGRAM_ID explicitly across all 9 test files. Also added the PROGRAM_ID import to 02-authority, 03-execute, 04-session, 07-e2e, 09-permissions, 10-session-execute (the others already had it). After this fix, vitest results against a live local validator: 35 passed | 28 failed | 2 skipped (65 total) The 28 remaining failures are ALL Secp256r1 paths. Root cause: program-v2's on-chain auth code still uses the OLD typeAndFlags format (extracts a single byte from auth_payload[13] and reconstructs clientDataJson on-chain), while sdk-legacy's mock signer uses the NEW format (embeds full clientDataJson directly in the payload). lazorkit-protocol's auth was upgraded to the new format; program-v2's wasn't ported. Fixing this requires porting lazorkit-protocol/program/src/auth/secp256r1/ to program-v2 — substantial change with audit attention. Tracked as follow-up. Ed25519 paths all pass. The 9 new E2E action tests (12-actions.test.ts) all pass since they use Ed25519 admin signers. --- tests-sdk/tests/01-wallet.test.ts | 2 +- tests-sdk/tests/02-authority.test.ts | 3 ++- tests-sdk/tests/03-execute.test.ts | 4 ++-- tests-sdk/tests/04-session.test.ts | 3 ++- tests-sdk/tests/07-e2e.test.ts | 4 ++-- tests-sdk/tests/09-permissions.test.ts | 3 ++- tests-sdk/tests/10-session-execute.test.ts | 3 ++- tests-sdk/tests/11-security.test.ts | 2 +- 8 files changed, 14 insertions(+), 10 deletions(-) diff --git a/tests-sdk/tests/01-wallet.test.ts b/tests-sdk/tests/01-wallet.test.ts index bb17233..4921dc5 100644 --- a/tests-sdk/tests/01-wallet.test.ts +++ b/tests-sdk/tests/01-wallet.test.ts @@ -16,7 +16,7 @@ describe('CreateWallet', () => { beforeAll(async () => { ctx = await setupTest(); - client = new LazorKitClient(ctx.connection); + client = new LazorKitClient(ctx.connection, PROGRAM_ID); }); it('creates a wallet with Ed25519 owner', async () => { diff --git a/tests-sdk/tests/02-authority.test.ts b/tests-sdk/tests/02-authority.test.ts index dc75989..6be97ff 100644 --- a/tests-sdk/tests/02-authority.test.ts +++ b/tests-sdk/tests/02-authority.test.ts @@ -5,6 +5,7 @@ import { setupTest, sendTx, sendTxExpectError, + PROGRAM_ID, type TestContext, } from './common'; import { generateMockSecp256r1Key, createMockSigner } from './secp256r1Utils'; @@ -25,7 +26,7 @@ describe('Authority Management', () => { beforeAll(async () => { ctx = await setupTest(); - client = new LazorKitClient(ctx.connection); + client = new LazorKitClient(ctx.connection, PROGRAM_ID); }); describe('Ed25519 admin flow', () => { diff --git a/tests-sdk/tests/03-execute.test.ts b/tests-sdk/tests/03-execute.test.ts index 832edea..c64289f 100644 --- a/tests-sdk/tests/03-execute.test.ts +++ b/tests-sdk/tests/03-execute.test.ts @@ -6,7 +6,7 @@ import { LAMPORTS_PER_SOL, } from '@solana/web3.js'; import * as crypto from 'crypto'; -import { setupTest, sendTx, type TestContext } from './common'; +import { setupTest, sendTx, PROGRAM_ID, type TestContext } from './common'; import { generateMockSecp256r1Key, createMockSigner } from './secp256r1Utils'; import { LazorKitClient, @@ -20,7 +20,7 @@ describe('Execute', () => { beforeAll(async () => { ctx = await setupTest(); - client = new LazorKitClient(ctx.connection); + client = new LazorKitClient(ctx.connection, PROGRAM_ID); }); describe('Ed25519 Execute', () => { diff --git a/tests-sdk/tests/04-session.test.ts b/tests-sdk/tests/04-session.test.ts index dbd0480..4ee4933 100644 --- a/tests-sdk/tests/04-session.test.ts +++ b/tests-sdk/tests/04-session.test.ts @@ -3,6 +3,7 @@ import { Keypair } from '@solana/web3.js'; import * as crypto from 'crypto'; import { setupTest, + PROGRAM_ID, sendTx, sendTxExpectError, getSlot, @@ -20,7 +21,7 @@ describe('CreateSession', () => { beforeAll(async () => { ctx = await setupTest(); - client = new LazorKitClient(ctx.connection); + client = new LazorKitClient(ctx.connection, PROGRAM_ID); ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); diff --git a/tests-sdk/tests/07-e2e.test.ts b/tests-sdk/tests/07-e2e.test.ts index 3c59492..103c8c5 100644 --- a/tests-sdk/tests/07-e2e.test.ts +++ b/tests-sdk/tests/07-e2e.test.ts @@ -5,7 +5,7 @@ import { LAMPORTS_PER_SOL, } from '@solana/web3.js'; import * as crypto from 'crypto'; -import { setupTest, sendTx, getSlot, type TestContext } from './common'; +import { setupTest, sendTx, PROGRAM_ID, getSlot, type TestContext } from './common'; import { generateMockSecp256r1Key, createMockSigner } from './secp256r1Utils'; import { LazorKitClient, @@ -44,7 +44,7 @@ describe('E2E Company Workflow', () => { beforeAll(async () => { ctx = await setupTest(); - client = new LazorKitClient(ctx.connection); + client = new LazorKitClient(ctx.connection, PROGRAM_ID); ceoKey = await generateMockSecp256r1Key('company.com'); adminKp = Keypair.generate(); spenderKey = await generateMockSecp256r1Key('company.com'); diff --git a/tests-sdk/tests/09-permissions.test.ts b/tests-sdk/tests/09-permissions.test.ts index 9d94d7a..ba31775 100644 --- a/tests-sdk/tests/09-permissions.test.ts +++ b/tests-sdk/tests/09-permissions.test.ts @@ -13,6 +13,7 @@ import { Keypair, LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js'; import * as crypto from 'crypto'; import { setupTest, + PROGRAM_ID, sendTx, sendTxExpectError, getSlot, @@ -44,7 +45,7 @@ describe('Permission Boundaries', () => { beforeAll(async () => { ctx = await setupTest(); - client = new LazorKitClient(ctx.connection); + client = new LazorKitClient(ctx.connection, PROGRAM_ID); // Create wallet with Ed25519 owner ownerKp = Keypair.generate(); diff --git a/tests-sdk/tests/10-session-execute.test.ts b/tests-sdk/tests/10-session-execute.test.ts index 45c6ab1..9d9aa9d 100644 --- a/tests-sdk/tests/10-session-execute.test.ts +++ b/tests-sdk/tests/10-session-execute.test.ts @@ -16,6 +16,7 @@ import { import * as crypto from 'crypto'; import { setupTest, + PROGRAM_ID, sendTx, sendTxExpectError, getSlot, @@ -38,7 +39,7 @@ describe('Session Execute', () => { beforeAll(async () => { ctx = await setupTest(); - client = new LazorKitClient(ctx.connection); + client = new LazorKitClient(ctx.connection, PROGRAM_ID); ownerKp = Keypair.generate(); const userSeed = crypto.randomBytes(32); diff --git a/tests-sdk/tests/11-security.test.ts b/tests-sdk/tests/11-security.test.ts index 269eb75..c73e55a 100644 --- a/tests-sdk/tests/11-security.test.ts +++ b/tests-sdk/tests/11-security.test.ts @@ -49,7 +49,7 @@ describe('Security', () => { beforeAll(async () => { ctx = await setupTest(); - client = new LazorKitClient(ctx.connection); + client = new LazorKitClient(ctx.connection, PROGRAM_ID); }); // ─── Counter increment verification ───────────────────────────── From 1a347a02deac8908cd3b5489ec52ca8978977d5b Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Wed, 6 May 2026 17:31:25 +0700 Subject: [PATCH 18/25] feat(auth): port Secp256r1 auth to clientDataJSON-embedding format Byte-identical with lazorkit-protocol/program/src/auth/secp256r1/. Replaces the older typeAndFlags format (which reconstructed clientDataJSON server-side from a single byte at auth_payload[13]) with the format that embeds the full raw clientDataJSON in the auth payload. Required for slot-share strategy: a wallet created on either binary (commercial or foundation) must remain verifiable after binary swap. Both binaries now share the same auth verification logic + on-chain authority account layout. Verification: - 58/65 vitest E2E tests pass against live validator (up from 12/65 before port). The 7 remaining failures are in 08-deferred.test.ts and reflect a test-side bug (missing expiryBuf in signedPayload), addressed in P5.3. --- program/src/auth/secp256r1/introspection.rs | 256 +++++++- program/src/auth/secp256r1/mod.rs | 214 ++++--- program/src/auth/secp256r1/webauthn.rs | 623 +++++++++++++++++--- 3 files changed, 924 insertions(+), 169 deletions(-) diff --git a/program/src/auth/secp256r1/introspection.rs b/program/src/auth/secp256r1/introspection.rs index a8ae963..ea95526 100644 --- a/program/src/auth/secp256r1/introspection.rs +++ b/program/src/auth/secp256r1/introspection.rs @@ -61,12 +61,18 @@ impl Secp256r1SignatureOffsets { } /// Verify the secp256r1 instruction data contains the expected signature and -/// public key. This also validates that the secp256r1 precompile offsets point -/// to the expected locations, ensuring proper data alignment. +/// public key. Also validates that the secp256r1 precompile offsets point to +/// the expected locations, ensuring proper data alignment. +/// +/// The expected precompile message is passed as TWO slices — the +/// authenticator_data and the clientDataJSON hash — which are concatenated +/// by the on-chain secp256r1 precompile as its signed message. Accepting +/// two slices here lets the caller skip a Vec allocation for the concat. pub fn verify_secp256r1_instruction_data( instruction_data: &[u8], expected_pubkey: &[u8; 33], - expected_message: &[u8], + auth_data: &[u8], + client_data_hash: &[u8; 32], ) -> Result<(), ProgramError> { // Minimum check: must have at least the header and offsets if instruction_data.len() < DATA_START { @@ -111,25 +117,257 @@ pub fn verify_secp256r1_instruction_data( if offsets.message_data_offset as usize != MESSAGE_DATA_OFFSET { return Err(AuthError::InvalidInstruction.into()); } - if offsets.message_data_size as usize != expected_message.len() { + let expected_msg_len = auth_data.len() + client_data_hash.len(); + if offsets.message_data_size as usize != expected_msg_len { return Err(AuthError::InvalidInstruction.into()); } // Dynamic length check: instruction must contain the full message - if instruction_data.len() < MESSAGE_DATA_OFFSET + expected_message.len() { + if instruction_data.len() < MESSAGE_DATA_OFFSET + expected_msg_len { return Err(AuthError::InvalidInstruction.into()); } let pubkey_data = &instruction_data [PUBKEY_DATA_OFFSET..PUBKEY_DATA_OFFSET + COMPRESSED_PUBKEY_SERIALIZED_SIZE]; - let message_data = - &instruction_data[MESSAGE_DATA_OFFSET..MESSAGE_DATA_OFFSET + expected_message.len()]; - if pubkey_data != expected_pubkey { return Err(AuthError::InvalidPubkey.into()); } - if message_data != expected_message { + + // Compare the precompile's message area against the two caller-supplied + // slices piecewise — no concat, no allocation. + let msg_auth = &instruction_data[MESSAGE_DATA_OFFSET..MESSAGE_DATA_OFFSET + auth_data.len()]; + if msg_auth != auth_data { + return Err(AuthError::InvalidMessageHash.into()); + } + let hash_start = MESSAGE_DATA_OFFSET + auth_data.len(); + let msg_hash = &instruction_data[hash_start..hash_start + client_data_hash.len()]; + if msg_hash != client_data_hash { return Err(AuthError::InvalidMessageHash.into()); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper: build valid secp256r1 precompile instruction data with the standard layout. + fn build_precompile_ix_data( + pubkey: &[u8; 33], + signature: &[u8; 64], + message: &[u8], + ) -> Vec { + let total_len = DATA_START + 64 + 33 + 1 + message.len(); + let mut data = vec![0u8; total_len]; + + // Header + data[0] = 1; // num_signatures + data[1] = 0; // padding + + // Offsets (little-endian) + data[2..4].copy_from_slice(&(SIGNATURE_DATA_OFFSET as u16).to_le_bytes()); + data[4..6].copy_from_slice(&0xFFFFu16.to_le_bytes()); // sig ix index + data[6..8].copy_from_slice(&(PUBKEY_DATA_OFFSET as u16).to_le_bytes()); + data[8..10].copy_from_slice(&0xFFFFu16.to_le_bytes()); // pubkey ix index + data[10..12].copy_from_slice(&(MESSAGE_DATA_OFFSET as u16).to_le_bytes()); + data[12..14].copy_from_slice(&(message.len() as u16).to_le_bytes()); // msg size + data[14..16].copy_from_slice(&0xFFFFu16.to_le_bytes()); // msg ix index + + // Data + data[SIGNATURE_DATA_OFFSET..SIGNATURE_DATA_OFFSET + 64].copy_from_slice(signature); + data[PUBKEY_DATA_OFFSET..PUBKEY_DATA_OFFSET + 33].copy_from_slice(pubkey); + // Byte at offset 113 is alignment padding (zero) + data[MESSAGE_DATA_OFFSET..MESSAGE_DATA_OFFSET + message.len()] + .copy_from_slice(message); + + data + } + + #[test] + fn test_verify_valid_instruction_data() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_ok()); + } + + #[test] + fn test_verify_variable_length_message() { + // Mode 1 messages are authenticatorData(37+) + clientDataJsonHash(32) = 69+ bytes. + // We split into the two halves exactly like the caller does post-refactor. + let pubkey = [0x03; 33]; + let signature = [0xCD; 64]; + let message = [0x22; 69]; + + let ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + let auth_data: &[u8] = &message[..37]; + let client_data_hash: &[u8; 32] = &message[37..].try_into().unwrap(); + assert!( + verify_secp256r1_instruction_data(&ix_data, &pubkey, auth_data, client_data_hash) + .is_ok() + ); + } + + #[test] + fn test_verify_rejects_wrong_pubkey() { + let pubkey = [0x02; 33]; + let wrong_pubkey = [0x03; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + let err = + verify_secp256r1_instruction_data(&ix_data, &wrong_pubkey, &[], &message).unwrap_err(); + assert_eq!(err, AuthError::InvalidPubkey.into()); + } + + #[test] + fn test_verify_rejects_wrong_message() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + let wrong_message = [0x22; 32]; + + let ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + let err = + verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &wrong_message).unwrap_err(); + assert_eq!(err, AuthError::InvalidMessageHash.into()); + } + + #[test] + fn test_verify_rejects_zero_signatures() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + ix_data[0] = 0; // zero signatures + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_multiple_signatures() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + ix_data[0] = 2; // two signatures + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_cross_instruction_sig_index() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + // Set signature_instruction_index to 0 instead of 0xFFFF + ix_data[4..6].copy_from_slice(&0u16.to_le_bytes()); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_cross_instruction_pubkey_index() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + // Set public_key_instruction_index to 1 instead of 0xFFFF + ix_data[8..10].copy_from_slice(&1u16.to_le_bytes()); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_cross_instruction_msg_index() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + // Set message_instruction_index to 0 instead of 0xFFFF + ix_data[14..16].copy_from_slice(&0u16.to_le_bytes()); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_wrong_pubkey_offset() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + // Tamper pubkey_offset to point elsewhere + ix_data[6..8].copy_from_slice(&200u16.to_le_bytes()); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_wrong_message_offset() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + // Tamper message_data_offset + ix_data[10..12].copy_from_slice(&50u16.to_le_bytes()); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_wrong_signature_offset() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + // Tamper signature_offset + ix_data[2..4].copy_from_slice(&100u16.to_le_bytes()); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_message_size_mismatch() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + // Set message_data_size to wrong value + ix_data[12..14].copy_from_slice(&64u16.to_le_bytes()); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_too_short_data() { + let pubkey = [0x02; 33]; + let message = [0x11; 32]; + + // Only 2 bytes — way too short + assert!(verify_secp256r1_instruction_data(&[0x01, 0x00], &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_verify_rejects_truncated_message_area() { + let pubkey = [0x02; 33]; + let signature = [0xAB; 64]; + let message = [0x11; 32]; + + let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); + // Truncate — remove last 10 bytes so message area is incomplete + ix_data.truncate(ix_data.len() - 10); + assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); + } + + #[test] + fn test_offsets_constants_are_consistent() { + assert_eq!(DATA_START, 16); // 2 header + 14 offsets + assert_eq!(SIGNATURE_DATA_OFFSET, 16); + assert_eq!(PUBKEY_DATA_OFFSET, 16 + 64); // 80 + assert_eq!(MESSAGE_DATA_OFFSET, 80 + 33 + 1); // 114 + } +} diff --git a/program/src/auth/secp256r1/mod.rs b/program/src/auth/secp256r1/mod.rs index a6bafe8..ecc8363 100644 --- a/program/src/auth/secp256r1/mod.rs +++ b/program/src/auth/secp256r1/mod.rs @@ -15,9 +15,7 @@ pub mod introspection; pub mod webauthn; use self::introspection::verify_secp256r1_instruction_data; -use self::webauthn::{ - reconstruct_client_data_json, AuthDataParser, ClientDataJsonReconstructionParams, -}; +use self::webauthn::{base64url_encode_no_pad, extract_top_level_string_field, AuthDataParser}; use crate::auth::traits::Authenticator; use crate::utils::get_stack_height; @@ -31,11 +29,20 @@ pub struct Secp256r1Authenticator; impl Authenticator for Secp256r1Authenticator { /// Authenticates a Secp256r1 signature (WebAuthn/Passkeys). /// - /// Auth payload layout: - /// [slot(8)] [counter(4)] [sysvarIxIdx(1)] [flags(1)] [authenticatorData(M)] + /// Auth payload layout (raw clientDataJSON — the only supported mode): + /// [slot(8)] [counter(4)] [sysvarIxIdx(1)] [_reserved(1)] + /// [authDataLen(2 LE)] [authenticatorData(M)] + /// [cdjLen(2 LE)] [clientDataJson(N)] + /// + /// rpIdHash is pre-computed at authority creation and stored on the + /// Authority account, so every Execute saves one sol_sha256 syscall and + /// the Authority account size is fixed (145 bytes for Secp256r1). /// - /// rpId is stored on the authority account (not in the payload). - /// Counter is a program-controlled u32 odometer. Client must submit `on_chain_counter + 1`. + /// Counter is a program-controlled u32 odometer. Client must submit + /// `on_chain_counter + 1`. + /// + /// Programmatic/bot signing should use Ed25519 authorities instead — + /// Secp256r1 is passkeys-only. fn authenticate( &self, accounts: &[AccountInfo], @@ -45,26 +52,23 @@ impl Authenticator for Secp256r1Authenticator { discriminator: &[u8], program_id: &Pubkey, ) -> Result<(), ProgramError> { - // Minimum: slot(8) + counter(4) + sysvarIxIdx(1) + flags(1) = 14 - if auth_payload.len() < 14 { + // Minimum: slot(8) + counter(4) + sysvarIxIdx(1) + reserved(1) = 14, + // plus authDataLen(2) + cdjLen(2) = 18 before any payload bytes. + if auth_payload.len() < 18 { return Err(AuthError::InvalidAuthorityPayload.into()); } let slot = u64::from_le_bytes(auth_payload[0..8].try_into().unwrap()); let submitted_counter = u32::from_le_bytes(auth_payload[8..12].try_into().unwrap()); let sysvar_ix_index = auth_payload[12] as usize; - - let reconstruction_params = ClientDataJsonReconstructionParams { - type_and_flags: auth_payload[13], - }; - let authenticator_data_raw: &[u8] = &auth_payload[14..]; + // auth_payload[13] reserved (carried over from legacy mode byte). // Anti-CPI check: prevent cross-program authentication attacks if get_stack_height() > 1 { return Err(AuthError::PermissionDenied.into()); } - // Validate slot freshness using Clock sysvar (replaces SlotHashes lookup) + // Validate slot freshness using Clock sysvar let clock = Clock::get()?; let current_slot = clock.slot; if slot > current_slot { @@ -84,55 +88,33 @@ impl Authenticator for Secp256r1Authenticator { }; // --- Odometer validation --- - // The client must submit exactly `stored_counter + 1`. - // This decouples replay protection from the WebAuthn hardware counter, - // which is unreliable for synced passkeys (iCloud, Google). let expected_counter = header.counter.wrapping_add(1); if submitted_counter != expected_counter { return Err(AuthError::SignatureReused.into()); } - // Secp256r1 on-chain data layout: - // [Header(48)] [credential_id_hash(32)] [Pubkey(33)] [rpIdLen(1)] [rpId(N)] - let pubkey_offset = header_size + 32; // skip credential_id_hash - if auth_data.len() < pubkey_offset + 33 { + // Secp256r1 on-chain data layout (fixed 145 bytes total): + // [Header(48)] [credential_id_hash(32)] [Pubkey(33)] [rpIdHash(32)] + let pubkey_offset = header_size + 32; // 80 + let rp_id_hash_offset = pubkey_offset + 33; // 113 + if auth_data.len() < rp_id_hash_offset + 32 { return Err(AuthError::InvalidAuthorityPayload.into()); } - - // Read rpId from authority account data (stored at creation time) - let rp_id_len_offset = pubkey_offset + 33; - if auth_data.len() < rp_id_len_offset + 1 { - return Err(AuthError::InvalidAuthorityPayload.into()); - } - let rp_id_len = auth_data[rp_id_len_offset] as usize; - let rp_id_offset = rp_id_len_offset + 1; - if auth_data.len() < rp_id_offset + rp_id_len { - return Err(AuthError::InvalidAuthorityPayload.into()); - } - let rp_id = &auth_data[rp_id_offset..rp_id_offset + rp_id_len]; - - #[allow(unused_assignments)] - let mut computed_rp_id_hash = [0u8; 32]; - #[cfg(target_os = "solana")] - unsafe { - let _res = pinocchio::syscalls::sol_sha256( - [rp_id].as_ptr() as *const u8, - 1, - computed_rp_id_hash.as_mut_ptr(), - ); - } - #[cfg(not(target_os = "solana"))] - { - computed_rp_id_hash = [0u8; 32]; - } + let stored_rp_id_hash = &auth_data[rp_id_hash_offset..rp_id_hash_offset + 32]; let payer = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; if !payer.is_signer() { return Err(ProgramError::MissingRequiredSignature); } - // Build challenge hash: - // SHA256(discriminator || auth_payload || signed_payload || slot || payer || counter || program_id) + // Challenge hash: + // SHA256(discriminator || auth_payload[..14] || signed_payload + // || payer || counter || program_id) + // + // Only the 14-byte fixed prefix of auth_payload is included because the + // remainder contains clientDataJSON — which is produced by the + // authenticator *after* signing the challenge, so it can't be in the + // hash input. let counter_bytes = expected_counter.to_le_bytes(); #[allow(unused_assignments)] let mut hasher = [0u8; 32]; @@ -141,57 +123,93 @@ impl Authenticator for Secp256r1Authenticator { let _res = pinocchio::syscalls::sol_sha256( [ discriminator, - auth_payload, + &auth_payload[..14], signed_payload, - &slot.to_le_bytes(), payer.key().as_ref(), &counter_bytes, program_id.as_ref(), ] .as_ptr() as *const u8, - 7, + 6, hasher.as_mut_ptr(), ); } #[cfg(not(target_os = "solana"))] { - let _ = signed_payload; - let _ = discriminator; - let _ = counter_bytes; - let _ = program_id; + let _ = (signed_payload, discriminator, counter_bytes, program_id); hasher = [0u8; 32]; } - let client_data_json = reconstruct_client_data_json(&reconstruction_params, rp_id, &hasher); + // --- Parse Mode 1 payload: authenticatorData + clientDataJSON --- + let auth_data_len = + u16::from_le_bytes(auth_payload[14..16].try_into().unwrap()) as usize; + if auth_payload.len() < 16 + auth_data_len + 2 { + return Err(AuthError::InvalidAuthorityPayload.into()); + } + let authenticator_data_raw = &auth_payload[16..16 + auth_data_len]; + + let cdj_len_offset = 16 + auth_data_len; + let cdj_len = + u16::from_le_bytes(auth_payload[cdj_len_offset..cdj_len_offset + 2].try_into().unwrap()) + as usize; + let cdj_offset = cdj_len_offset + 2; + // L2: strict length — trailing bytes after cdj are not covered by + // challenge hash or precompile message, so they're rejected. + if cdj_len == 0 || auth_payload.len() != cdj_offset + cdj_len { + return Err(AuthError::InvalidAuthorityPayload.into()); + } + let raw_client_data_json = &auth_payload[cdj_offset..cdj_offset + cdj_len]; + + // L1: We intentionally do NOT validate the `origin` field inside the + // clientDataJSON. The binding that matters is the authenticator's + // `rpIdHash` (checked below against the on-chain stored rpIdHash), + // which the authenticator hardware/OS computes from the registered + // relying party and refuses to sign cross-origin. + + // Validate "type" field is "webauthn.get" + let type_value = extract_top_level_string_field(raw_client_data_json, b"type")?; + if type_value != b"webauthn.get" { + return Err(AuthError::InvalidAuthenticationKind.into()); + } + + // Validate "challenge" field matches expected base64url(challenge_hash). + // L3: constant-time byte comparison. + let challenge_value = + extract_top_level_string_field(raw_client_data_json, b"challenge")?; + let expected_challenge_b64 = base64url_encode_no_pad(&hasher); + if !ct_eq(challenge_value, expected_challenge_b64.as_slice()) { + return Err(AuthError::InvalidMessageHash.into()); + } + + // Hash the raw clientDataJSON #[allow(unused_assignments)] let mut client_data_hash = [0u8; 32]; #[cfg(target_os = "solana")] unsafe { let _res = pinocchio::syscalls::sol_sha256( - [client_data_json.as_slice()].as_ptr() as *const u8, + [raw_client_data_json].as_ptr() as *const u8, 1, client_data_hash.as_mut_ptr(), ); } #[cfg(not(target_os = "solana"))] { - let _ = client_data_json; + let _ = raw_client_data_json; client_data_hash = [0u8; 32]; } + // --- Shared validation (both modes) --- + let auth_data_parser = AuthDataParser::new(authenticator_data_raw)?; if !auth_data_parser.is_user_present() { return Err(AuthError::PermissionDenied.into()); } - // Note: We intentionally do NOT check auth_data_parser.counter() (the WebAuthn hardware - // counter). Synced passkeys (iCloud Keychain, Google Password Manager) may return 0 or - // non-incrementing values. The program-controlled odometer above provides replay protection. + // Note: We intentionally do NOT check the WebAuthn hardware counter. + // Synced passkeys (iCloud, Google) may return 0 or non-incrementing values. - // Security Validation: - // Ensure the domain (rp_id_hash) the user provided in the instruction payload actually matches - // the rpIdHash that the authenticator (Hardware/FaceID) signed over inside authenticatorData. - if auth_data_parser.rp_id_hash() != computed_rp_id_hash { + // Validate rpIdHash in authenticatorData matches the stored rpIdHash. + if auth_data_parser.rp_id_hash() != stored_rp_id_hash { return Err(AuthError::InvalidPubkey.into()); } @@ -199,10 +217,10 @@ impl Authenticator for Secp256r1Authenticator { let instruction_pubkey_bytes = &auth_data[pubkey_offset..pubkey_offset + 33]; let expected_pubkey: &[u8; 33] = instruction_pubkey_bytes.try_into().unwrap(); - let mut signed_message = Vec::with_capacity(authenticator_data_raw.len() + 32); - signed_message.extend_from_slice(authenticator_data_raw); - signed_message.extend_from_slice(&client_data_hash); + // The precompile's signed message is authenticator_data ∥ client_data_hash. + // Pass the two slices separately to avoid an intermediate Vec allocation. + // Introspect the secp256r1 precompile instruction (must be the previous instruction) let sysvar_instructions = accounts .get(sysvar_ix_index) .ok_or(AuthError::InvalidAuthorityPayload)?; @@ -225,10 +243,11 @@ impl Authenticator for Secp256r1Authenticator { verify_secp256r1_instruction_data( secp_ix.get_instruction_data(), expected_pubkey, - &signed_message, + authenticator_data_raw, + &client_data_hash, )?; - // Signature verified successfully — now commit the counter update + // Signature verified successfully — commit the counter update header.counter = expected_counter; unsafe { std::ptr::write_unaligned( @@ -240,3 +259,52 @@ impl Authenticator for Secp256r1Authenticator { Ok(()) } } + +/// Constant-time byte slice equality. Returns `false` for different lengths; +/// otherwise XORs every byte pair into an accumulator and compares to zero, +/// ensuring the comparison takes the same time regardless of where (or if) the +/// bytes differ. Used for the Mode 1 challenge check. +#[inline(always)] +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut acc: u8 = 0; + for i in 0..a.len() { + acc |= a[i] ^ b[i]; + } + acc == 0 +} + +#[cfg(test)] +mod tests { + use super::ct_eq; + + #[test] + fn ct_eq_equal() { + assert!(ct_eq(b"abc", b"abc")); + assert!(ct_eq(b"", b"")); + assert!(ct_eq(&[0xFF; 43], &[0xFF; 43])); + } + + #[test] + fn ct_eq_different_length() { + assert!(!ct_eq(b"abc", b"abcd")); + assert!(!ct_eq(b"", b"a")); + } + + #[test] + fn ct_eq_differs_at_start() { + assert!(!ct_eq(b"xbc", b"abc")); + } + + #[test] + fn ct_eq_differs_at_end() { + assert!(!ct_eq(b"abx", b"abc")); + } + + #[test] + fn ct_eq_differs_at_middle() { + assert!(!ct_eq(b"axc", b"abc")); + } +} diff --git a/program/src/auth/secp256r1/webauthn.rs b/program/src/auth/secp256r1/webauthn.rs index f8d0371..db1725c 100644 --- a/program/src/auth/secp256r1/webauthn.rs +++ b/program/src/auth/secp256r1/webauthn.rs @@ -1,51 +1,9 @@ -#[allow(unused_imports)] use crate::error::AuthError; -#[allow(unused_imports)] use pinocchio::program_error::ProgramError; -/// Packed flags for clientDataJson reconstruction -#[derive(Clone, Copy, Debug)] -#[repr(C)] -pub struct ClientDataJsonReconstructionParams { - pub type_and_flags: u8, -} - -impl ClientDataJsonReconstructionParams { - #[allow(dead_code)] - const TYPE_CREATE: u8 = 0x00; - const TYPE_GET: u8 = 0x10; - const FLAG_CROSS_ORIGIN: u8 = 0x01; - const FLAG_HTTP_ORIGIN: u8 = 0x02; - const FLAG_GOOGLE_EXTRA: u8 = 0x04; - - pub fn auth_type(&self) -> AuthType { - if (self.type_and_flags & 0xF0) == Self::TYPE_GET { - AuthType::Get - } else { - AuthType::Create - } - } - - pub fn is_cross_origin(&self) -> bool { - self.type_and_flags & Self::FLAG_CROSS_ORIGIN != 0 - } - - pub fn is_http(&self) -> bool { - self.type_and_flags & Self::FLAG_HTTP_ORIGIN != 0 - } - - pub fn has_google_extra(&self) -> bool { - self.type_and_flags & Self::FLAG_GOOGLE_EXTRA != 0 - } -} - -#[derive(Clone, Copy, Debug)] -pub enum AuthType { - Create, - Get, -} - -/// Simple Base64URL encoder without padding +/// Simple Base64URL encoder without padding. +/// Used to compare an on-chain-computed challenge against the base64url value +/// the browser authenticator placed inside clientDataJSON. pub fn base64url_encode_no_pad(data: &[u8]) -> Vec { const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; let mut result = Vec::with_capacity(data.len().div_ceil(3) * 4); @@ -70,48 +28,6 @@ pub fn base64url_encode_no_pad(data: &[u8]) -> Vec { result } -/// Reconstructs the clientDataJson -pub fn reconstruct_client_data_json( - params: &ClientDataJsonReconstructionParams, - rp_id: &[u8], - challenge: &[u8], -) -> Vec { - let challenge_b64url = base64url_encode_no_pad(challenge); - let type_str: &[u8] = match params.auth_type() { - AuthType::Create => b"webauthn.create", - AuthType::Get => b"webauthn.get", - }; - - let prefix: &[u8] = if params.is_http() { - b"http://" - } else { - b"https://" - }; - let cross_origin: &[u8] = if params.is_cross_origin() { - b"true" - } else { - b"false" - }; - - let mut json = Vec::with_capacity(256); - json.extend_from_slice(b"{\"type\":\""); - json.extend_from_slice(type_str); - json.extend_from_slice(b"\",\"challenge\":\""); - json.extend_from_slice(&challenge_b64url); - json.extend_from_slice(b"\",\"origin\":\""); - json.extend_from_slice(prefix); - json.extend_from_slice(rp_id); - json.extend_from_slice(b"\",\"crossOrigin\":"); - json.extend_from_slice(cross_origin); - - if params.has_google_extra() { - json.extend_from_slice(b",\"other_keys_can_be_added_here\":\"do not compare clientDataJSON against a template. See https://goo.gl/yabPex\""); - } - - json.extend_from_slice(b"}"); - json -} - /// Minimum authenticator data length: rpIdHash(32) + flags(1) + counter(4) = 37 pub const AUTH_DATA_MIN_LEN: usize = 37; @@ -144,3 +60,536 @@ impl<'a> AuthDataParser<'a> { u32::from_be_bytes(self.data[33..37].try_into().unwrap()) } } + +/// Extracts a top-level string value for a given key from a JSON object. +/// +/// Walks `{"key":"value", ...}` looking for the specified key at depth 1. +/// Returns the value bytes (without quotes). Rejects escaped strings +/// (backslash inside key or value) to prevent challenge injection. +pub fn extract_top_level_string_field<'a>( + json: &'a [u8], + field_name: &[u8], +) -> Result<&'a [u8], ProgramError> { + // Skip leading whitespace + let mut i = 0; + while i < json.len() && json[i].is_ascii_whitespace() { + i += 1; + } + if i >= json.len() || json[i] != b'{' { + return Err(AuthError::InvalidMessage.into()); + } + + let mut depth: usize = 0; + let mut cursor = i; + + while cursor < json.len() { + let byte = json[cursor]; + + if byte == b'{' { + depth += 1; + cursor += 1; + continue; + } + if byte == b'}' { + if depth == 0 { + return Err(AuthError::InvalidMessage.into()); + } + depth -= 1; + cursor += 1; + continue; + } + + // Only parse keys at the top level (depth == 1) + if depth == 1 && byte == b'"' { + // Parse key + let key_start = cursor + 1; + let mut key_end = key_start; + while key_end < json.len() { + let b = json[key_end]; + if b == b'"' { + break; + } + if b == b'\\' { + return Err(AuthError::InvalidMessage.into()); + } + key_end += 1; + } + if key_end >= json.len() { + return Err(AuthError::InvalidMessage.into()); + } + + // Skip past closing quote + cursor = key_end + 1; + + // Skip whitespace before colon + while cursor < json.len() && json[cursor].is_ascii_whitespace() { + cursor += 1; + } + if cursor >= json.len() || json[cursor] != b':' { + return Err(AuthError::InvalidMessage.into()); + } + cursor += 1; + + // Skip whitespace after colon + while cursor < json.len() && json[cursor].is_ascii_whitespace() { + cursor += 1; + } + if cursor >= json.len() { + return Err(AuthError::InvalidMessage.into()); + } + + // Check if this is the field we want + if &json[key_start..key_end] == field_name { + // Value must be a string + if json[cursor] != b'"' { + return Err(AuthError::InvalidMessage.into()); + } + let value_start = cursor + 1; + let mut value_end = value_start; + while value_end < json.len() { + let b = json[value_end]; + if b == b'"' { + return Ok(&json[value_start..value_end]); + } + if b == b'\\' { + return Err(AuthError::InvalidMessage.into()); + } + value_end += 1; + } + return Err(AuthError::InvalidMessage.into()); + } + + // Not our field — skip the value + if json[cursor] == b'"' { + // String value — skip to closing quote + cursor += 1; + while cursor < json.len() { + let b = json[cursor]; + if b == b'"' { + cursor += 1; + break; + } + if b == b'\\' { + return Err(AuthError::InvalidMessage.into()); + } + cursor += 1; + } + } else { + // Non-string value (number, bool, null, object, array) — skip + // until the next comma or closing brace at the same depth. + // Track nested braces and brackets, and when we encounter a + // string, consume it entirely so that `{`, `}`, `[`, `]`, or + // `,` inside the string body don't corrupt depth tracking. + // + // Without the inner string-skip, a payload like + // {"tokenBinding":{"id":"x}y"},"challenge":"real"} + // would have the `}` inside "x}y" mistakenly close the nested + // object and the parser would then mis-locate the top-level + // "challenge" entry. + let mut nest: usize = 0; + while cursor < json.len() { + match json[cursor] { + b'"' => { + // Consume the string; inner quotes/braces/commas + // must not affect outer nesting state. + cursor += 1; + while cursor < json.len() { + let b = json[cursor]; + if b == b'"' { + cursor += 1; + break; + } + if b == b'\\' { + return Err(AuthError::InvalidMessage.into()); + } + cursor += 1; + } + continue; + } + b'{' | b'[' => nest += 1, + b'}' | b']' => { + if nest == 0 { + break; + } + nest -= 1; + } + b',' if nest == 0 => break, + _ => {} + } + cursor += 1; + } + } + continue; + } + + cursor += 1; + } + + Err(AuthError::InvalidMessage.into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ─── extract_top_level_string_field tests ─────────────────────────── + + #[test] + fn test_extract_field_basic() { + let json = br#"{"type":"webauthn.get","challenge":"abc123","origin":"https://example.com"}"#; + assert_eq!( + extract_top_level_string_field(json, b"type").unwrap(), + b"webauthn.get" + ); + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"abc123" + ); + assert_eq!( + extract_top_level_string_field(json, b"origin").unwrap(), + b"https://example.com" + ); + } + + #[test] + fn test_extract_field_with_bool_value() { + let json = + br#"{"type":"webauthn.get","challenge":"abc","crossOrigin":false,"origin":"https://x.com"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"abc" + ); + assert_eq!( + extract_top_level_string_field(json, b"origin").unwrap(), + b"https://x.com" + ); + } + + #[test] + fn test_extract_field_with_nested_object() { + // Real Android clientDataJSON has extra fields like androidPackageName + let json = + br#"{"type":"webauthn.get","challenge":"xyz","origin":"https://a.com","androidPackageName":"com.example.app"}"#; + assert_eq!( + extract_top_level_string_field(json, b"type").unwrap(), + b"webauthn.get" + ); + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"xyz" + ); + } + + #[test] + fn test_extract_field_with_nested_json_object() { + // Nested object should be skipped when looking for top-level keys + let json = br#"{"nested":{"challenge":"fake"},"type":"webauthn.get","challenge":"real"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"real" + ); + assert_eq!( + extract_top_level_string_field(json, b"type").unwrap(), + b"webauthn.get" + ); + } + + #[test] + fn test_extract_field_missing_key() { + let json = br#"{"type":"webauthn.get"}"#; + assert!(extract_top_level_string_field(json, b"challenge").is_err()); + } + + #[test] + fn test_extract_field_rejects_escaped_key() { + // Backslash in key → reject (prevents injection) + let json = br#"{"ty\"pe":"webauthn.get"}"#; + assert!(extract_top_level_string_field(json, b"type").is_err()); + } + + #[test] + fn test_extract_field_rejects_escaped_value() { + // Backslash in value → reject + let json = br#"{"challenge":"abc\"def"}"#; + assert!(extract_top_level_string_field(json, b"challenge").is_err()); + } + + #[test] + fn test_extract_field_rejects_non_string_value() { + // challenge is a number, not a string → reject + let json = br#"{"challenge":12345}"#; + assert!(extract_top_level_string_field(json, b"challenge").is_err()); + } + + #[test] + fn test_extract_field_rejects_empty_input() { + assert!(extract_top_level_string_field(b"", b"type").is_err()); + } + + #[test] + fn test_extract_field_rejects_not_object() { + assert!(extract_top_level_string_field(b"[1,2,3]", b"type").is_err()); + } + + #[test] + fn test_extract_field_nested_challenge_not_found_at_top() { + // challenge only exists inside a nested object — should not be found + let json = br#"{"type":"webauthn.get","nested":{"challenge":"sneaky"}}"#; + assert!(extract_top_level_string_field(json, b"challenge").is_err()); + } + + #[test] + fn test_extract_field_with_whitespace() { + let json = br#"{ "type" : "webauthn.get" , "challenge" : "abc" }"#; + assert_eq!( + extract_top_level_string_field(json, b"type").unwrap(), + b"webauthn.get" + ); + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"abc" + ); + } + + #[test] + fn test_extract_field_google_extra() { + // Google Chrome adds this extra field + let json = br#"{"type":"webauthn.get","challenge":"abc","origin":"https://x.com","crossOrigin":false,"other_keys_can_be_added_here":"do not compare clientDataJSON against a template. See https://goo.gl/yabPex"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"abc" + ); + assert_eq!( + extract_top_level_string_field(json, b"type").unwrap(), + b"webauthn.get" + ); + } + + #[test] + fn test_extract_field_with_array_value() { + // Array value should be skipped properly + let json = br#"{"arr":[1,2,3],"challenge":"abc"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"abc" + ); + } + + #[test] + fn test_extract_field_with_nested_array_of_objects() { + let json = br#"{"arr":[{"challenge":"fake"},{"x":1}],"challenge":"real"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"real" + ); + } + + // ─── M1 regression: string content inside nested value must not + // corrupt depth tracking ────────────────────────────────────── + + #[test] + fn test_extract_skips_string_containing_close_brace_in_nested_object() { + // Pre-fix, the `}` inside "x}y" would mistakenly close the nested + // object, and the parser would then mis-locate the top-level + // "challenge" entry. Post-fix, the inner string is consumed as a + // whole so nested depth stays at 1 until the real `}` at end of + // the tokenBinding value. + let json = br#"{"tokenBinding":{"id":"x}y"},"challenge":"real"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"real" + ); + } + + #[test] + fn test_extract_skips_string_containing_close_bracket() { + let json = br#"{"arr":[{"id":"x]y"}],"challenge":"real"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"real" + ); + } + + #[test] + fn test_extract_skips_string_containing_comma_in_nested_object() { + // Comma inside a string at non-zero depth — should not affect anything, + // but once we `continue` to the top of the skip loop we could be fooled + // into thinking the comma terminates the value. + let json = br#"{"obj":{"k":"a,b,c"},"challenge":"real"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"real" + ); + } + + #[test] + fn test_extract_skips_string_containing_open_brace_in_array() { + let json = br#"{"arr":[{"id":"x{y"}],"challenge":"real"}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"real" + ); + } + + #[test] + fn test_extract_challenge_before_tokenbinding_still_works() { + // Safety: ensure the fix doesn't break the common happy path. + let json = br#"{"type":"webauthn.get","challenge":"real","tokenBinding":{"id":"x}y"}}"#; + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"real" + ); + } + + #[test] + fn test_extract_rejects_backslash_in_skipped_string_inside_nested() { + // Backslashes are rejected in string values everywhere, including + // strings inside a skipped nested object. This prevents escape + // injection reaching the parser via nested fields. + let json = br#"{"obj":{"id":"a\"b"},"challenge":"real"}"#; + assert!(extract_top_level_string_field(json, b"challenge").is_err()); + } + + // ─── base64url_encode_no_pad tests ────────────────────────────────── + + #[test] + fn test_base64url_encode_empty() { + assert_eq!(base64url_encode_no_pad(&[]), b""); + } + + #[test] + fn test_base64url_encode_known_vectors() { + // "f" → "Zg" + assert_eq!(base64url_encode_no_pad(b"f"), b"Zg"); + // "fo" → "Zm8" + assert_eq!(base64url_encode_no_pad(b"fo"), b"Zm8"); + // "foo" → "Zm9v" + assert_eq!(base64url_encode_no_pad(b"foo"), b"Zm9v"); + } + + #[test] + fn test_base64url_encode_32_bytes() { + // SHA256 output (32 bytes) → 43 base64url chars (no padding) + let data = [0x11u8; 32]; + let encoded = base64url_encode_no_pad(&data); + assert_eq!(encoded.len(), 43); + // Verify no padding characters + assert!(!encoded.contains(&b'=')); + // Verify URL-safe: no + or / + assert!(!encoded.contains(&b'+')); + assert!(!encoded.contains(&b'/')); + } + + #[test] + fn test_base64url_uses_url_safe_chars() { + // 0xFB, 0xFF → should produce '-' and '_' instead of '+' and '/' + let data = [0xFB, 0xFF, 0xFE]; + let encoded = base64url_encode_no_pad(&data); + let encoded_str = std::str::from_utf8(&encoded).unwrap(); + assert!( + !encoded_str.contains('+') && !encoded_str.contains('/'), + "Must use URL-safe alphabet" + ); + } + + // ─── AuthDataParser tests ─────────────────────────────────────────── + + #[test] + fn test_auth_data_parser_basic() { + let mut data = [0u8; 37]; + // rpIdHash = first 32 bytes (zeros) + data[32] = 0x05; // flags: user present (0x01) + user verified (0x04) + data[33..37].copy_from_slice(&[0, 0, 0, 42]); // counter = 42 (big-endian) + + let parser = AuthDataParser::new(&data).unwrap(); + assert!(parser.is_user_present()); + assert!(parser.is_user_verified()); + assert_eq!(parser.counter(), 42); + assert_eq!(parser.rp_id_hash(), &[0u8; 32]); + } + + #[test] + fn test_auth_data_parser_no_flags() { + let data = [0u8; 37]; + let parser = AuthDataParser::new(&data).unwrap(); + assert!(!parser.is_user_present()); + assert!(!parser.is_user_verified()); + } + + #[test] + fn test_auth_data_parser_too_short() { + let data = [0u8; 36]; // Less than 37 + assert!(AuthDataParser::new(&data).is_err()); + } + + #[test] + fn test_auth_data_parser_with_extensions() { + // Real authenticators may return > 37 bytes (with extensions) + let mut data = [0u8; 100]; + data[32] = 0x41; // user present + attested credential data + let parser = AuthDataParser::new(&data).unwrap(); + assert!(parser.is_user_present()); + } + + // ─── Real-world clientDataJSON samples ────────────────────────────── + + #[test] + fn test_extract_from_chrome_sample() { + let json = br#"{"type":"webauthn.get","challenge":"dGVzdC1jaGFsbGVuZ2U","origin":"https://lazorkit.app","crossOrigin":false}"#; + assert_eq!( + extract_top_level_string_field(json, b"type").unwrap(), + b"webauthn.get" + ); + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"dGVzdC1jaGFsbGVuZ2U" + ); + assert_eq!( + extract_top_level_string_field(json, b"origin").unwrap(), + b"https://lazorkit.app" + ); + } + + #[test] + fn test_extract_from_android_sample() { + // Android may include androidPackageName and topOrigin + let json = br#"{"type":"webauthn.get","challenge":"abc123","origin":"https://example.com","androidPackageName":"com.example.app","topOrigin":"https://example.com"}"#; + assert_eq!( + extract_top_level_string_field(json, b"type").unwrap(), + b"webauthn.get" + ); + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"abc123" + ); + assert_eq!( + extract_top_level_string_field(json, b"androidPackageName").unwrap(), + b"com.example.app" + ); + } + + #[test] + fn test_extract_from_safari_no_crossorigin() { + // Safari may omit crossOrigin entirely + let json = + br#"{"type":"webauthn.get","challenge":"xyz","origin":"https://lazorkit.app"}"#; + assert_eq!( + extract_top_level_string_field(json, b"type").unwrap(), + b"webauthn.get" + ); + assert_eq!( + extract_top_level_string_field(json, b"challenge").unwrap(), + b"xyz" + ); + // crossOrigin field doesn't exist → error + assert!(extract_top_level_string_field(json, b"crossOrigin").is_err()); + } + + #[test] + fn test_extract_rejects_webauthn_create_type() { + let json = br#"{"type":"webauthn.create","challenge":"abc"}"#; + let type_val = extract_top_level_string_field(json, b"type").unwrap(); + assert_ne!(type_val, b"webauthn.get"); + assert_eq!(type_val, b"webauthn.create"); + } +} From 19b52f9d123dda12e8fa873302ada5b100fc1f2e Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Wed, 6 May 2026 17:31:47 +0700 Subject: [PATCH 19/25] feat(processor): port wallet/authority/execute/session processors from upstream Byte-identical with lazorkit-protocol/program/src/processor/{wallet/create, authority/manage, authority/transfer_ownership, execute/deferred, session/revoke}.rs. Brings the on-chain authority data layout into alignment with upstream: Secp256r1 authority = header(48) + cred_hash(32) + pubkey(33) + rpIdHash(32) = 145B Previously program-v2 stored variable-length raw rpId; the new layout stores a precomputed SHA256 digest at offset 113. Saves one sol_sha256 syscall per Execute. Critical for slot-share: existing wallets created on lazorkit-protocol must remain readable after binary swap. File names stay flat (program-v2 keeps `processor/create_wallet.rs` rather than upstream's `processor/wallet/create.rs`); content identical. --- program/src/processor/create_wallet.rs | 72 +++++++++++++++--- program/src/processor/execute_deferred.rs | 83 +++++++++++---------- program/src/processor/manage_authority.rs | 54 +++++++++++++- program/src/processor/revoke_session.rs | 5 ++ program/src/processor/transfer_ownership.rs | 74 ++++++++++++++---- 5 files changed, 217 insertions(+), 71 deletions(-) diff --git a/program/src/processor/create_wallet.rs b/program/src/processor/create_wallet.rs index e16b42e..1d70f5d 100644 --- a/program/src/processor/create_wallet.rs +++ b/program/src/processor/create_wallet.rs @@ -79,10 +79,13 @@ pub fn process( let (id_seed, full_auth_data) = match args.authority_type { 0 => { - if rest.len() != 32 { + // Use minimum-length check (consistent with AddAuthority / TransferOwnership). + // Exact-length check would reject clients that append trailing context bytes. + if rest.len() < 32 { return Err(ProgramError::InvalidInstructionData); } - (rest, rest) + let (pubkey, _) = rest.split_at(32); + (pubkey, pubkey) }, 1 => { // [credential_id_hash(32)] [pubkey(33)] [rpIdLen(1)] [rpId(N)] @@ -91,6 +94,12 @@ pub fn process( } let (credential_id_hash, rest_after_cred) = rest.split_at(32); let rp_id_len = rest_after_cred[33] as usize; + // Enforce a sane upper bound: max valid domain name is 253 chars. + // Without this an attacker-controlled payer could create a 369-byte + // authority account with 255 bytes of arbitrary rpId data. + if rp_id_len == 0 || rp_id_len > 253 { + return Err(ProgramError::InvalidInstructionData); + } let total_auth_data = 32 + 33 + 1 + rp_id_len; if rest.len() < total_auth_data { return Err(ProgramError::InvalidInstructionData); @@ -188,11 +197,18 @@ pub fn process( } // --- 2. Initialize Authority Account --- - // Authority accounts have a variable size depending on the authority type (e.g., Secp256r1 keys are larger). + // Fixed sizes per auth type: + // Ed25519 = header(48) + pubkey(32) = 80 bytes + // Secp256r1 = header(48) + cred_hash(32) + pubkey(33) + rpIdHash(32) = 145 bytes + // + // For Secp256r1 we hash rpId once at creation and store the digest, so + // every subsequent Execute saves one sol_sha256 syscall. let header_size = std::mem::size_of::(); - let variable_size = full_auth_data.len(); - - let auth_space = header_size + variable_size; + let auth_space = match args.authority_type { + 0 => header_size + 32, // Ed25519 + 1 => header_size + 32 + 33 + 32, // Secp256r1 fixed + _ => return Err(AuthError::InvalidAuthenticationKind.into()), + }; let auth_rent = rent.minimum_balance(auth_space); // Use secure transfer-allocate-assign pattern to prevent DoS (Issue #4) @@ -228,18 +244,50 @@ pub fn process( wallet: *wallet_pda.key(), }; - // safe write + // safe write of header let header_bytes = unsafe { std::slice::from_raw_parts( &header as *const AuthorityAccountHeader as *const u8, - std::mem::size_of::(), + header_size, ) }; - auth_account_data[0..std::mem::size_of::()] - .copy_from_slice(header_bytes); + auth_account_data[0..header_size].copy_from_slice(header_bytes); - let variable_target = &mut auth_account_data[header_size..]; - variable_target.copy_from_slice(full_auth_data); + // Write variable data + match args.authority_type { + 0 => { + // Ed25519: pubkey(32) — full_auth_data is exactly 32 bytes + auth_account_data[header_size..header_size + 32] + .copy_from_slice(&full_auth_data[..32]); + } + 1 => { + // Secp256r1: cred_hash(32) ∥ pubkey(33) ∥ rpIdHash(32). + // full_auth_data layout as parsed above: + // [cred_hash(32)] [pubkey(33)] [rpIdLen(1)] [rpId(N)] + auth_account_data[header_size..header_size + 32] + .copy_from_slice(&full_auth_data[..32]); + auth_account_data[header_size + 32..header_size + 32 + 33] + .copy_from_slice(&full_auth_data[32..32 + 33]); + // Compute rpIdHash from rpId + let rp_id_len = full_auth_data[32 + 33] as usize; + let rp_id = &full_auth_data[32 + 33 + 1..32 + 33 + 1 + rp_id_len]; + let rp_id_hash_offset = header_size + 32 + 33; + #[cfg(target_os = "solana")] + unsafe { + let _ = pinocchio::syscalls::sol_sha256( + [rp_id].as_ptr() as *const u8, + 1, + auth_account_data[rp_id_hash_offset..rp_id_hash_offset + 32].as_mut_ptr(), + ); + } + #[cfg(not(target_os = "solana"))] + { + let _ = rp_id; + auth_account_data[rp_id_hash_offset..rp_id_hash_offset + 32].fill(0); + } + } + _ => unreachable!(), + } Ok(()) } diff --git a/program/src/processor/execute_deferred.rs b/program/src/processor/execute_deferred.rs index cc24c27..fafe0b4 100644 --- a/program/src/processor/execute_deferred.rs +++ b/program/src/processor/execute_deferred.rs @@ -1,5 +1,5 @@ use crate::{ - compact::parse_compact_instructions, + compact::{parse_compact_instructions_ref_with_len, CompactInstructionRef}, error::AuthError, state::{deferred::DeferredExecAccount, AccountDiscriminator}, }; @@ -99,14 +99,14 @@ pub fn process( return Err(AuthError::DeferredAuthorizationExpired.into()); } - // Parse compact instructions - let compact_instructions = parse_compact_instructions(instruction_data)?; + // Parse compact instructions and track consumed length. We hash the + // raw instruction_data[..consumed] directly — the parse/encode format + // is byte-identical, so there's no need to re-serialize. + let (compact_instructions, compact_len) = + parse_compact_instructions_ref_with_len(instruction_data)?; - // Serialize compact instructions to compute hash - let compact_bytes = crate::compact::serialize_compact_instructions(&compact_instructions); - - // Verify instructions hash - let instructions_hash = compute_sha256(&compact_bytes); + // Verify instructions hash against the exact bytes we parsed from + let instructions_hash = compute_sha256(&instruction_data[..compact_len]); if instructions_hash != deferred.instructions_hash { return Err(AuthError::DeferredHashMismatch.into()); } @@ -140,46 +140,47 @@ pub fn process( let close_data = unsafe { deferred_pda.borrow_mut_data_unchecked() }; close_data.fill(0); + // Reuse Vecs across inner CPI iterations — allocated once, cleared + + // repushed each iteration. Same optimisation as execute::immediate. + 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); + + 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 via CPI with vault PDA signing for compact_ix in &compact_instructions { let decompressed = compact_ix.decompress(accounts)?; - // Build AccountMeta array - 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 if decompressed.program_id.as_ref() == program_id.as_ref() { return Err(AuthError::SelfReentrancyNotAllowed.into()); } + 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, }; - 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(); - let cpi_accounts: Vec = decompressed - .accounts - .iter() - .map(|acc| Account::from(*acc)) - .collect(); - unsafe { invoke_signed_unchecked(&ix, &cpi_accounts, &[signer]); } @@ -209,26 +210,26 @@ fn compute_sha256(data: &[u8]) -> [u8; 32] { } /// Compute SHA256 hash of all account pubkeys referenced by compact instructions. -/// Same logic as execute.rs::compute_accounts_hash. +/// Matches execute::immediate::compute_accounts_hash. fn compute_accounts_hash( accounts: &[AccountInfo], - compact_instructions: &[crate::compact::CompactInstruction], + compact_instructions: &[CompactInstructionRef<'_>], ) -> Result<[u8; 32], ProgramError> { - let mut pubkeys_data = Vec::new(); + let mut refs: Vec<&[u8]> = Vec::with_capacity(compact_instructions.len() * 4); for ix in compact_instructions { 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()); - 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()); } } @@ -237,15 +238,15 @@ fn compute_accounts_hash( #[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"))] { hash = [0xAA; 32]; - let _ = pubkeys_data; + let _ = refs; } Ok(hash) diff --git a/program/src/processor/manage_authority.rs b/program/src/processor/manage_authority.rs index 34d6073..1e165b7 100644 --- a/program/src/processor/manage_authority.rs +++ b/program/src/processor/manage_authority.rs @@ -91,6 +91,9 @@ pub fn process_add_authority( } let (credential_id_hash, rest_after_cred) = rest.split_at(32); let rp_id_len = rest_after_cred[33] as usize; + if rp_id_len == 0 || rp_id_len > 253 { + return Err(ProgramError::InvalidInstructionData); + } let total_auth_data = 32 + 33 + 1 + rp_id_len; if rest.len() < total_auth_data { return Err(ProgramError::InvalidInstructionData); @@ -198,6 +201,12 @@ pub fn process_add_authority( } // Authorization + // Validate new_role is a known value (0=Owner, 1=Admin, 2=Spender). + // Without this check an Owner could create a role-255 authority that can + // execute but cannot be revoked by any Admin. + if args.new_role > 2 { + return Err(AuthError::PermissionDenied.into()); + } if admin_header.role != 0 && (admin_header.role != 1 || args.new_role != 2) { return Err(AuthError::PermissionDenied.into()); } @@ -212,9 +221,13 @@ pub fn process_add_authority( } check_zero_data(new_auth_pda, ProgramError::AccountAlreadyInitialized)?; + // Fixed sizes per auth type (see wallet/create.rs for layout). let header_size = std::mem::size_of::(); - let variable_size = full_auth_data.len(); - let space = header_size + variable_size; + let space = match args.authority_type { + 0 => header_size + 32, // Ed25519: pubkey + 1 => header_size + 32 + 33 + 32, // Secp256r1: cred ∥ pubkey ∥ rpIdHash + _ => return Err(AuthError::InvalidAuthenticationKind.into()), + }; let rent_lamports = rent.minimum_balance(space); // Use secure transfer-allocate-assign pattern to prevent DoS (Issue #4) @@ -252,8 +265,35 @@ pub fn process_add_authority( *(data.as_mut_ptr() as *mut AuthorityAccountHeader) = header; } - let variable_target = &mut data[header_size..]; - variable_target.copy_from_slice(full_auth_data); + // Write variable data. For Secp256r1 hash rpId once here so every Execute + // saves a sol_sha256 syscall. + match args.authority_type { + 0 => { + data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]); + } + 1 => { + data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]); + data[header_size + 32..header_size + 32 + 33] + .copy_from_slice(&full_auth_data[32..32 + 33]); + let rp_id_len = full_auth_data[32 + 33] as usize; + let rp_id = &full_auth_data[32 + 33 + 1..32 + 33 + 1 + rp_id_len]; + let rp_id_hash_offset = header_size + 32 + 33; + #[cfg(target_os = "solana")] + unsafe { + let _ = pinocchio::syscalls::sol_sha256( + [rp_id].as_ptr() as *const u8, + 1, + data[rp_id_hash_offset..rp_id_hash_offset + 32].as_mut_ptr(), + ); + } + #[cfg(not(target_os = "solana"))] + { + let _ = rp_id; + data[rp_id_hash_offset..rp_id_hash_offset + 32].fill(0); + } + } + _ => unreachable!(), + } Ok(()) } @@ -398,6 +438,12 @@ pub fn process_remove_authority( } } + // Guard: if target == refund_dest the double-write would burn lamports and + // trigger a Solana lamport conservation error, aborting after doing work. + if target_auth_pda.key() == refund_dest.key() { + return Err(ProgramError::InvalidAccountData); + } + let target_lamports = unsafe { *target_auth_pda.borrow_mut_lamports_unchecked() }; let refund_lamports = unsafe { *refund_dest.borrow_mut_lamports_unchecked() }; unsafe { diff --git a/program/src/processor/revoke_session.rs b/program/src/processor/revoke_session.rs index dae1b39..aeada67 100644 --- a/program/src/processor/revoke_session.rs +++ b/program/src/processor/revoke_session.rs @@ -136,6 +136,11 @@ pub fn process( return Err(ProgramError::InvalidAccountData); } + // Guard: session_pda == refund_dest would burn lamports. + if session_pda.key() == refund_dest.key() { + return Err(ProgramError::InvalidAccountData); + } + // Close the session account — zero data and drain lamports session_data.fill(0); diff --git a/program/src/processor/transfer_ownership.rs b/program/src/processor/transfer_ownership.rs index 51dc8ad..0800f84 100644 --- a/program/src/processor/transfer_ownership.rs +++ b/program/src/processor/transfer_ownership.rs @@ -27,14 +27,16 @@ use crate::{ /// 2. **Authorization**: strictly enforced to only work if `current_owner` has `Role::Owner` (0). /// 3. **Atomic Swap**: /// - Creates the `new_owner` account. -/// - Closes the `current_owner` account and refunds rent to payer. +/// - Closes the `current_owner` account and refunds rent to `refund_dest`. /// /// # Accounts: /// 1. `[signer, writable]` Payer. /// 2. `[]` Wallet PDA. /// 3. `[signer, writable]` Current Owner Authority. /// 4. `[writable]` New Owner Authority. -/// 5. `[]` System Program. +/// 5. `[writable]` Refund Destination (receives closed current_owner rent). +/// 6. `[]` System Program. +/// 7. `[]` Rent Sysvar. /// /// Arguments for the `TransferOwnership` instruction. /// @@ -80,6 +82,9 @@ pub fn process( } let (hash, rest_after_hash) = rest.split_at(32); let rp_id_len = rest_after_hash[33] as usize; + if rp_id_len == 0 || rp_id_len > 253 { + return Err(ProgramError::InvalidInstructionData); + } let total_auth_data = 32 + 33 + 1 + rp_id_len; if rest.len() < total_auth_data { return Err(ProgramError::InvalidInstructionData); @@ -115,6 +120,9 @@ pub fn process( let new_owner = account_info_iter .next() .ok_or(ProgramError::NotEnoughAccountKeys)?; + let refund_dest = account_info_iter + .next() + .ok_or(ProgramError::NotEnoughAccountKeys)?; let system_program = account_info_iter .next() .ok_or(ProgramError::NotEnoughAccountKeys)?; @@ -126,6 +134,11 @@ pub fn process( if wallet_pda.owner() != program_id || current_owner.owner() != program_id { return Err(ProgramError::IllegalOwner); } + + // Guard: closing current_owner to itself would burn lamports. + if current_owner.key() == refund_dest.key() { + return Err(ProgramError::InvalidAccountData); + } // Validate Wallet Discriminator (Issue #7) let wallet_data = unsafe { wallet_pda.borrow_data_unchecked() }; if wallet_data.is_empty() || wallet_data[0] != AccountDiscriminator::Wallet as u8 { @@ -155,15 +168,16 @@ pub fn process( return Err(AuthError::PermissionDenied.into()); } - // Authenticate Current Owner - // Issue: Include payer + new_owner to prevent rent theft via payer swap - let mut ed25519_payload = Vec::with_capacity(64); + // Authenticate Current Owner. + // Sign over payer + new_owner + refund_dest to prevent substitution attacks. + let mut ed25519_payload = Vec::with_capacity(96); ed25519_payload.extend_from_slice(payer.key().as_ref()); ed25519_payload.extend_from_slice(new_owner.key().as_ref()); + ed25519_payload.extend_from_slice(refund_dest.key().as_ref()); match auth.authority_type { 0 => { - // Ed25519: Include payer + new_owner in signed payload + // Ed25519: sign over payer + new_owner + refund_dest Ed25519Authenticator.authenticate(accounts, data, &[], &ed25519_payload, &[3], program_id)?; }, 1 => { @@ -171,10 +185,11 @@ pub fn process( if !current_owner.is_writable() { return Err(ProgramError::InvalidAccountData); } - // Secp256r1: Include payer in signed payload to prevent rent theft - let mut extended_data_payload = Vec::with_capacity(data_payload.len() + 32); + // Sign over data_payload + payer + refund_dest + let mut extended_data_payload = Vec::with_capacity(data_payload.len() + 64); extended_data_payload.extend_from_slice(data_payload); extended_data_payload.extend_from_slice(payer.key().as_ref()); + extended_data_payload.extend_from_slice(refund_dest.key().as_ref()); Secp256r1Authenticator.authenticate( accounts, @@ -198,9 +213,13 @@ pub fn process( } check_zero_data(new_owner, ProgramError::AccountAlreadyInitialized)?; + // Fixed sizes per auth type (see wallet/create.rs for layout). let header_size = std::mem::size_of::(); - let variable_size = full_auth_data.len(); - let space = header_size + variable_size; + let space = match args.auth_type { + 0 => header_size + 32, // Ed25519: pubkey + 1 => header_size + 32 + 33 + 32, // Secp256r1: cred ∥ pubkey ∥ rpIdHash + _ => return Err(AuthError::InvalidAuthenticationKind.into()), + }; let rent = rent_obj.minimum_balance(space); // Use secure transfer-allocate-assign pattern to prevent DoS (Issue #4) @@ -241,13 +260,40 @@ pub fn process( std::ptr::write_unaligned(data.as_mut_ptr() as *mut AuthorityAccountHeader, header); } - let variable_target = &mut data[header_size..]; - variable_target.copy_from_slice(full_auth_data); + // Write variable data. For Secp256r1 hash rpId once here so every Execute + // saves a sol_sha256 syscall. + match args.auth_type { + 0 => { + data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]); + } + 1 => { + data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]); + data[header_size + 32..header_size + 32 + 33] + .copy_from_slice(&full_auth_data[32..32 + 33]); + let rp_id_len = full_auth_data[32 + 33] as usize; + let rp_id = &full_auth_data[32 + 33 + 1..32 + 33 + 1 + rp_id_len]; + let rp_id_hash_offset = header_size + 32 + 33; + #[cfg(target_os = "solana")] + unsafe { + let _ = pinocchio::syscalls::sol_sha256( + [rp_id].as_ptr() as *const u8, + 1, + data[rp_id_hash_offset..rp_id_hash_offset + 32].as_mut_ptr(), + ); + } + #[cfg(not(target_os = "solana"))] + { + let _ = rp_id; + data[rp_id_hash_offset..rp_id_hash_offset + 32].fill(0); + } + } + _ => unreachable!(), + } let current_lamports = unsafe { *current_owner.borrow_mut_lamports_unchecked() }; - let payer_lamports = unsafe { *payer.borrow_mut_lamports_unchecked() }; + let refund_lamports = unsafe { *refund_dest.borrow_mut_lamports_unchecked() }; unsafe { - *payer.borrow_mut_lamports_unchecked() = payer_lamports + *refund_dest.borrow_mut_lamports_unchecked() = refund_lamports .checked_add(current_lamports) .ok_or(ProgramError::ArithmeticOverflow)?; *current_owner.borrow_mut_lamports_unchecked() = 0; From 62ad92768a219d25c916bd919010f937ce17ebb6 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Wed, 6 May 2026 17:35:04 +0700 Subject: [PATCH 20/25] fix(tests-sdk): include expiry_offset in deferred-execution signed payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Authorize instruction binds expiry_offset to the Secp256r1 signature hash via signed_payload = instructions_hash || accounts_hash || expiry_offset (u16 LE). Test code was building signed_payload without the expiryBuf, causing all 7 deferred tests to fail with InvalidMessageHash (3005). Add expiryBuf at all 6 sign sites; values match the corresponding createAuthorizeIx expiryOffset arg (4 × 300, 1 × 9000, 1 × 10). Verification: 65/65 vitest pass against live validator (was 58/65 before). --- tests-sdk/tests/08-deferred.test.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/tests-sdk/tests/08-deferred.test.ts b/tests-sdk/tests/08-deferred.test.ts index 777045c..f316190 100644 --- a/tests-sdk/tests/08-deferred.test.ts +++ b/tests-sdk/tests/08-deferred.test.ts @@ -116,8 +116,10 @@ describe('Deferred Execution', () => { ]; const accountsHash = computeAccountsHash(tx2AccountMetas, compactIxs); - // Build signed_payload = instructions_hash || accounts_hash - const signedPayload = Buffer.concat([instructionsHash, accountsHash]); + // Build signed_payload = instructions_hash || accounts_hash || expiry_offset (u16 LE) + const expiryBuf = Buffer.alloc(2); + expiryBuf.writeUInt16LE(300); + const signedPayload = Buffer.concat([instructionsHash, accountsHash, expiryBuf]); // Sign with Secp256r1 const { authPayload, precompileIx } = await signSecp256r1({ @@ -240,7 +242,9 @@ describe('Deferred Execution', () => { { pubkey: recipient3, isSigner: false, isWritable: true }, ]; const accountsHash = computeAccountsHash(tx2AccountMetas, compactIxs); - const signedPayload = Buffer.concat([instructionsHash, accountsHash]); + const expiryBuf = Buffer.alloc(2); + expiryBuf.writeUInt16LE(300); + const signedPayload = Buffer.concat([instructionsHash, accountsHash, expiryBuf]); // Counter is now 2 (after first test incremented to 1) const { authPayload, precompileIx } = await signSecp256r1({ @@ -380,7 +384,9 @@ describe('Deferred Execution', () => { { pubkey: recipient, isSigner: false, isWritable: true }, ]; const accountsHash = computeAccountsHash(tx2AccountMetas, compactIxs); - const signedPayload = Buffer.concat([instructionsHash, accountsHash]); + const expiryBuf = Buffer.alloc(2); + expiryBuf.writeUInt16LE(300); + const signedPayload = Buffer.concat([instructionsHash, accountsHash, expiryBuf]); const { authPayload, precompileIx } = await signSecp256r1({ key: ownerKey, @@ -504,7 +510,9 @@ describe('Deferred Execution', () => { { pubkey: recipient, isSigner: false, isWritable: true }, ]; const accountsHash = computeAccountsHash(tx2AccountMetas, compactIxs); - const signedPayload = Buffer.concat([instructionsHash, accountsHash]); + const expiryBuf = Buffer.alloc(2); + expiryBuf.writeUInt16LE(300); + const signedPayload = Buffer.concat([instructionsHash, accountsHash, expiryBuf]); const { authPayload, precompileIx } = await signSecp256r1({ key: ownerKey, @@ -610,7 +618,9 @@ describe('Deferred Execution', () => { { pubkey: recipient, isSigner: false, isWritable: true }, ]; const accountsHash = computeAccountsHash(tx2AccountMetas, compactIxs); - const signedPayload = Buffer.concat([instructionsHash, accountsHash]); + const expiryBuf = Buffer.alloc(2); + expiryBuf.writeUInt16LE(9000); + const signedPayload = Buffer.concat([instructionsHash, accountsHash, expiryBuf]); const { authPayload, precompileIx } = await signSecp256r1({ key: ownerKey, @@ -710,7 +720,9 @@ describe('Deferred Execution', () => { { pubkey: recipient, isSigner: false, isWritable: true }, ]; const accountsHash = computeAccountsHash(tx2AccountMetas, compactIxs); - const signedPayload = Buffer.concat([instructionsHash, accountsHash]); + const expiryBuf = Buffer.alloc(2); + expiryBuf.writeUInt16LE(10); + const signedPayload = Buffer.concat([instructionsHash, accountsHash, expiryBuf]); const { authPayload, precompileIx } = await signSecp256r1({ key: ownerKey, From cbf80cff736ee4a59de214362c7f915e5fff772c Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Wed, 6 May 2026 17:36:35 +0700 Subject: [PATCH 21/25] chore(instruction): sync Shank IDL declarations from upstream (strip fee ix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit instruction.rs's ProgramIx enum declarations (account metadata: writable modifiers, positions, descriptions) had drifted from lazorkit-protocol. Runtime not affected — sdk-legacy uses hand-written instruction builders, not the generated IDL. Resync now to keep IDL output (program/idl.json) faithful to actual on-chain account expectations. Strip 5 protocol-mgmt instruction variants (disc 10-14): InitializeProtocol, UpdateProtocol, RegisterPayer, WithdrawTreasury, InitializeTreasuryShard. program-v2 keeps disc 0-9 only (matches entrypoint dispatch). Verification: - cargo build --features devnet → clean - bash scripts/check-no-fee.sh → clean --- program/idl.json | 48 ++++++++++++++----------- program/src/instruction.rs | 73 ++++++++++++++++---------------------- 2 files changed, 58 insertions(+), 63 deletions(-) diff --git a/program/idl.json b/program/idl.json index 5912e6d..73e28e8 100644 --- a/program/idl.json +++ b/program/idl.json @@ -92,10 +92,10 @@ "accounts": [ { "name": "payer", - "isMut": false, + "isMut": true, "isSigner": true, "docs": [ - "Transaction payer" + "Payer and rent contributor" ] }, { @@ -108,10 +108,10 @@ }, { "name": "adminAuthority", - "isMut": false, - "isSigner": true, + "isMut": true, + "isSigner": false, "docs": [ - "Admin authority PDA authorizing this action" + "Admin authority PDA authorizing this action (counter incremented)" ] }, { @@ -186,7 +186,7 @@ "accounts": [ { "name": "payer", - "isMut": false, + "isMut": true, "isSigner": true, "docs": [ "Transaction payer" @@ -202,10 +202,10 @@ }, { "name": "adminAuthority", - "isMut": false, - "isSigner": true, + "isMut": true, + "isSigner": false, "docs": [ - "Admin authority PDA authorizing this action" + "Admin authority PDA authorizing this action (counter incremented)" ] }, { @@ -245,10 +245,10 @@ "accounts": [ { "name": "payer", - "isMut": false, + "isMut": true, "isSigner": true, "docs": [ - "Transaction payer" + "Payer and rent contributor" ] }, { @@ -275,6 +275,14 @@ "New owner authority PDA to be created" ] }, + { + "name": "refundDestination", + "isMut": true, + "isSigner": false, + "docs": [ + "Account to receive rent refund from closed current owner" + ] + }, { "name": "systemProgram", "isMut": false, @@ -335,7 +343,7 @@ "accounts": [ { "name": "payer", - "isMut": false, + "isMut": true, "isSigner": true, "docs": [ "Transaction payer" @@ -351,18 +359,18 @@ }, { "name": "authority", - "isMut": false, + "isMut": true, "isSigner": false, "docs": [ - "Authority or Session PDA authorizing execution" + "Authority or Session PDA authorizing execution (counter incremented)" ] }, { "name": "vault", - "isMut": false, + "isMut": true, "isSigner": false, "docs": [ - "Vault PDA" + "Vault PDA (signer for CPI, lamports debited)" ] }, { @@ -407,10 +415,10 @@ }, { "name": "adminAuthority", - "isMut": false, - "isSigner": true, + "isMut": true, + "isSigner": false, "docs": [ - "Admin/Owner authority PDA authorizing logic" + "Admin/Owner authority PDA authorizing logic (counter incremented)" ] }, { @@ -650,7 +658,7 @@ "accounts": [ { "name": "payer", - "isMut": false, + "isMut": true, "isSigner": true, "docs": [ "Transaction payer" diff --git a/program/src/instruction.rs b/program/src/instruction.rs index 21742cc..0c970f0 100644 --- a/program/src/instruction.rs +++ b/program/src/instruction.rs @@ -26,13 +26,13 @@ pub enum ProgramIx { }, /// Add a new authority to the wallet - #[account(0, signer, name = "payer", desc = "Transaction payer")] + #[account(0, signer, writable, name = "payer", desc = "Payer and rent contributor")] #[account(1, name = "wallet", desc = "Wallet PDA")] #[account( 2, - signer, + writable, name = "admin_authority", - desc = "Admin authority PDA authorizing this action" + desc = "Admin authority PDA authorizing this action (counter incremented)" )] #[account( 3, @@ -57,13 +57,13 @@ pub enum ProgramIx { }, /// Remove an authority from the wallet - #[account(0, signer, name = "payer", desc = "Transaction payer")] + #[account(0, signer, writable, name = "payer", desc = "Transaction payer")] #[account(1, name = "wallet", desc = "Wallet PDA")] #[account( 2, - signer, + writable, name = "admin_authority", - desc = "Admin authority PDA authorizing this action" + desc = "Admin authority PDA authorizing this action (counter incremented)" )] #[account( 3, @@ -86,7 +86,7 @@ pub enum ProgramIx { RemoveAuthority, /// Transfer ownership (atomic swap of Owner role) - #[account(0, signer, name = "payer", desc = "Transaction payer")] + #[account(0, signer, writable, name = "payer", desc = "Payer and rent contributor")] #[account(1, name = "wallet", desc = "Wallet PDA")] #[account( 2, @@ -100,10 +100,16 @@ pub enum ProgramIx { name = "new_owner_authority", desc = "New owner authority PDA to be created" )] - #[account(4, name = "system_program", desc = "System Program")] - #[account(5, name = "rent_sysvar", desc = "Rent Sysvar")] #[account( - 6, + 4, + writable, + name = "refund_destination", + desc = "Account to receive rent refund from closed current owner" + )] + #[account(5, name = "system_program", desc = "System Program")] + #[account(6, name = "rent_sysvar", desc = "Rent Sysvar")] + #[account( + 7, signer, optional, name = "authorizer_signer", @@ -116,14 +122,15 @@ pub enum ProgramIx { }, /// Execute transactions - #[account(0, signer, name = "payer", desc = "Transaction payer")] + #[account(0, signer, writable, name = "payer", desc = "Transaction payer")] #[account(1, name = "wallet", desc = "Wallet PDA")] #[account( 2, + writable, name = "authority", - desc = "Authority or Session PDA authorizing execution" + desc = "Authority or Session PDA authorizing execution (counter incremented)" )] - #[account(3, name = "vault", desc = "Vault PDA")] + #[account(3, writable, name = "vault", desc = "Vault PDA (signer for CPI, lamports debited)")] #[account( 4, optional, @@ -142,9 +149,9 @@ pub enum ProgramIx { #[account(1, name = "wallet", desc = "Wallet PDA")] #[account( 2, - signer, + writable, name = "admin_authority", - desc = "Admin/Owner authority PDA authorizing logic" + desc = "Admin/Owner authority PDA authorizing logic (counter incremented)" )] #[account(3, writable, name = "session", desc = "New session PDA to be created")] #[account(4, name = "system_program", desc = "System Program")] @@ -202,13 +209,7 @@ pub enum ProgramIx { /// /// Verifies compact instructions against stored hashes, executes via CPI /// with vault PDA signing, then closes the DeferredExec account. - #[account( - 0, - signer, - writable, - name = "payer", - desc = "Transaction payer" - )] + #[account(0, signer, writable, name = "payer", desc = "Transaction payer")] #[account(1, name = "wallet", desc = "Wallet PDA")] #[account(2, writable, name = "vault", desc = "Vault PDA (signer for CPI)")] #[account( @@ -223,9 +224,7 @@ pub enum ProgramIx { name = "refund_destination", desc = "Account to receive rent refund from closed DeferredExec" )] - ExecuteDeferred { - instructions: Vec, - }, + ExecuteDeferred { instructions: Vec }, /// Reclaim an expired DeferredExec account and refund rent /// @@ -253,29 +252,15 @@ pub enum ProgramIx { /// Revoke a session key early (before expiry) /// /// Only Owner or Admin can revoke. Closes the session account and refunds rent. - #[account( - 0, - signer, - name = "payer", - desc = "Transaction payer" - )] - #[account( - 1, - name = "wallet", - desc = "Wallet PDA" - )] + #[account(0, signer, writable, name = "payer", desc = "Transaction payer")] + #[account(1, name = "wallet", desc = "Wallet PDA")] #[account( 2, writable, name = "admin_authority", desc = "Owner/Admin authority PDA (counter incremented for Secp256r1)" )] - #[account( - 3, - writable, - name = "session", - desc = "Session PDA to revoke" - )] + #[account(3, writable, name = "session", desc = "Session PDA to revoke")] #[account( 4, writable, @@ -341,7 +326,9 @@ pub enum LazorKitInstruction { /// 2. `[]` Wallet PDA /// 3. `[writable]` Current Owner Authority PDA /// 4. `[writable]` New Owner Authority PDA - /// 5. `[]` System Program + /// 5. `[writable]` Refund Destination + /// 6. `[]` System Program + /// 7. `[]` Rent Sysvar TransferOwnership { new_type: u8, new_pubkey: [u8; 33], From 0554ae23308f3dbde9585b2b41bc36dfefd69fc4 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Wed, 6 May 2026 17:43:20 +0700 Subject: [PATCH 22/25] docs(audit): delta brief + diff bundle for Accretion follow-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepares the consolidated state at audit-pending-v1 for Accretion's delta-audit review. Local-only artifacts; not published, not pushed. Deliverables under docs/audit/: - DELTA_BRIEF.md: structured summary by phase (P0-P5), audit asks per phase, byte-identity claims vs upstream lazorkit-protocol, slot-share strategy context, contact + reproducibility info - program-src.diff: full unified diff of program/ between audit-baseline-2026-02-accretion (d1eaaeb, the prior audited state) and audit-pending-v1 (9c97fe2, the new state) - program-src.diff.stat: per-file changed-line summary - upstream-parity.txt: byte-identity report — 13/19 changed files byte-identical with already-audited lazorkit-protocol; 6 differ only for fee-strip / cosmetic reasons (URLs, layout) Local git tags created (not pushed): - audit-baseline-2026-02-accretion → d1eaaeb (prior audit baseline) - audit-pending-v1 → 9c97fe2 (current consolidated state) Audit ask is explicit per phase: - P1 action enforcement engine: confirm no new vulnerabilities - P5 auth/processor port: confirm Accretion's prior review of byte-identical upstream files extends to program-v2 - Slot-share compatibility: confirm state account layouts forward-compatible for binary swap at LazorjRF… mainnet slot Per user direction, NOT publishing or pushing yet — awaiting explicit permission for those operational steps. --- docs/audit/DELTA_BRIEF.md | 255 ++ docs/audit/program-src.diff | 5636 ++++++++++++++++++++++++++++++ docs/audit/program-src.diff.stat | 23 + docs/audit/upstream-parity.txt | 26 + 4 files changed, 5940 insertions(+) create mode 100644 docs/audit/DELTA_BRIEF.md create mode 100644 docs/audit/program-src.diff create mode 100644 docs/audit/program-src.diff.stat create mode 100644 docs/audit/upstream-parity.txt diff --git a/docs/audit/DELTA_BRIEF.md b/docs/audit/DELTA_BRIEF.md new file mode 100644 index 0000000..d568176 --- /dev/null +++ b/docs/audit/DELTA_BRIEF.md @@ -0,0 +1,255 @@ +# Accretion Audit Delta Brief — `program-v2` `audit-baseline-2026-02-accretion` → `audit-pending-v1` + +**Repository:** `lazor-kit/program-v2` +**Previous audit:** Accretion Labs, Solana Foundation, February 2026, A26SFR1 +([audits/2026-accretion-solana-foundation-lazorkit-audit-A26SFR1.pdf](../../audits/2026-accretion-solana-foundation-lazorkit-audit-A26SFR1.pdf)) +**Previous baseline tag:** `audit-baseline-2026-02-accretion` → commit `d1eaaeb` (Merge PR #49 fix/audit-hardening, 17/17 findings resolved) +**Delta tag:** `audit-pending-v1` → commit `9c97fe2` +**Delta scope:** 21 files in `program/`, +4168 / −444 lines + +This delta brief is for an **Accretion follow-up engagement** to confirm the +changes between the two tags introduce no new vulnerabilities versus the prior +audited state. Most of the new code is **byte-identical with the +already-audited `lazorkit-protocol` repo** (commercial sibling under the same +audit engagement); this is documented per-file in +[upstream-parity.txt](./upstream-parity.txt). + +--- + +## Strategic context (deploy plan) + +`program-v2` ships at the **same mainnet program ID** as `lazorkit-protocol` +(`LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi`). The foundation contract +period uses the `program-v2` (no-fee) build at that slot; afterwards, the +upgrade authority swaps the binary at the same slot to the `lazorkit-protocol` +(commercial, with-fee) build. + +**Implication for audit scope:** binary-swap compatibility requires that +program-v2 and lazorkit-protocol share identical: +- Account layouts (Wallet, Authority, Session, DeferredExec — verified + byte-identical via `state/*.rs` ↔ upstream) +- Instruction encoding (discriminators 0–9, account orderings — kept aligned + via the IDL sync in P5.4) +- Auth verification logic (auth/secp256r1/* — byte-identical with upstream + after P5.1) + +Audit confirmation of this delta therefore also benefits the slot-share +strategy: any wallet/authority/session created on either binary remains +verifiable by the other after swap. + +--- + +## Delta by phase + +The 23 commits between the two tags group into 5 phases: + +### P0 — Cherry-pick guardrails (zero-audit, mechanical) + +Tooling-only. Adds `scripts/strip-fee.sh`, `scripts/check-no-fee.sh`, +`scripts/fee-paths.txt`, and `.github/workflows/check-no-fee.yml` to enforce +that fee/admin/FeeRecord surface from `lazorkit-protocol` cannot leak into +`program-v2` during cherry-picks. **No `program/` source changed.** + +### P1 — Action types port + execute enforcement (delta-audit) + +**Most security-relevant section of the delta.** New on-chain feature. + +Files touched: +- `program/src/state/action.rs` (NEW, 697 lines) — byte-identical with + upstream `lazorkit-protocol/program/src/state/action.rs` (already audited). + Defines 8 action types + parser + validator. +- `program/src/state/session.rs` — minor (+23 lines) — adds + `SESSION_HEADER_SIZE` const and `actions_slice()` helper. Byte-identical + with upstream. +- `program/src/state/mod.rs` — adds `pub mod action;`. +- `program/src/processor/create_session.rs` — adopts variable-size session PDA + for optional actions buffer + validation at creation. Byte-identical with + upstream. +- `program/src/processor/execute_actions.rs` (NEW, 1644 lines) — + byte-identical with upstream `lazorkit-protocol/program/src/processor/execute/actions.rs`. + Pre-CPI program whitelist/blacklist + token snapshots; post-CPI delta + computation, SOL/token cap enforcement, recurring window resets, vault + invariant defenses against System::Assign / SetAuthority escapes. +- `program/src/processor/execute.rs` — wires pre/post action evaluation + around the CPI loop, adds the L5 anti-CPI guard for sessions, gross SOL + outflow tracking. Differs from upstream `processor/execute/immediate.rs` + by **1 import-path line** (`processor::execute_actions::` vs + `processor::execute::actions::`) due to flat vs nested processor layout. +- `program/src/error.rs` — adds 13 error variants (3020–3032) for action + validation, enforcement, and vault invariant defense. + +**Audit ask (P1):** Confirm action enforcement engine + execute integration +introduce no new vulnerabilities vs. the audited upstream version. + +### P2 — Dual-cluster + security_txt (zero-audit, mechanical) + +- `program/src/lib.rs` — embeds `security_txt!` with program-v2-specific URLs + (only difference vs upstream is the URL strings — see `upstream-parity.txt`) +- `assertions/src/lib.rs`, `assertions/Cargo.toml`, `program/Cargo.toml` — + Pattern D feature flags: `--features mainnet` embeds `LazorjRF…` (slot + shared with upstream); `--features devnet` embeds `FLb7…`; no feature → + `compile_error!` (prevents accidental cross-cluster deploy) + +No on-chain logic change. Build-time configuration only. + +### P3 — SDK consolidation (no audit) + +Off-chain only. `program-v2/sdk/solita-client/` deleted; `program-v2/tests-sdk/` +migrated to `@lazorkit/sdk-legacy` from npm (the same SDK +`lazorkit-protocol` ships). + +### P4 — Release infrastructure (no audit) + +`CHANGELOG.md`, `docs/MAINNET_DEPLOY_RUNBOOK.md`, `.github/workflows/release.yml`. +Documentation + CI only. + +### P5 — Consolidate: auth + processor port + IDL sync (delta-audit) + +**Aligns the remaining processor + auth files with upstream so the slot-share +strategy works end-to-end.** + +Files (all byte-identical with upstream after this phase, except where noted): +- `program/src/auth/secp256r1/{mod,webauthn,introspection}.rs` — port from + upstream. Replaces older typeAndFlags-format auth (which reconstructed + clientDataJSON server-side) with the format that embeds full raw + clientDataJSON in the auth payload. **Byte-identical with upstream.** +- `program/src/processor/{create_wallet,manage_authority,transfer_ownership, + execute_deferred,revoke_session}.rs` — port from upstream. Brings authority + data layout to: + `Secp256r1 authority = header(48) + cred_hash(32) + pubkey(33) + rpIdHash(32) = 145B` + (previously variable-length raw rpId; now precomputed SHA256 digest at + offset 113, saves one syscall per Execute). **Byte-identical with upstream.** +- `program/src/instruction.rs` — Shank IDL declarations resynced from + upstream (account metadata: writable modifiers, positions, descriptions); + 5 fee instruction variants (disc 10–14) stripped. Runtime not affected + (sdk-legacy uses hand-written builders, not generated IDL). + +**Audit ask (P5):** Since these files are byte-identical with upstream, +confirm Accretion's prior review of the upstream files extends to this +program. Specifically: +- Auth payload format change (typeAndFlags → embedded clientDataJSON) — was + this reviewed in the upstream audit? Any concerns specific to program-v2 + context? +- Authority layout change (raw rpId → rpIdHash) — same question. + +--- + +## Files NOT changed since baseline (sanity) + +- `program/src/auth/ed25519.rs` — unchanged +- `program/src/auth/mod.rs` — unchanged +- `program/src/auth/traits.rs` — unchanged +- `program/src/utils.rs` — unchanged +- `program/src/state/wallet.rs` — unchanged (verified byte-identical with + upstream — wallet account layout is the cross-binary contract for + slot-share) +- `program/src/state/authority.rs` — unchanged (same reasoning as wallet) +- `program/src/state/deferred.rs` — unchanged +- `program/src/processor/authorize.rs` — unchanged +- `program/src/processor/reclaim_deferred.rs` — unchanged +- `program/src/entrypoint.rs` — unchanged dispatch (still discs 0–9) + +--- + +## Strip surface — what's NOT in `program-v2` + +Per the slot-share strategy, the following are intentionally absent: + +- `program/src/state/protocol_config.rs` — admin + fee config +- `program/src/state/integrator_record.rs` — FeeRecord +- `program/src/state/treasury_shard.rs` — treasury +- `program/src/processor/protocol/{initialize_protocol,update_protocol, + register_integrator,withdraw_treasury,initialize_treasury_shard}.rs` +- Discriminators 10–14 in entrypoint dispatch +- `try_collect_fee` function in entrypoint +- `ProtocolError` enum (codes 4001–4007) +- `ProtocolConfig` / `FeeRecord` / `TreasuryShard` discriminator slots (5/6/7) + in `state/mod.rs::AccountDiscriminator` + +`scripts/check-no-fee.sh` enforces this via `scripts/fee-paths.txt`. CI fails +on any introduction. Verified clean at `audit-pending-v1`. + +--- + +## What we want from this engagement + +Specific questions for Accretion: + +1. **P1 action enforcement** — is the `evaluate_pre_actions` / + `evaluate_post_actions` engine + integration into `execute.rs` and + `create_session.rs` introducing any new vulnerabilities the prior audit + didn't cover? Action discriminator collision, integer overflow in + recurring-limit window math, race conditions between snapshot/execute, + vault invariant gaps? + +2. **P5 auth port** — confirm the typeAndFlags → embedded-clientDataJSON + format change is safe in this context. Particular attention to: + - origin field validation (intentionally omitted — see [audit doc L1 + comment](../../program/src/auth/secp256r1/mod.rs)) + - challenge field base64url encoding/decoding round-trip + - rpIdHash computation timing + +3. **P5 authority layout change** — confirm the raw rpId → rpIdHash storage + change preserves all binding properties. Specifically that the on-chain + stored rpIdHash remains tied to the credential at registration. + +4. **Slot-share compatibility** — given program-v2 will live at + `LazorjRF…` mainnet slot and later be replaced in-place by + `lazorkit-protocol`'s commercial binary, confirm: + - State account layouts are forward-compatible (existing Wallet, + Authority, Session, DeferredExec accounts created by program-v2 are + readable + valid for the commercial binary) + - Auth verification continues working for sessions/wallets created + pre-swap + +5. **Anything Accretion flagged in the prior audit that may have regressed** + — full diff bundle at [program-src.diff](./program-src.diff) for + line-by-line review. + +--- + +## Deliverables included + +- [DELTA_BRIEF.md](./DELTA_BRIEF.md) (this file) +- [program-src.diff](./program-src.diff) — full unified diff of `program/` + between the two tags (~5600 lines) +- [program-src.diff.stat](./program-src.diff.stat) — per-file changed-line + counts +- [upstream-parity.txt](./upstream-parity.txt) — byte-identity report vs + `lazorkit-protocol` +- Git tags `audit-baseline-2026-02-accretion` (commit `d1eaaeb`) and + `audit-pending-v1` (commit `9c97fe2`) +- This brief is intended to be sent alongside the on-chain `security_txt!` + pointer to the prior PDF. + +## Out of scope for this brief + +- `tests-sdk/`, `sdk/`, `docs/`, `scripts/`, `.github/`, `Cargo.toml`, + `assertions/Cargo.toml` — non-program changes (test infrastructure, build + config). Available in the full `git diff audit-baseline-2026-02-accretion..audit-pending-v1` + if Accretion requests but not part of the on-chain audit ask. +- Operational items (mainnet deploy, multisig setup, binary swap procedure) + — handled in [docs/MAINNET_DEPLOY_RUNBOOK.md](../MAINNET_DEPLOY_RUNBOOK.md). + +--- + +## Build reproducibility + +```bash +git checkout audit-pending-v1 +cd program && cargo build-sbf --features mainnet +shasum -a 256 ../target/deploy/lazorkit_program.so +# Hash should match the artifact in the eventual mainnet deploy. +``` + +CI workflow at `.github/workflows/release.yml` reproduces and publishes the +hash on each tag push. + +--- + +## Contact + +- Email: security@lazorkit.app +- GitHub Security Advisories: https://github.com/lazor-kit/program-v2/security/advisories/new +- On-chain pointer: `solana program show LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi` + → `security_txt!` block links to this repo + audit PDF. diff --git a/docs/audit/program-src.diff b/docs/audit/program-src.diff new file mode 100644 index 0000000..4eef8f5 --- /dev/null +++ b/docs/audit/program-src.diff @@ -0,0 +1,5636 @@ +diff --git a/program/Cargo.toml b/program/Cargo.toml +index 37229b6..a9b9e4b 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 } +@@ -13,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/idl.json b/program/idl.json +index 5912e6d..73e28e8 100644 +--- a/program/idl.json ++++ b/program/idl.json +@@ -92,10 +92,10 @@ + "accounts": [ + { + "name": "payer", +- "isMut": false, ++ "isMut": true, + "isSigner": true, + "docs": [ +- "Transaction payer" ++ "Payer and rent contributor" + ] + }, + { +@@ -108,10 +108,10 @@ + }, + { + "name": "adminAuthority", +- "isMut": false, +- "isSigner": true, ++ "isMut": true, ++ "isSigner": false, + "docs": [ +- "Admin authority PDA authorizing this action" ++ "Admin authority PDA authorizing this action (counter incremented)" + ] + }, + { +@@ -186,7 +186,7 @@ + "accounts": [ + { + "name": "payer", +- "isMut": false, ++ "isMut": true, + "isSigner": true, + "docs": [ + "Transaction payer" +@@ -202,10 +202,10 @@ + }, + { + "name": "adminAuthority", +- "isMut": false, +- "isSigner": true, ++ "isMut": true, ++ "isSigner": false, + "docs": [ +- "Admin authority PDA authorizing this action" ++ "Admin authority PDA authorizing this action (counter incremented)" + ] + }, + { +@@ -245,10 +245,10 @@ + "accounts": [ + { + "name": "payer", +- "isMut": false, ++ "isMut": true, + "isSigner": true, + "docs": [ +- "Transaction payer" ++ "Payer and rent contributor" + ] + }, + { +@@ -275,6 +275,14 @@ + "New owner authority PDA to be created" + ] + }, ++ { ++ "name": "refundDestination", ++ "isMut": true, ++ "isSigner": false, ++ "docs": [ ++ "Account to receive rent refund from closed current owner" ++ ] ++ }, + { + "name": "systemProgram", + "isMut": false, +@@ -335,7 +343,7 @@ + "accounts": [ + { + "name": "payer", +- "isMut": false, ++ "isMut": true, + "isSigner": true, + "docs": [ + "Transaction payer" +@@ -351,18 +359,18 @@ + }, + { + "name": "authority", +- "isMut": false, ++ "isMut": true, + "isSigner": false, + "docs": [ +- "Authority or Session PDA authorizing execution" ++ "Authority or Session PDA authorizing execution (counter incremented)" + ] + }, + { + "name": "vault", +- "isMut": false, ++ "isMut": true, + "isSigner": false, + "docs": [ +- "Vault PDA" ++ "Vault PDA (signer for CPI, lamports debited)" + ] + }, + { +@@ -407,10 +415,10 @@ + }, + { + "name": "adminAuthority", +- "isMut": false, +- "isSigner": true, ++ "isMut": true, ++ "isSigner": false, + "docs": [ +- "Admin/Owner authority PDA authorizing logic" ++ "Admin/Owner authority PDA authorizing logic (counter incremented)" + ] + }, + { +@@ -650,7 +658,7 @@ + "accounts": [ + { + "name": "payer", +- "isMut": false, ++ "isMut": true, + "isSigner": true, + "docs": [ + "Transaction payer" +diff --git a/program/src/auth/secp256r1/introspection.rs b/program/src/auth/secp256r1/introspection.rs +index a8ae963..ea95526 100644 +--- a/program/src/auth/secp256r1/introspection.rs ++++ b/program/src/auth/secp256r1/introspection.rs +@@ -61,12 +61,18 @@ impl Secp256r1SignatureOffsets { + } + + /// Verify the secp256r1 instruction data contains the expected signature and +-/// public key. This also validates that the secp256r1 precompile offsets point +-/// to the expected locations, ensuring proper data alignment. ++/// public key. Also validates that the secp256r1 precompile offsets point to ++/// the expected locations, ensuring proper data alignment. ++/// ++/// The expected precompile message is passed as TWO slices — the ++/// authenticator_data and the clientDataJSON hash — which are concatenated ++/// by the on-chain secp256r1 precompile as its signed message. Accepting ++/// two slices here lets the caller skip a Vec allocation for the concat. + pub fn verify_secp256r1_instruction_data( + instruction_data: &[u8], + expected_pubkey: &[u8; 33], +- expected_message: &[u8], ++ auth_data: &[u8], ++ client_data_hash: &[u8; 32], + ) -> Result<(), ProgramError> { + // Minimum check: must have at least the header and offsets + if instruction_data.len() < DATA_START { +@@ -111,25 +117,257 @@ pub fn verify_secp256r1_instruction_data( + if offsets.message_data_offset as usize != MESSAGE_DATA_OFFSET { + return Err(AuthError::InvalidInstruction.into()); + } +- if offsets.message_data_size as usize != expected_message.len() { ++ let expected_msg_len = auth_data.len() + client_data_hash.len(); ++ if offsets.message_data_size as usize != expected_msg_len { + return Err(AuthError::InvalidInstruction.into()); + } + + // Dynamic length check: instruction must contain the full message +- if instruction_data.len() < MESSAGE_DATA_OFFSET + expected_message.len() { ++ if instruction_data.len() < MESSAGE_DATA_OFFSET + expected_msg_len { + return Err(AuthError::InvalidInstruction.into()); + } + + let pubkey_data = &instruction_data + [PUBKEY_DATA_OFFSET..PUBKEY_DATA_OFFSET + COMPRESSED_PUBKEY_SERIALIZED_SIZE]; +- let message_data = +- &instruction_data[MESSAGE_DATA_OFFSET..MESSAGE_DATA_OFFSET + expected_message.len()]; +- + if pubkey_data != expected_pubkey { + return Err(AuthError::InvalidPubkey.into()); + } +- if message_data != expected_message { ++ ++ // Compare the precompile's message area against the two caller-supplied ++ // slices piecewise — no concat, no allocation. ++ let msg_auth = &instruction_data[MESSAGE_DATA_OFFSET..MESSAGE_DATA_OFFSET + auth_data.len()]; ++ if msg_auth != auth_data { ++ return Err(AuthError::InvalidMessageHash.into()); ++ } ++ let hash_start = MESSAGE_DATA_OFFSET + auth_data.len(); ++ let msg_hash = &instruction_data[hash_start..hash_start + client_data_hash.len()]; ++ if msg_hash != client_data_hash { + return Err(AuthError::InvalidMessageHash.into()); + } + Ok(()) + } ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ /// Helper: build valid secp256r1 precompile instruction data with the standard layout. ++ fn build_precompile_ix_data( ++ pubkey: &[u8; 33], ++ signature: &[u8; 64], ++ message: &[u8], ++ ) -> Vec { ++ let total_len = DATA_START + 64 + 33 + 1 + message.len(); ++ let mut data = vec![0u8; total_len]; ++ ++ // Header ++ data[0] = 1; // num_signatures ++ data[1] = 0; // padding ++ ++ // Offsets (little-endian) ++ data[2..4].copy_from_slice(&(SIGNATURE_DATA_OFFSET as u16).to_le_bytes()); ++ data[4..6].copy_from_slice(&0xFFFFu16.to_le_bytes()); // sig ix index ++ data[6..8].copy_from_slice(&(PUBKEY_DATA_OFFSET as u16).to_le_bytes()); ++ data[8..10].copy_from_slice(&0xFFFFu16.to_le_bytes()); // pubkey ix index ++ data[10..12].copy_from_slice(&(MESSAGE_DATA_OFFSET as u16).to_le_bytes()); ++ data[12..14].copy_from_slice(&(message.len() as u16).to_le_bytes()); // msg size ++ data[14..16].copy_from_slice(&0xFFFFu16.to_le_bytes()); // msg ix index ++ ++ // Data ++ data[SIGNATURE_DATA_OFFSET..SIGNATURE_DATA_OFFSET + 64].copy_from_slice(signature); ++ data[PUBKEY_DATA_OFFSET..PUBKEY_DATA_OFFSET + 33].copy_from_slice(pubkey); ++ // Byte at offset 113 is alignment padding (zero) ++ data[MESSAGE_DATA_OFFSET..MESSAGE_DATA_OFFSET + message.len()] ++ .copy_from_slice(message); ++ ++ data ++ } ++ ++ #[test] ++ fn test_verify_valid_instruction_data() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_ok()); ++ } ++ ++ #[test] ++ fn test_verify_variable_length_message() { ++ // Mode 1 messages are authenticatorData(37+) + clientDataJsonHash(32) = 69+ bytes. ++ // We split into the two halves exactly like the caller does post-refactor. ++ let pubkey = [0x03; 33]; ++ let signature = [0xCD; 64]; ++ let message = [0x22; 69]; ++ ++ let ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ let auth_data: &[u8] = &message[..37]; ++ let client_data_hash: &[u8; 32] = &message[37..].try_into().unwrap(); ++ assert!( ++ verify_secp256r1_instruction_data(&ix_data, &pubkey, auth_data, client_data_hash) ++ .is_ok() ++ ); ++ } ++ ++ #[test] ++ fn test_verify_rejects_wrong_pubkey() { ++ let pubkey = [0x02; 33]; ++ let wrong_pubkey = [0x03; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ let err = ++ verify_secp256r1_instruction_data(&ix_data, &wrong_pubkey, &[], &message).unwrap_err(); ++ assert_eq!(err, AuthError::InvalidPubkey.into()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_wrong_message() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ let wrong_message = [0x22; 32]; ++ ++ let ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ let err = ++ verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &wrong_message).unwrap_err(); ++ assert_eq!(err, AuthError::InvalidMessageHash.into()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_zero_signatures() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ ix_data[0] = 0; // zero signatures ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_multiple_signatures() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ ix_data[0] = 2; // two signatures ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_cross_instruction_sig_index() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ // Set signature_instruction_index to 0 instead of 0xFFFF ++ ix_data[4..6].copy_from_slice(&0u16.to_le_bytes()); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_cross_instruction_pubkey_index() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ // Set public_key_instruction_index to 1 instead of 0xFFFF ++ ix_data[8..10].copy_from_slice(&1u16.to_le_bytes()); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_cross_instruction_msg_index() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ // Set message_instruction_index to 0 instead of 0xFFFF ++ ix_data[14..16].copy_from_slice(&0u16.to_le_bytes()); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_wrong_pubkey_offset() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ // Tamper pubkey_offset to point elsewhere ++ ix_data[6..8].copy_from_slice(&200u16.to_le_bytes()); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_wrong_message_offset() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ // Tamper message_data_offset ++ ix_data[10..12].copy_from_slice(&50u16.to_le_bytes()); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_wrong_signature_offset() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ // Tamper signature_offset ++ ix_data[2..4].copy_from_slice(&100u16.to_le_bytes()); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_message_size_mismatch() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ // Set message_data_size to wrong value ++ ix_data[12..14].copy_from_slice(&64u16.to_le_bytes()); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_too_short_data() { ++ let pubkey = [0x02; 33]; ++ let message = [0x11; 32]; ++ ++ // Only 2 bytes — way too short ++ assert!(verify_secp256r1_instruction_data(&[0x01, 0x00], &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_verify_rejects_truncated_message_area() { ++ let pubkey = [0x02; 33]; ++ let signature = [0xAB; 64]; ++ let message = [0x11; 32]; ++ ++ let mut ix_data = build_precompile_ix_data(&pubkey, &signature, &message); ++ // Truncate — remove last 10 bytes so message area is incomplete ++ ix_data.truncate(ix_data.len() - 10); ++ assert!(verify_secp256r1_instruction_data(&ix_data, &pubkey, &[], &message).is_err()); ++ } ++ ++ #[test] ++ fn test_offsets_constants_are_consistent() { ++ assert_eq!(DATA_START, 16); // 2 header + 14 offsets ++ assert_eq!(SIGNATURE_DATA_OFFSET, 16); ++ assert_eq!(PUBKEY_DATA_OFFSET, 16 + 64); // 80 ++ assert_eq!(MESSAGE_DATA_OFFSET, 80 + 33 + 1); // 114 ++ } ++} +diff --git a/program/src/auth/secp256r1/mod.rs b/program/src/auth/secp256r1/mod.rs +index a6bafe8..ecc8363 100644 +--- a/program/src/auth/secp256r1/mod.rs ++++ b/program/src/auth/secp256r1/mod.rs +@@ -15,9 +15,7 @@ pub mod introspection; + pub mod webauthn; + + use self::introspection::verify_secp256r1_instruction_data; +-use self::webauthn::{ +- reconstruct_client_data_json, AuthDataParser, ClientDataJsonReconstructionParams, +-}; ++use self::webauthn::{base64url_encode_no_pad, extract_top_level_string_field, AuthDataParser}; + + use crate::auth::traits::Authenticator; + use crate::utils::get_stack_height; +@@ -31,11 +29,20 @@ pub struct Secp256r1Authenticator; + impl Authenticator for Secp256r1Authenticator { + /// Authenticates a Secp256r1 signature (WebAuthn/Passkeys). + /// +- /// Auth payload layout: +- /// [slot(8)] [counter(4)] [sysvarIxIdx(1)] [flags(1)] [authenticatorData(M)] ++ /// Auth payload layout (raw clientDataJSON — the only supported mode): ++ /// [slot(8)] [counter(4)] [sysvarIxIdx(1)] [_reserved(1)] ++ /// [authDataLen(2 LE)] [authenticatorData(M)] ++ /// [cdjLen(2 LE)] [clientDataJson(N)] ++ /// ++ /// rpIdHash is pre-computed at authority creation and stored on the ++ /// Authority account, so every Execute saves one sol_sha256 syscall and ++ /// the Authority account size is fixed (145 bytes for Secp256r1). + /// +- /// rpId is stored on the authority account (not in the payload). +- /// Counter is a program-controlled u32 odometer. Client must submit `on_chain_counter + 1`. ++ /// Counter is a program-controlled u32 odometer. Client must submit ++ /// `on_chain_counter + 1`. ++ /// ++ /// Programmatic/bot signing should use Ed25519 authorities instead — ++ /// Secp256r1 is passkeys-only. + fn authenticate( + &self, + accounts: &[AccountInfo], +@@ -45,26 +52,23 @@ impl Authenticator for Secp256r1Authenticator { + discriminator: &[u8], + program_id: &Pubkey, + ) -> Result<(), ProgramError> { +- // Minimum: slot(8) + counter(4) + sysvarIxIdx(1) + flags(1) = 14 +- if auth_payload.len() < 14 { ++ // Minimum: slot(8) + counter(4) + sysvarIxIdx(1) + reserved(1) = 14, ++ // plus authDataLen(2) + cdjLen(2) = 18 before any payload bytes. ++ if auth_payload.len() < 18 { + return Err(AuthError::InvalidAuthorityPayload.into()); + } + + let slot = u64::from_le_bytes(auth_payload[0..8].try_into().unwrap()); + let submitted_counter = u32::from_le_bytes(auth_payload[8..12].try_into().unwrap()); + let sysvar_ix_index = auth_payload[12] as usize; +- +- let reconstruction_params = ClientDataJsonReconstructionParams { +- type_and_flags: auth_payload[13], +- }; +- let authenticator_data_raw: &[u8] = &auth_payload[14..]; ++ // auth_payload[13] reserved (carried over from legacy mode byte). + + // Anti-CPI check: prevent cross-program authentication attacks + if get_stack_height() > 1 { + return Err(AuthError::PermissionDenied.into()); + } + +- // Validate slot freshness using Clock sysvar (replaces SlotHashes lookup) ++ // Validate slot freshness using Clock sysvar + let clock = Clock::get()?; + let current_slot = clock.slot; + if slot > current_slot { +@@ -84,55 +88,33 @@ impl Authenticator for Secp256r1Authenticator { + }; + + // --- Odometer validation --- +- // The client must submit exactly `stored_counter + 1`. +- // This decouples replay protection from the WebAuthn hardware counter, +- // which is unreliable for synced passkeys (iCloud, Google). + let expected_counter = header.counter.wrapping_add(1); + if submitted_counter != expected_counter { + return Err(AuthError::SignatureReused.into()); + } + +- // Secp256r1 on-chain data layout: +- // [Header(48)] [credential_id_hash(32)] [Pubkey(33)] [rpIdLen(1)] [rpId(N)] +- let pubkey_offset = header_size + 32; // skip credential_id_hash +- if auth_data.len() < pubkey_offset + 33 { ++ // Secp256r1 on-chain data layout (fixed 145 bytes total): ++ // [Header(48)] [credential_id_hash(32)] [Pubkey(33)] [rpIdHash(32)] ++ let pubkey_offset = header_size + 32; // 80 ++ let rp_id_hash_offset = pubkey_offset + 33; // 113 ++ if auth_data.len() < rp_id_hash_offset + 32 { + return Err(AuthError::InvalidAuthorityPayload.into()); + } +- +- // Read rpId from authority account data (stored at creation time) +- let rp_id_len_offset = pubkey_offset + 33; +- if auth_data.len() < rp_id_len_offset + 1 { +- return Err(AuthError::InvalidAuthorityPayload.into()); +- } +- let rp_id_len = auth_data[rp_id_len_offset] as usize; +- let rp_id_offset = rp_id_len_offset + 1; +- if auth_data.len() < rp_id_offset + rp_id_len { +- return Err(AuthError::InvalidAuthorityPayload.into()); +- } +- let rp_id = &auth_data[rp_id_offset..rp_id_offset + rp_id_len]; +- +- #[allow(unused_assignments)] +- let mut computed_rp_id_hash = [0u8; 32]; +- #[cfg(target_os = "solana")] +- unsafe { +- let _res = pinocchio::syscalls::sol_sha256( +- [rp_id].as_ptr() as *const u8, +- 1, +- computed_rp_id_hash.as_mut_ptr(), +- ); +- } +- #[cfg(not(target_os = "solana"))] +- { +- computed_rp_id_hash = [0u8; 32]; +- } ++ let stored_rp_id_hash = &auth_data[rp_id_hash_offset..rp_id_hash_offset + 32]; + + let payer = accounts.first().ok_or(ProgramError::NotEnoughAccountKeys)?; + if !payer.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } + +- // Build challenge hash: +- // SHA256(discriminator || auth_payload || signed_payload || slot || payer || counter || program_id) ++ // Challenge hash: ++ // SHA256(discriminator || auth_payload[..14] || signed_payload ++ // || payer || counter || program_id) ++ // ++ // Only the 14-byte fixed prefix of auth_payload is included because the ++ // remainder contains clientDataJSON — which is produced by the ++ // authenticator *after* signing the challenge, so it can't be in the ++ // hash input. + let counter_bytes = expected_counter.to_le_bytes(); + #[allow(unused_assignments)] + let mut hasher = [0u8; 32]; +@@ -141,57 +123,93 @@ impl Authenticator for Secp256r1Authenticator { + let _res = pinocchio::syscalls::sol_sha256( + [ + discriminator, +- auth_payload, ++ &auth_payload[..14], + signed_payload, +- &slot.to_le_bytes(), + payer.key().as_ref(), + &counter_bytes, + program_id.as_ref(), + ] + .as_ptr() as *const u8, +- 7, ++ 6, + hasher.as_mut_ptr(), + ); + } + #[cfg(not(target_os = "solana"))] + { +- let _ = signed_payload; +- let _ = discriminator; +- let _ = counter_bytes; +- let _ = program_id; ++ let _ = (signed_payload, discriminator, counter_bytes, program_id); + hasher = [0u8; 32]; + } + +- let client_data_json = reconstruct_client_data_json(&reconstruction_params, rp_id, &hasher); ++ // --- Parse Mode 1 payload: authenticatorData + clientDataJSON --- ++ let auth_data_len = ++ u16::from_le_bytes(auth_payload[14..16].try_into().unwrap()) as usize; ++ if auth_payload.len() < 16 + auth_data_len + 2 { ++ return Err(AuthError::InvalidAuthorityPayload.into()); ++ } ++ let authenticator_data_raw = &auth_payload[16..16 + auth_data_len]; ++ ++ let cdj_len_offset = 16 + auth_data_len; ++ let cdj_len = ++ u16::from_le_bytes(auth_payload[cdj_len_offset..cdj_len_offset + 2].try_into().unwrap()) ++ as usize; ++ let cdj_offset = cdj_len_offset + 2; ++ // L2: strict length — trailing bytes after cdj are not covered by ++ // challenge hash or precompile message, so they're rejected. ++ if cdj_len == 0 || auth_payload.len() != cdj_offset + cdj_len { ++ return Err(AuthError::InvalidAuthorityPayload.into()); ++ } ++ let raw_client_data_json = &auth_payload[cdj_offset..cdj_offset + cdj_len]; ++ ++ // L1: We intentionally do NOT validate the `origin` field inside the ++ // clientDataJSON. The binding that matters is the authenticator's ++ // `rpIdHash` (checked below against the on-chain stored rpIdHash), ++ // which the authenticator hardware/OS computes from the registered ++ // relying party and refuses to sign cross-origin. ++ ++ // Validate "type" field is "webauthn.get" ++ let type_value = extract_top_level_string_field(raw_client_data_json, b"type")?; ++ if type_value != b"webauthn.get" { ++ return Err(AuthError::InvalidAuthenticationKind.into()); ++ } ++ ++ // Validate "challenge" field matches expected base64url(challenge_hash). ++ // L3: constant-time byte comparison. ++ let challenge_value = ++ extract_top_level_string_field(raw_client_data_json, b"challenge")?; ++ let expected_challenge_b64 = base64url_encode_no_pad(&hasher); ++ if !ct_eq(challenge_value, expected_challenge_b64.as_slice()) { ++ return Err(AuthError::InvalidMessageHash.into()); ++ } ++ ++ // Hash the raw clientDataJSON + #[allow(unused_assignments)] + let mut client_data_hash = [0u8; 32]; + #[cfg(target_os = "solana")] + unsafe { + let _res = pinocchio::syscalls::sol_sha256( +- [client_data_json.as_slice()].as_ptr() as *const u8, ++ [raw_client_data_json].as_ptr() as *const u8, + 1, + client_data_hash.as_mut_ptr(), + ); + } + #[cfg(not(target_os = "solana"))] + { +- let _ = client_data_json; ++ let _ = raw_client_data_json; + client_data_hash = [0u8; 32]; + } + ++ // --- Shared validation (both modes) --- ++ + let auth_data_parser = AuthDataParser::new(authenticator_data_raw)?; + if !auth_data_parser.is_user_present() { + return Err(AuthError::PermissionDenied.into()); + } + +- // Note: We intentionally do NOT check auth_data_parser.counter() (the WebAuthn hardware +- // counter). Synced passkeys (iCloud Keychain, Google Password Manager) may return 0 or +- // non-incrementing values. The program-controlled odometer above provides replay protection. ++ // Note: We intentionally do NOT check the WebAuthn hardware counter. ++ // Synced passkeys (iCloud, Google) may return 0 or non-incrementing values. + +- // Security Validation: +- // Ensure the domain (rp_id_hash) the user provided in the instruction payload actually matches +- // the rpIdHash that the authenticator (Hardware/FaceID) signed over inside authenticatorData. +- if auth_data_parser.rp_id_hash() != computed_rp_id_hash { ++ // Validate rpIdHash in authenticatorData matches the stored rpIdHash. ++ if auth_data_parser.rp_id_hash() != stored_rp_id_hash { + return Err(AuthError::InvalidPubkey.into()); + } + +@@ -199,10 +217,10 @@ impl Authenticator for Secp256r1Authenticator { + let instruction_pubkey_bytes = &auth_data[pubkey_offset..pubkey_offset + 33]; + let expected_pubkey: &[u8; 33] = instruction_pubkey_bytes.try_into().unwrap(); + +- let mut signed_message = Vec::with_capacity(authenticator_data_raw.len() + 32); +- signed_message.extend_from_slice(authenticator_data_raw); +- signed_message.extend_from_slice(&client_data_hash); ++ // The precompile's signed message is authenticator_data ∥ client_data_hash. ++ // Pass the two slices separately to avoid an intermediate Vec allocation. + ++ // Introspect the secp256r1 precompile instruction (must be the previous instruction) + let sysvar_instructions = accounts + .get(sysvar_ix_index) + .ok_or(AuthError::InvalidAuthorityPayload)?; +@@ -225,10 +243,11 @@ impl Authenticator for Secp256r1Authenticator { + verify_secp256r1_instruction_data( + secp_ix.get_instruction_data(), + expected_pubkey, +- &signed_message, ++ authenticator_data_raw, ++ &client_data_hash, + )?; + +- // Signature verified successfully — now commit the counter update ++ // Signature verified successfully — commit the counter update + header.counter = expected_counter; + unsafe { + std::ptr::write_unaligned( +@@ -240,3 +259,52 @@ impl Authenticator for Secp256r1Authenticator { + Ok(()) + } + } ++ ++/// Constant-time byte slice equality. Returns `false` for different lengths; ++/// otherwise XORs every byte pair into an accumulator and compares to zero, ++/// ensuring the comparison takes the same time regardless of where (or if) the ++/// bytes differ. Used for the Mode 1 challenge check. ++#[inline(always)] ++fn ct_eq(a: &[u8], b: &[u8]) -> bool { ++ if a.len() != b.len() { ++ return false; ++ } ++ let mut acc: u8 = 0; ++ for i in 0..a.len() { ++ acc |= a[i] ^ b[i]; ++ } ++ acc == 0 ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::ct_eq; ++ ++ #[test] ++ fn ct_eq_equal() { ++ assert!(ct_eq(b"abc", b"abc")); ++ assert!(ct_eq(b"", b"")); ++ assert!(ct_eq(&[0xFF; 43], &[0xFF; 43])); ++ } ++ ++ #[test] ++ fn ct_eq_different_length() { ++ assert!(!ct_eq(b"abc", b"abcd")); ++ assert!(!ct_eq(b"", b"a")); ++ } ++ ++ #[test] ++ fn ct_eq_differs_at_start() { ++ assert!(!ct_eq(b"xbc", b"abc")); ++ } ++ ++ #[test] ++ fn ct_eq_differs_at_end() { ++ assert!(!ct_eq(b"abx", b"abc")); ++ } ++ ++ #[test] ++ fn ct_eq_differs_at_middle() { ++ assert!(!ct_eq(b"axc", b"abc")); ++ } ++} +diff --git a/program/src/auth/secp256r1/webauthn.rs b/program/src/auth/secp256r1/webauthn.rs +index f8d0371..db1725c 100644 +--- a/program/src/auth/secp256r1/webauthn.rs ++++ b/program/src/auth/secp256r1/webauthn.rs +@@ -1,51 +1,9 @@ +-#[allow(unused_imports)] + use crate::error::AuthError; +-#[allow(unused_imports)] + use pinocchio::program_error::ProgramError; + +-/// Packed flags for clientDataJson reconstruction +-#[derive(Clone, Copy, Debug)] +-#[repr(C)] +-pub struct ClientDataJsonReconstructionParams { +- pub type_and_flags: u8, +-} +- +-impl ClientDataJsonReconstructionParams { +- #[allow(dead_code)] +- const TYPE_CREATE: u8 = 0x00; +- const TYPE_GET: u8 = 0x10; +- const FLAG_CROSS_ORIGIN: u8 = 0x01; +- const FLAG_HTTP_ORIGIN: u8 = 0x02; +- const FLAG_GOOGLE_EXTRA: u8 = 0x04; +- +- pub fn auth_type(&self) -> AuthType { +- if (self.type_and_flags & 0xF0) == Self::TYPE_GET { +- AuthType::Get +- } else { +- AuthType::Create +- } +- } +- +- pub fn is_cross_origin(&self) -> bool { +- self.type_and_flags & Self::FLAG_CROSS_ORIGIN != 0 +- } +- +- pub fn is_http(&self) -> bool { +- self.type_and_flags & Self::FLAG_HTTP_ORIGIN != 0 +- } +- +- pub fn has_google_extra(&self) -> bool { +- self.type_and_flags & Self::FLAG_GOOGLE_EXTRA != 0 +- } +-} +- +-#[derive(Clone, Copy, Debug)] +-pub enum AuthType { +- Create, +- Get, +-} +- +-/// Simple Base64URL encoder without padding ++/// Simple Base64URL encoder without padding. ++/// Used to compare an on-chain-computed challenge against the base64url value ++/// the browser authenticator placed inside clientDataJSON. + pub fn base64url_encode_no_pad(data: &[u8]) -> Vec { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut result = Vec::with_capacity(data.len().div_ceil(3) * 4); +@@ -70,48 +28,6 @@ pub fn base64url_encode_no_pad(data: &[u8]) -> Vec { + result + } + +-/// Reconstructs the clientDataJson +-pub fn reconstruct_client_data_json( +- params: &ClientDataJsonReconstructionParams, +- rp_id: &[u8], +- challenge: &[u8], +-) -> Vec { +- let challenge_b64url = base64url_encode_no_pad(challenge); +- let type_str: &[u8] = match params.auth_type() { +- AuthType::Create => b"webauthn.create", +- AuthType::Get => b"webauthn.get", +- }; +- +- let prefix: &[u8] = if params.is_http() { +- b"http://" +- } else { +- b"https://" +- }; +- let cross_origin: &[u8] = if params.is_cross_origin() { +- b"true" +- } else { +- b"false" +- }; +- +- let mut json = Vec::with_capacity(256); +- json.extend_from_slice(b"{\"type\":\""); +- json.extend_from_slice(type_str); +- json.extend_from_slice(b"\",\"challenge\":\""); +- json.extend_from_slice(&challenge_b64url); +- json.extend_from_slice(b"\",\"origin\":\""); +- json.extend_from_slice(prefix); +- json.extend_from_slice(rp_id); +- json.extend_from_slice(b"\",\"crossOrigin\":"); +- json.extend_from_slice(cross_origin); +- +- if params.has_google_extra() { +- json.extend_from_slice(b",\"other_keys_can_be_added_here\":\"do not compare clientDataJSON against a template. See https://goo.gl/yabPex\""); +- } +- +- json.extend_from_slice(b"}"); +- json +-} +- + /// Minimum authenticator data length: rpIdHash(32) + flags(1) + counter(4) = 37 + pub const AUTH_DATA_MIN_LEN: usize = 37; + +@@ -144,3 +60,536 @@ impl<'a> AuthDataParser<'a> { + u32::from_be_bytes(self.data[33..37].try_into().unwrap()) + } + } ++ ++/// Extracts a top-level string value for a given key from a JSON object. ++/// ++/// Walks `{"key":"value", ...}` looking for the specified key at depth 1. ++/// Returns the value bytes (without quotes). Rejects escaped strings ++/// (backslash inside key or value) to prevent challenge injection. ++pub fn extract_top_level_string_field<'a>( ++ json: &'a [u8], ++ field_name: &[u8], ++) -> Result<&'a [u8], ProgramError> { ++ // Skip leading whitespace ++ let mut i = 0; ++ while i < json.len() && json[i].is_ascii_whitespace() { ++ i += 1; ++ } ++ if i >= json.len() || json[i] != b'{' { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ ++ let mut depth: usize = 0; ++ let mut cursor = i; ++ ++ while cursor < json.len() { ++ let byte = json[cursor]; ++ ++ if byte == b'{' { ++ depth += 1; ++ cursor += 1; ++ continue; ++ } ++ if byte == b'}' { ++ if depth == 0 { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ depth -= 1; ++ cursor += 1; ++ continue; ++ } ++ ++ // Only parse keys at the top level (depth == 1) ++ if depth == 1 && byte == b'"' { ++ // Parse key ++ let key_start = cursor + 1; ++ let mut key_end = key_start; ++ while key_end < json.len() { ++ let b = json[key_end]; ++ if b == b'"' { ++ break; ++ } ++ if b == b'\\' { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ key_end += 1; ++ } ++ if key_end >= json.len() { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ ++ // Skip past closing quote ++ cursor = key_end + 1; ++ ++ // Skip whitespace before colon ++ while cursor < json.len() && json[cursor].is_ascii_whitespace() { ++ cursor += 1; ++ } ++ if cursor >= json.len() || json[cursor] != b':' { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ cursor += 1; ++ ++ // Skip whitespace after colon ++ while cursor < json.len() && json[cursor].is_ascii_whitespace() { ++ cursor += 1; ++ } ++ if cursor >= json.len() { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ ++ // Check if this is the field we want ++ if &json[key_start..key_end] == field_name { ++ // Value must be a string ++ if json[cursor] != b'"' { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ let value_start = cursor + 1; ++ let mut value_end = value_start; ++ while value_end < json.len() { ++ let b = json[value_end]; ++ if b == b'"' { ++ return Ok(&json[value_start..value_end]); ++ } ++ if b == b'\\' { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ value_end += 1; ++ } ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ ++ // Not our field — skip the value ++ if json[cursor] == b'"' { ++ // String value — skip to closing quote ++ cursor += 1; ++ while cursor < json.len() { ++ let b = json[cursor]; ++ if b == b'"' { ++ cursor += 1; ++ break; ++ } ++ if b == b'\\' { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ cursor += 1; ++ } ++ } else { ++ // Non-string value (number, bool, null, object, array) — skip ++ // until the next comma or closing brace at the same depth. ++ // Track nested braces and brackets, and when we encounter a ++ // string, consume it entirely so that `{`, `}`, `[`, `]`, or ++ // `,` inside the string body don't corrupt depth tracking. ++ // ++ // Without the inner string-skip, a payload like ++ // {"tokenBinding":{"id":"x}y"},"challenge":"real"} ++ // would have the `}` inside "x}y" mistakenly close the nested ++ // object and the parser would then mis-locate the top-level ++ // "challenge" entry. ++ let mut nest: usize = 0; ++ while cursor < json.len() { ++ match json[cursor] { ++ b'"' => { ++ // Consume the string; inner quotes/braces/commas ++ // must not affect outer nesting state. ++ cursor += 1; ++ while cursor < json.len() { ++ let b = json[cursor]; ++ if b == b'"' { ++ cursor += 1; ++ break; ++ } ++ if b == b'\\' { ++ return Err(AuthError::InvalidMessage.into()); ++ } ++ cursor += 1; ++ } ++ continue; ++ } ++ b'{' | b'[' => nest += 1, ++ b'}' | b']' => { ++ if nest == 0 { ++ break; ++ } ++ nest -= 1; ++ } ++ b',' if nest == 0 => break, ++ _ => {} ++ } ++ cursor += 1; ++ } ++ } ++ continue; ++ } ++ ++ cursor += 1; ++ } ++ ++ Err(AuthError::InvalidMessage.into()) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ // ─── extract_top_level_string_field tests ─────────────────────────── ++ ++ #[test] ++ fn test_extract_field_basic() { ++ let json = br#"{"type":"webauthn.get","challenge":"abc123","origin":"https://example.com"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"type").unwrap(), ++ b"webauthn.get" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"abc123" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"origin").unwrap(), ++ b"https://example.com" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_field_with_bool_value() { ++ let json = ++ br#"{"type":"webauthn.get","challenge":"abc","crossOrigin":false,"origin":"https://x.com"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"abc" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"origin").unwrap(), ++ b"https://x.com" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_field_with_nested_object() { ++ // Real Android clientDataJSON has extra fields like androidPackageName ++ let json = ++ br#"{"type":"webauthn.get","challenge":"xyz","origin":"https://a.com","androidPackageName":"com.example.app"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"type").unwrap(), ++ b"webauthn.get" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"xyz" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_field_with_nested_json_object() { ++ // Nested object should be skipped when looking for top-level keys ++ let json = br#"{"nested":{"challenge":"fake"},"type":"webauthn.get","challenge":"real"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"real" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"type").unwrap(), ++ b"webauthn.get" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_field_missing_key() { ++ let json = br#"{"type":"webauthn.get"}"#; ++ assert!(extract_top_level_string_field(json, b"challenge").is_err()); ++ } ++ ++ #[test] ++ fn test_extract_field_rejects_escaped_key() { ++ // Backslash in key → reject (prevents injection) ++ let json = br#"{"ty\"pe":"webauthn.get"}"#; ++ assert!(extract_top_level_string_field(json, b"type").is_err()); ++ } ++ ++ #[test] ++ fn test_extract_field_rejects_escaped_value() { ++ // Backslash in value → reject ++ let json = br#"{"challenge":"abc\"def"}"#; ++ assert!(extract_top_level_string_field(json, b"challenge").is_err()); ++ } ++ ++ #[test] ++ fn test_extract_field_rejects_non_string_value() { ++ // challenge is a number, not a string → reject ++ let json = br#"{"challenge":12345}"#; ++ assert!(extract_top_level_string_field(json, b"challenge").is_err()); ++ } ++ ++ #[test] ++ fn test_extract_field_rejects_empty_input() { ++ assert!(extract_top_level_string_field(b"", b"type").is_err()); ++ } ++ ++ #[test] ++ fn test_extract_field_rejects_not_object() { ++ assert!(extract_top_level_string_field(b"[1,2,3]", b"type").is_err()); ++ } ++ ++ #[test] ++ fn test_extract_field_nested_challenge_not_found_at_top() { ++ // challenge only exists inside a nested object — should not be found ++ let json = br#"{"type":"webauthn.get","nested":{"challenge":"sneaky"}}"#; ++ assert!(extract_top_level_string_field(json, b"challenge").is_err()); ++ } ++ ++ #[test] ++ fn test_extract_field_with_whitespace() { ++ let json = br#"{ "type" : "webauthn.get" , "challenge" : "abc" }"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"type").unwrap(), ++ b"webauthn.get" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"abc" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_field_google_extra() { ++ // Google Chrome adds this extra field ++ let json = br#"{"type":"webauthn.get","challenge":"abc","origin":"https://x.com","crossOrigin":false,"other_keys_can_be_added_here":"do not compare clientDataJSON against a template. See https://goo.gl/yabPex"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"abc" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"type").unwrap(), ++ b"webauthn.get" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_field_with_array_value() { ++ // Array value should be skipped properly ++ let json = br#"{"arr":[1,2,3],"challenge":"abc"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"abc" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_field_with_nested_array_of_objects() { ++ let json = br#"{"arr":[{"challenge":"fake"},{"x":1}],"challenge":"real"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"real" ++ ); ++ } ++ ++ // ─── M1 regression: string content inside nested value must not ++ // corrupt depth tracking ────────────────────────────────────── ++ ++ #[test] ++ fn test_extract_skips_string_containing_close_brace_in_nested_object() { ++ // Pre-fix, the `}` inside "x}y" would mistakenly close the nested ++ // object, and the parser would then mis-locate the top-level ++ // "challenge" entry. Post-fix, the inner string is consumed as a ++ // whole so nested depth stays at 1 until the real `}` at end of ++ // the tokenBinding value. ++ let json = br#"{"tokenBinding":{"id":"x}y"},"challenge":"real"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"real" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_skips_string_containing_close_bracket() { ++ let json = br#"{"arr":[{"id":"x]y"}],"challenge":"real"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"real" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_skips_string_containing_comma_in_nested_object() { ++ // Comma inside a string at non-zero depth — should not affect anything, ++ // but once we `continue` to the top of the skip loop we could be fooled ++ // into thinking the comma terminates the value. ++ let json = br#"{"obj":{"k":"a,b,c"},"challenge":"real"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"real" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_skips_string_containing_open_brace_in_array() { ++ let json = br#"{"arr":[{"id":"x{y"}],"challenge":"real"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"real" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_challenge_before_tokenbinding_still_works() { ++ // Safety: ensure the fix doesn't break the common happy path. ++ let json = br#"{"type":"webauthn.get","challenge":"real","tokenBinding":{"id":"x}y"}}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"real" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_rejects_backslash_in_skipped_string_inside_nested() { ++ // Backslashes are rejected in string values everywhere, including ++ // strings inside a skipped nested object. This prevents escape ++ // injection reaching the parser via nested fields. ++ let json = br#"{"obj":{"id":"a\"b"},"challenge":"real"}"#; ++ assert!(extract_top_level_string_field(json, b"challenge").is_err()); ++ } ++ ++ // ─── base64url_encode_no_pad tests ────────────────────────────────── ++ ++ #[test] ++ fn test_base64url_encode_empty() { ++ assert_eq!(base64url_encode_no_pad(&[]), b""); ++ } ++ ++ #[test] ++ fn test_base64url_encode_known_vectors() { ++ // "f" → "Zg" ++ assert_eq!(base64url_encode_no_pad(b"f"), b"Zg"); ++ // "fo" → "Zm8" ++ assert_eq!(base64url_encode_no_pad(b"fo"), b"Zm8"); ++ // "foo" → "Zm9v" ++ assert_eq!(base64url_encode_no_pad(b"foo"), b"Zm9v"); ++ } ++ ++ #[test] ++ fn test_base64url_encode_32_bytes() { ++ // SHA256 output (32 bytes) → 43 base64url chars (no padding) ++ let data = [0x11u8; 32]; ++ let encoded = base64url_encode_no_pad(&data); ++ assert_eq!(encoded.len(), 43); ++ // Verify no padding characters ++ assert!(!encoded.contains(&b'=')); ++ // Verify URL-safe: no + or / ++ assert!(!encoded.contains(&b'+')); ++ assert!(!encoded.contains(&b'/')); ++ } ++ ++ #[test] ++ fn test_base64url_uses_url_safe_chars() { ++ // 0xFB, 0xFF → should produce '-' and '_' instead of '+' and '/' ++ let data = [0xFB, 0xFF, 0xFE]; ++ let encoded = base64url_encode_no_pad(&data); ++ let encoded_str = std::str::from_utf8(&encoded).unwrap(); ++ assert!( ++ !encoded_str.contains('+') && !encoded_str.contains('/'), ++ "Must use URL-safe alphabet" ++ ); ++ } ++ ++ // ─── AuthDataParser tests ─────────────────────────────────────────── ++ ++ #[test] ++ fn test_auth_data_parser_basic() { ++ let mut data = [0u8; 37]; ++ // rpIdHash = first 32 bytes (zeros) ++ data[32] = 0x05; // flags: user present (0x01) + user verified (0x04) ++ data[33..37].copy_from_slice(&[0, 0, 0, 42]); // counter = 42 (big-endian) ++ ++ let parser = AuthDataParser::new(&data).unwrap(); ++ assert!(parser.is_user_present()); ++ assert!(parser.is_user_verified()); ++ assert_eq!(parser.counter(), 42); ++ assert_eq!(parser.rp_id_hash(), &[0u8; 32]); ++ } ++ ++ #[test] ++ fn test_auth_data_parser_no_flags() { ++ let data = [0u8; 37]; ++ let parser = AuthDataParser::new(&data).unwrap(); ++ assert!(!parser.is_user_present()); ++ assert!(!parser.is_user_verified()); ++ } ++ ++ #[test] ++ fn test_auth_data_parser_too_short() { ++ let data = [0u8; 36]; // Less than 37 ++ assert!(AuthDataParser::new(&data).is_err()); ++ } ++ ++ #[test] ++ fn test_auth_data_parser_with_extensions() { ++ // Real authenticators may return > 37 bytes (with extensions) ++ let mut data = [0u8; 100]; ++ data[32] = 0x41; // user present + attested credential data ++ let parser = AuthDataParser::new(&data).unwrap(); ++ assert!(parser.is_user_present()); ++ } ++ ++ // ─── Real-world clientDataJSON samples ────────────────────────────── ++ ++ #[test] ++ fn test_extract_from_chrome_sample() { ++ let json = br#"{"type":"webauthn.get","challenge":"dGVzdC1jaGFsbGVuZ2U","origin":"https://lazorkit.app","crossOrigin":false}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"type").unwrap(), ++ b"webauthn.get" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"dGVzdC1jaGFsbGVuZ2U" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"origin").unwrap(), ++ b"https://lazorkit.app" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_from_android_sample() { ++ // Android may include androidPackageName and topOrigin ++ let json = br#"{"type":"webauthn.get","challenge":"abc123","origin":"https://example.com","androidPackageName":"com.example.app","topOrigin":"https://example.com"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"type").unwrap(), ++ b"webauthn.get" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"abc123" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"androidPackageName").unwrap(), ++ b"com.example.app" ++ ); ++ } ++ ++ #[test] ++ fn test_extract_from_safari_no_crossorigin() { ++ // Safari may omit crossOrigin entirely ++ let json = ++ br#"{"type":"webauthn.get","challenge":"xyz","origin":"https://lazorkit.app"}"#; ++ assert_eq!( ++ extract_top_level_string_field(json, b"type").unwrap(), ++ b"webauthn.get" ++ ); ++ assert_eq!( ++ extract_top_level_string_field(json, b"challenge").unwrap(), ++ b"xyz" ++ ); ++ // crossOrigin field doesn't exist → error ++ assert!(extract_top_level_string_field(json, b"crossOrigin").is_err()); ++ } ++ ++ #[test] ++ fn test_extract_rejects_webauthn_create_type() { ++ let json = br#"{"type":"webauthn.create","challenge":"abc"}"#; ++ let type_val = extract_top_level_string_field(json, b"type").unwrap(); ++ assert_ne!(type_val, b"webauthn.get"); ++ assert_eq!(type_val, b"webauthn.create"); ++ } ++} +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] +diff --git a/program/src/error.rs b/program/src/error.rs +index f555307..c3b483b 100644 +--- a/program/src/error.rs ++++ b/program/src/error.rs +@@ -22,6 +22,21 @@ 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, ++ // 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/instruction.rs b/program/src/instruction.rs +index 21742cc..0c970f0 100644 +--- a/program/src/instruction.rs ++++ b/program/src/instruction.rs +@@ -26,13 +26,13 @@ pub enum ProgramIx { + }, + + /// Add a new authority to the wallet +- #[account(0, signer, name = "payer", desc = "Transaction payer")] ++ #[account(0, signer, writable, name = "payer", desc = "Payer and rent contributor")] + #[account(1, name = "wallet", desc = "Wallet PDA")] + #[account( + 2, +- signer, ++ writable, + name = "admin_authority", +- desc = "Admin authority PDA authorizing this action" ++ desc = "Admin authority PDA authorizing this action (counter incremented)" + )] + #[account( + 3, +@@ -57,13 +57,13 @@ pub enum ProgramIx { + }, + + /// Remove an authority from the wallet +- #[account(0, signer, name = "payer", desc = "Transaction payer")] ++ #[account(0, signer, writable, name = "payer", desc = "Transaction payer")] + #[account(1, name = "wallet", desc = "Wallet PDA")] + #[account( + 2, +- signer, ++ writable, + name = "admin_authority", +- desc = "Admin authority PDA authorizing this action" ++ desc = "Admin authority PDA authorizing this action (counter incremented)" + )] + #[account( + 3, +@@ -86,7 +86,7 @@ pub enum ProgramIx { + RemoveAuthority, + + /// Transfer ownership (atomic swap of Owner role) +- #[account(0, signer, name = "payer", desc = "Transaction payer")] ++ #[account(0, signer, writable, name = "payer", desc = "Payer and rent contributor")] + #[account(1, name = "wallet", desc = "Wallet PDA")] + #[account( + 2, +@@ -100,10 +100,16 @@ pub enum ProgramIx { + name = "new_owner_authority", + desc = "New owner authority PDA to be created" + )] +- #[account(4, name = "system_program", desc = "System Program")] +- #[account(5, name = "rent_sysvar", desc = "Rent Sysvar")] + #[account( +- 6, ++ 4, ++ writable, ++ name = "refund_destination", ++ desc = "Account to receive rent refund from closed current owner" ++ )] ++ #[account(5, name = "system_program", desc = "System Program")] ++ #[account(6, name = "rent_sysvar", desc = "Rent Sysvar")] ++ #[account( ++ 7, + signer, + optional, + name = "authorizer_signer", +@@ -116,14 +122,15 @@ pub enum ProgramIx { + }, + + /// Execute transactions +- #[account(0, signer, name = "payer", desc = "Transaction payer")] ++ #[account(0, signer, writable, name = "payer", desc = "Transaction payer")] + #[account(1, name = "wallet", desc = "Wallet PDA")] + #[account( + 2, ++ writable, + name = "authority", +- desc = "Authority or Session PDA authorizing execution" ++ desc = "Authority or Session PDA authorizing execution (counter incremented)" + )] +- #[account(3, name = "vault", desc = "Vault PDA")] ++ #[account(3, writable, name = "vault", desc = "Vault PDA (signer for CPI, lamports debited)")] + #[account( + 4, + optional, +@@ -142,9 +149,9 @@ pub enum ProgramIx { + #[account(1, name = "wallet", desc = "Wallet PDA")] + #[account( + 2, +- signer, ++ writable, + name = "admin_authority", +- desc = "Admin/Owner authority PDA authorizing logic" ++ desc = "Admin/Owner authority PDA authorizing logic (counter incremented)" + )] + #[account(3, writable, name = "session", desc = "New session PDA to be created")] + #[account(4, name = "system_program", desc = "System Program")] +@@ -202,13 +209,7 @@ pub enum ProgramIx { + /// + /// Verifies compact instructions against stored hashes, executes via CPI + /// with vault PDA signing, then closes the DeferredExec account. +- #[account( +- 0, +- signer, +- writable, +- name = "payer", +- desc = "Transaction payer" +- )] ++ #[account(0, signer, writable, name = "payer", desc = "Transaction payer")] + #[account(1, name = "wallet", desc = "Wallet PDA")] + #[account(2, writable, name = "vault", desc = "Vault PDA (signer for CPI)")] + #[account( +@@ -223,9 +224,7 @@ pub enum ProgramIx { + name = "refund_destination", + desc = "Account to receive rent refund from closed DeferredExec" + )] +- ExecuteDeferred { +- instructions: Vec, +- }, ++ ExecuteDeferred { instructions: Vec }, + + /// Reclaim an expired DeferredExec account and refund rent + /// +@@ -253,29 +252,15 @@ pub enum ProgramIx { + /// Revoke a session key early (before expiry) + /// + /// Only Owner or Admin can revoke. Closes the session account and refunds rent. +- #[account( +- 0, +- signer, +- name = "payer", +- desc = "Transaction payer" +- )] +- #[account( +- 1, +- name = "wallet", +- desc = "Wallet PDA" +- )] ++ #[account(0, signer, writable, name = "payer", desc = "Transaction payer")] ++ #[account(1, name = "wallet", desc = "Wallet PDA")] + #[account( + 2, + writable, + name = "admin_authority", + desc = "Owner/Admin authority PDA (counter incremented for Secp256r1)" + )] +- #[account( +- 3, +- writable, +- name = "session", +- desc = "Session PDA to revoke" +- )] ++ #[account(3, writable, name = "session", desc = "Session PDA to revoke")] + #[account( + 4, + writable, +@@ -341,7 +326,9 @@ pub enum LazorKitInstruction { + /// 2. `[]` Wallet PDA + /// 3. `[writable]` Current Owner Authority PDA + /// 4. `[writable]` New Owner Authority PDA +- /// 5. `[]` System Program ++ /// 5. `[writable]` Refund Destination ++ /// 6. `[]` System Program ++ /// 7. `[]` Rent Sysvar + TransferOwnership { + new_type: u8, + new_pubkey: [u8; 33], +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; +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/processor/create_wallet.rs b/program/src/processor/create_wallet.rs +index e16b42e..1d70f5d 100644 +--- a/program/src/processor/create_wallet.rs ++++ b/program/src/processor/create_wallet.rs +@@ -79,10 +79,13 @@ pub fn process( + + let (id_seed, full_auth_data) = match args.authority_type { + 0 => { +- if rest.len() != 32 { ++ // Use minimum-length check (consistent with AddAuthority / TransferOwnership). ++ // Exact-length check would reject clients that append trailing context bytes. ++ if rest.len() < 32 { + return Err(ProgramError::InvalidInstructionData); + } +- (rest, rest) ++ let (pubkey, _) = rest.split_at(32); ++ (pubkey, pubkey) + }, + 1 => { + // [credential_id_hash(32)] [pubkey(33)] [rpIdLen(1)] [rpId(N)] +@@ -91,6 +94,12 @@ pub fn process( + } + let (credential_id_hash, rest_after_cred) = rest.split_at(32); + let rp_id_len = rest_after_cred[33] as usize; ++ // Enforce a sane upper bound: max valid domain name is 253 chars. ++ // Without this an attacker-controlled payer could create a 369-byte ++ // authority account with 255 bytes of arbitrary rpId data. ++ if rp_id_len == 0 || rp_id_len > 253 { ++ return Err(ProgramError::InvalidInstructionData); ++ } + let total_auth_data = 32 + 33 + 1 + rp_id_len; + if rest.len() < total_auth_data { + return Err(ProgramError::InvalidInstructionData); +@@ -188,11 +197,18 @@ pub fn process( + } + + // --- 2. Initialize Authority Account --- +- // Authority accounts have a variable size depending on the authority type (e.g., Secp256r1 keys are larger). ++ // Fixed sizes per auth type: ++ // Ed25519 = header(48) + pubkey(32) = 80 bytes ++ // Secp256r1 = header(48) + cred_hash(32) + pubkey(33) + rpIdHash(32) = 145 bytes ++ // ++ // For Secp256r1 we hash rpId once at creation and store the digest, so ++ // every subsequent Execute saves one sol_sha256 syscall. + let header_size = std::mem::size_of::(); +- let variable_size = full_auth_data.len(); +- +- let auth_space = header_size + variable_size; ++ let auth_space = match args.authority_type { ++ 0 => header_size + 32, // Ed25519 ++ 1 => header_size + 32 + 33 + 32, // Secp256r1 fixed ++ _ => return Err(AuthError::InvalidAuthenticationKind.into()), ++ }; + let auth_rent = rent.minimum_balance(auth_space); + + // Use secure transfer-allocate-assign pattern to prevent DoS (Issue #4) +@@ -228,18 +244,50 @@ pub fn process( + wallet: *wallet_pda.key(), + }; + +- // safe write ++ // safe write of header + let header_bytes = unsafe { + std::slice::from_raw_parts( + &header as *const AuthorityAccountHeader as *const u8, +- std::mem::size_of::(), ++ header_size, + ) + }; +- auth_account_data[0..std::mem::size_of::()] +- .copy_from_slice(header_bytes); ++ auth_account_data[0..header_size].copy_from_slice(header_bytes); + +- let variable_target = &mut auth_account_data[header_size..]; +- variable_target.copy_from_slice(full_auth_data); ++ // Write variable data ++ match args.authority_type { ++ 0 => { ++ // Ed25519: pubkey(32) — full_auth_data is exactly 32 bytes ++ auth_account_data[header_size..header_size + 32] ++ .copy_from_slice(&full_auth_data[..32]); ++ } ++ 1 => { ++ // Secp256r1: cred_hash(32) ∥ pubkey(33) ∥ rpIdHash(32). ++ // full_auth_data layout as parsed above: ++ // [cred_hash(32)] [pubkey(33)] [rpIdLen(1)] [rpId(N)] ++ auth_account_data[header_size..header_size + 32] ++ .copy_from_slice(&full_auth_data[..32]); ++ auth_account_data[header_size + 32..header_size + 32 + 33] ++ .copy_from_slice(&full_auth_data[32..32 + 33]); ++ // Compute rpIdHash from rpId ++ let rp_id_len = full_auth_data[32 + 33] as usize; ++ let rp_id = &full_auth_data[32 + 33 + 1..32 + 33 + 1 + rp_id_len]; ++ let rp_id_hash_offset = header_size + 32 + 33; ++ #[cfg(target_os = "solana")] ++ unsafe { ++ let _ = pinocchio::syscalls::sol_sha256( ++ [rp_id].as_ptr() as *const u8, ++ 1, ++ auth_account_data[rp_id_hash_offset..rp_id_hash_offset + 32].as_mut_ptr(), ++ ); ++ } ++ #[cfg(not(target_os = "solana"))] ++ { ++ let _ = rp_id; ++ auth_account_data[rp_id_hash_offset..rp_id_hash_offset + 32].fill(0); ++ } ++ } ++ _ => unreachable!(), ++ } + + Ok(()) + } +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/execute_deferred.rs b/program/src/processor/execute_deferred.rs +index cc24c27..fafe0b4 100644 +--- a/program/src/processor/execute_deferred.rs ++++ b/program/src/processor/execute_deferred.rs +@@ -1,5 +1,5 @@ + use crate::{ +- compact::parse_compact_instructions, ++ compact::{parse_compact_instructions_ref_with_len, CompactInstructionRef}, + error::AuthError, + state::{deferred::DeferredExecAccount, AccountDiscriminator}, + }; +@@ -99,14 +99,14 @@ pub fn process( + return Err(AuthError::DeferredAuthorizationExpired.into()); + } + +- // Parse compact instructions +- let compact_instructions = parse_compact_instructions(instruction_data)?; ++ // Parse compact instructions and track consumed length. We hash the ++ // raw instruction_data[..consumed] directly — the parse/encode format ++ // is byte-identical, so there's no need to re-serialize. ++ let (compact_instructions, compact_len) = ++ parse_compact_instructions_ref_with_len(instruction_data)?; + +- // Serialize compact instructions to compute hash +- let compact_bytes = crate::compact::serialize_compact_instructions(&compact_instructions); +- +- // Verify instructions hash +- let instructions_hash = compute_sha256(&compact_bytes); ++ // Verify instructions hash against the exact bytes we parsed from ++ let instructions_hash = compute_sha256(&instruction_data[..compact_len]); + if instructions_hash != deferred.instructions_hash { + return Err(AuthError::DeferredHashMismatch.into()); + } +@@ -140,46 +140,47 @@ pub fn process( + let close_data = unsafe { deferred_pda.borrow_mut_data_unchecked() }; + close_data.fill(0); + ++ // Reuse Vecs across inner CPI iterations — allocated once, cleared + ++ // repushed each iteration. Same optimisation as execute::immediate. ++ 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); ++ ++ 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 via CPI with vault PDA signing + for compact_ix in &compact_instructions { + let decompressed = compact_ix.decompress(accounts)?; + +- // Build AccountMeta array +- 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 + if decompressed.program_id.as_ref() == program_id.as_ref() { + return Err(AuthError::SelfReentrancyNotAllowed.into()); + } + ++ 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, + }; + +- 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(); + +- let cpi_accounts: Vec = decompressed +- .accounts +- .iter() +- .map(|acc| Account::from(*acc)) +- .collect(); +- + unsafe { + invoke_signed_unchecked(&ix, &cpi_accounts, &[signer]); + } +@@ -209,26 +210,26 @@ fn compute_sha256(data: &[u8]) -> [u8; 32] { + } + + /// Compute SHA256 hash of all account pubkeys referenced by compact instructions. +-/// Same logic as execute.rs::compute_accounts_hash. ++/// Matches execute::immediate::compute_accounts_hash. + fn compute_accounts_hash( + accounts: &[AccountInfo], +- compact_instructions: &[crate::compact::CompactInstruction], ++ compact_instructions: &[CompactInstructionRef<'_>], + ) -> Result<[u8; 32], ProgramError> { +- let mut pubkeys_data = Vec::new(); ++ let mut refs: Vec<&[u8]> = Vec::with_capacity(compact_instructions.len() * 4); + + for ix in compact_instructions { + 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()); + +- 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()); + } + } + +@@ -237,15 +238,15 @@ fn compute_accounts_hash( + #[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"))] + { + hash = [0xAA; 32]; +- let _ = pubkeys_data; ++ let _ = refs; + } + + Ok(hash) +diff --git a/program/src/processor/manage_authority.rs b/program/src/processor/manage_authority.rs +index 34d6073..1e165b7 100644 +--- a/program/src/processor/manage_authority.rs ++++ b/program/src/processor/manage_authority.rs +@@ -91,6 +91,9 @@ pub fn process_add_authority( + } + let (credential_id_hash, rest_after_cred) = rest.split_at(32); + let rp_id_len = rest_after_cred[33] as usize; ++ if rp_id_len == 0 || rp_id_len > 253 { ++ return Err(ProgramError::InvalidInstructionData); ++ } + let total_auth_data = 32 + 33 + 1 + rp_id_len; + if rest.len() < total_auth_data { + return Err(ProgramError::InvalidInstructionData); +@@ -198,6 +201,12 @@ pub fn process_add_authority( + } + + // Authorization ++ // Validate new_role is a known value (0=Owner, 1=Admin, 2=Spender). ++ // Without this check an Owner could create a role-255 authority that can ++ // execute but cannot be revoked by any Admin. ++ if args.new_role > 2 { ++ return Err(AuthError::PermissionDenied.into()); ++ } + if admin_header.role != 0 && (admin_header.role != 1 || args.new_role != 2) { + return Err(AuthError::PermissionDenied.into()); + } +@@ -212,9 +221,13 @@ pub fn process_add_authority( + } + check_zero_data(new_auth_pda, ProgramError::AccountAlreadyInitialized)?; + ++ // Fixed sizes per auth type (see wallet/create.rs for layout). + let header_size = std::mem::size_of::(); +- let variable_size = full_auth_data.len(); +- let space = header_size + variable_size; ++ let space = match args.authority_type { ++ 0 => header_size + 32, // Ed25519: pubkey ++ 1 => header_size + 32 + 33 + 32, // Secp256r1: cred ∥ pubkey ∥ rpIdHash ++ _ => return Err(AuthError::InvalidAuthenticationKind.into()), ++ }; + let rent_lamports = rent.minimum_balance(space); + + // Use secure transfer-allocate-assign pattern to prevent DoS (Issue #4) +@@ -252,8 +265,35 @@ pub fn process_add_authority( + *(data.as_mut_ptr() as *mut AuthorityAccountHeader) = header; + } + +- let variable_target = &mut data[header_size..]; +- variable_target.copy_from_slice(full_auth_data); ++ // Write variable data. For Secp256r1 hash rpId once here so every Execute ++ // saves a sol_sha256 syscall. ++ match args.authority_type { ++ 0 => { ++ data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]); ++ } ++ 1 => { ++ data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]); ++ data[header_size + 32..header_size + 32 + 33] ++ .copy_from_slice(&full_auth_data[32..32 + 33]); ++ let rp_id_len = full_auth_data[32 + 33] as usize; ++ let rp_id = &full_auth_data[32 + 33 + 1..32 + 33 + 1 + rp_id_len]; ++ let rp_id_hash_offset = header_size + 32 + 33; ++ #[cfg(target_os = "solana")] ++ unsafe { ++ let _ = pinocchio::syscalls::sol_sha256( ++ [rp_id].as_ptr() as *const u8, ++ 1, ++ data[rp_id_hash_offset..rp_id_hash_offset + 32].as_mut_ptr(), ++ ); ++ } ++ #[cfg(not(target_os = "solana"))] ++ { ++ let _ = rp_id; ++ data[rp_id_hash_offset..rp_id_hash_offset + 32].fill(0); ++ } ++ } ++ _ => unreachable!(), ++ } + + Ok(()) + } +@@ -398,6 +438,12 @@ pub fn process_remove_authority( + } + } + ++ // Guard: if target == refund_dest the double-write would burn lamports and ++ // trigger a Solana lamport conservation error, aborting after doing work. ++ if target_auth_pda.key() == refund_dest.key() { ++ return Err(ProgramError::InvalidAccountData); ++ } ++ + let target_lamports = unsafe { *target_auth_pda.borrow_mut_lamports_unchecked() }; + let refund_lamports = unsafe { *refund_dest.borrow_mut_lamports_unchecked() }; + unsafe { +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; +diff --git a/program/src/processor/revoke_session.rs b/program/src/processor/revoke_session.rs +index dae1b39..aeada67 100644 +--- a/program/src/processor/revoke_session.rs ++++ b/program/src/processor/revoke_session.rs +@@ -136,6 +136,11 @@ pub fn process( + return Err(ProgramError::InvalidAccountData); + } + ++ // Guard: session_pda == refund_dest would burn lamports. ++ if session_pda.key() == refund_dest.key() { ++ return Err(ProgramError::InvalidAccountData); ++ } ++ + // Close the session account — zero data and drain lamports + session_data.fill(0); + +diff --git a/program/src/processor/transfer_ownership.rs b/program/src/processor/transfer_ownership.rs +index 51dc8ad..0800f84 100644 +--- a/program/src/processor/transfer_ownership.rs ++++ b/program/src/processor/transfer_ownership.rs +@@ -27,14 +27,16 @@ use crate::{ + /// 2. **Authorization**: strictly enforced to only work if `current_owner` has `Role::Owner` (0). + /// 3. **Atomic Swap**: + /// - Creates the `new_owner` account. +-/// - Closes the `current_owner` account and refunds rent to payer. ++/// - Closes the `current_owner` account and refunds rent to `refund_dest`. + /// + /// # Accounts: + /// 1. `[signer, writable]` Payer. + /// 2. `[]` Wallet PDA. + /// 3. `[signer, writable]` Current Owner Authority. + /// 4. `[writable]` New Owner Authority. +-/// 5. `[]` System Program. ++/// 5. `[writable]` Refund Destination (receives closed current_owner rent). ++/// 6. `[]` System Program. ++/// 7. `[]` Rent Sysvar. + /// + /// Arguments for the `TransferOwnership` instruction. + /// +@@ -80,6 +82,9 @@ pub fn process( + } + let (hash, rest_after_hash) = rest.split_at(32); + let rp_id_len = rest_after_hash[33] as usize; ++ if rp_id_len == 0 || rp_id_len > 253 { ++ return Err(ProgramError::InvalidInstructionData); ++ } + let total_auth_data = 32 + 33 + 1 + rp_id_len; + if rest.len() < total_auth_data { + return Err(ProgramError::InvalidInstructionData); +@@ -115,6 +120,9 @@ pub fn process( + let new_owner = account_info_iter + .next() + .ok_or(ProgramError::NotEnoughAccountKeys)?; ++ let refund_dest = account_info_iter ++ .next() ++ .ok_or(ProgramError::NotEnoughAccountKeys)?; + let system_program = account_info_iter + .next() + .ok_or(ProgramError::NotEnoughAccountKeys)?; +@@ -126,6 +134,11 @@ pub fn process( + if wallet_pda.owner() != program_id || current_owner.owner() != program_id { + return Err(ProgramError::IllegalOwner); + } ++ ++ // Guard: closing current_owner to itself would burn lamports. ++ if current_owner.key() == refund_dest.key() { ++ return Err(ProgramError::InvalidAccountData); ++ } + // Validate Wallet Discriminator (Issue #7) + let wallet_data = unsafe { wallet_pda.borrow_data_unchecked() }; + if wallet_data.is_empty() || wallet_data[0] != AccountDiscriminator::Wallet as u8 { +@@ -155,15 +168,16 @@ pub fn process( + return Err(AuthError::PermissionDenied.into()); + } + +- // Authenticate Current Owner +- // Issue: Include payer + new_owner to prevent rent theft via payer swap +- let mut ed25519_payload = Vec::with_capacity(64); ++ // Authenticate Current Owner. ++ // Sign over payer + new_owner + refund_dest to prevent substitution attacks. ++ let mut ed25519_payload = Vec::with_capacity(96); + ed25519_payload.extend_from_slice(payer.key().as_ref()); + ed25519_payload.extend_from_slice(new_owner.key().as_ref()); ++ ed25519_payload.extend_from_slice(refund_dest.key().as_ref()); + + match auth.authority_type { + 0 => { +- // Ed25519: Include payer + new_owner in signed payload ++ // Ed25519: sign over payer + new_owner + refund_dest + Ed25519Authenticator.authenticate(accounts, data, &[], &ed25519_payload, &[3], program_id)?; + }, + 1 => { +@@ -171,10 +185,11 @@ pub fn process( + if !current_owner.is_writable() { + return Err(ProgramError::InvalidAccountData); + } +- // Secp256r1: Include payer in signed payload to prevent rent theft +- let mut extended_data_payload = Vec::with_capacity(data_payload.len() + 32); ++ // Sign over data_payload + payer + refund_dest ++ let mut extended_data_payload = Vec::with_capacity(data_payload.len() + 64); + extended_data_payload.extend_from_slice(data_payload); + extended_data_payload.extend_from_slice(payer.key().as_ref()); ++ extended_data_payload.extend_from_slice(refund_dest.key().as_ref()); + + Secp256r1Authenticator.authenticate( + accounts, +@@ -198,9 +213,13 @@ pub fn process( + } + check_zero_data(new_owner, ProgramError::AccountAlreadyInitialized)?; + ++ // Fixed sizes per auth type (see wallet/create.rs for layout). + let header_size = std::mem::size_of::(); +- let variable_size = full_auth_data.len(); +- let space = header_size + variable_size; ++ let space = match args.auth_type { ++ 0 => header_size + 32, // Ed25519: pubkey ++ 1 => header_size + 32 + 33 + 32, // Secp256r1: cred ∥ pubkey ∥ rpIdHash ++ _ => return Err(AuthError::InvalidAuthenticationKind.into()), ++ }; + let rent = rent_obj.minimum_balance(space); + + // Use secure transfer-allocate-assign pattern to prevent DoS (Issue #4) +@@ -241,13 +260,40 @@ pub fn process( + std::ptr::write_unaligned(data.as_mut_ptr() as *mut AuthorityAccountHeader, header); + } + +- let variable_target = &mut data[header_size..]; +- variable_target.copy_from_slice(full_auth_data); ++ // Write variable data. For Secp256r1 hash rpId once here so every Execute ++ // saves a sol_sha256 syscall. ++ match args.auth_type { ++ 0 => { ++ data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]); ++ } ++ 1 => { ++ data[header_size..header_size + 32].copy_from_slice(&full_auth_data[..32]); ++ data[header_size + 32..header_size + 32 + 33] ++ .copy_from_slice(&full_auth_data[32..32 + 33]); ++ let rp_id_len = full_auth_data[32 + 33] as usize; ++ let rp_id = &full_auth_data[32 + 33 + 1..32 + 33 + 1 + rp_id_len]; ++ let rp_id_hash_offset = header_size + 32 + 33; ++ #[cfg(target_os = "solana")] ++ unsafe { ++ let _ = pinocchio::syscalls::sol_sha256( ++ [rp_id].as_ptr() as *const u8, ++ 1, ++ data[rp_id_hash_offset..rp_id_hash_offset + 32].as_mut_ptr(), ++ ); ++ } ++ #[cfg(not(target_os = "solana"))] ++ { ++ let _ = rp_id; ++ data[rp_id_hash_offset..rp_id_hash_offset + 32].fill(0); ++ } ++ } ++ _ => unreachable!(), ++ } + + let current_lamports = unsafe { *current_owner.borrow_mut_lamports_unchecked() }; +- let payer_lamports = unsafe { *payer.borrow_mut_lamports_unchecked() }; ++ let refund_lamports = unsafe { *refund_dest.borrow_mut_lamports_unchecked() }; + unsafe { +- *payer.borrow_mut_lamports_unchecked() = payer_lamports ++ *refund_dest.borrow_mut_lamports_unchecked() = refund_lamports + .checked_add(current_lamports) + .ok_or(ProgramError::ArithmeticOverflow)?; + *current_owner.borrow_mut_lamports_unchecked() = 0; +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; +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 { ++ &[] ++ } ++} diff --git a/docs/audit/program-src.diff.stat b/docs/audit/program-src.diff.stat new file mode 100644 index 0000000..ccb2184 --- /dev/null +++ b/docs/audit/program-src.diff.stat @@ -0,0 +1,23 @@ + program/Cargo.toml | 10 + + program/idl.json | 48 +- + program/src/auth/secp256r1/introspection.rs | 256 ++++- + program/src/auth/secp256r1/mod.rs | 214 ++-- + program/src/auth/secp256r1/webauthn.rs | 623 ++++++++-- + program/src/compact.rs | 152 ++- + program/src/error.rs | 15 + + program/src/instruction.rs | 73 +- + program/src/lib.rs | 17 + + program/src/processor/create_session.rs | 278 ++++- + program/src/processor/create_wallet.rs | 72 +- + program/src/processor/execute.rs | 272 +++-- + program/src/processor/execute_actions.rs | 1644 +++++++++++++++++++++++++++ + program/src/processor/execute_deferred.rs | 83 +- + program/src/processor/manage_authority.rs | 54 +- + program/src/processor/mod.rs | 1 + + program/src/processor/revoke_session.rs | 5 + + program/src/processor/transfer_ownership.rs | 74 +- + program/src/state/action.rs | 697 ++++++++++++ + program/src/state/mod.rs | 1 + + program/src/state/session.rs | 23 +- + 21 files changed, 4168 insertions(+), 444 deletions(-) + 21 files changed, 4168 insertions(+), 444 deletions(-) diff --git a/docs/audit/upstream-parity.txt b/docs/audit/upstream-parity.txt new file mode 100644 index 0000000..cfd7d00 --- /dev/null +++ b/docs/audit/upstream-parity.txt @@ -0,0 +1,26 @@ +Generating byte-identity report vs upstream lazorkit-protocol... + +For each file changed in program-v2 between audit-baseline-2026-02-accretion +and audit-pending-v1, indicate whether the post-state is byte-identical to +the equivalent file in lazorkit-protocol (which Accretion has audited as the +shared upstream). Identical → audit can defer to existing review. + + ✓ identical: program/src/auth/secp256r1/mod.rs + ✓ identical: program/src/auth/secp256r1/webauthn.rs + ✓ identical: program/src/auth/secp256r1/introspection.rs + ✓ identical: program/src/compact.rs + ⚠ differs (14 diff lines): program/src/lib.rs vs program/src/lib.rs + ⚠ differs (23 diff lines): program/src/error.rs vs program/src/error.rs + ⚠ differs (68 diff lines): program/src/instruction.rs vs program/src/instruction.rs + ✓ identical: program/src/state/action.rs + ✓ identical: program/src/state/session.rs + ⚠ differs (12 diff lines): program/src/state/mod.rs vs program/src/state/mod.rs + ⚠ differs (26 diff lines): program/src/processor/mod.rs vs program/src/processor/mod.rs + ✓ identical: program/src/processor/create_session.rs + ✓ identical: program/src/processor/create_wallet.rs + ⚠ differs (4 diff lines): program/src/processor/execute.rs vs program/src/processor/execute/immediate.rs + ✓ identical: program/src/processor/execute_actions.rs + ✓ identical: program/src/processor/execute_deferred.rs + ✓ identical: program/src/processor/manage_authority.rs + ✓ identical: program/src/processor/revoke_session.rs + ✓ identical: program/src/processor/transfer_ownership.rs From 770ad2d51f66e04f4a85f3e1665a844269012c03 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Wed, 6 May 2026 18:45:53 +0700 Subject: [PATCH 23/25] docs(changelog): record P5 (auth + processor port) and P6 (audit prep) entries --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 174077c..68008c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- End-to-end vitest tests for session-action enforcement (`tests-sdk/tests/12-actions.test.ts`, 9 cases): `programWhitelist` allow + reject (3021), `programBlacklist` allow + reject (3022), `solMaxPerTx` allow at-cap + reject over-cap (3023), `solLimit` lifetime budget exhaustion (3024), and combined-rules enforcement. Runs against a live `solana-test-validator` with the foundation binary loaded and uses `@lazorkit/sdk-legacy`'s `Actions` builder to dogfood the full encode → on-chain enforce path. +- `docs/audit/` artifacts for an Accretion delta-audit follow-up: `DELTA_BRIEF.md` summarises the changes from the previous audited baseline by phase with explicit audit asks; `program-src.diff` is the full unified diff of `program/`; `program-src.diff.stat` is a per-file changed-line summary; `upstream-parity.txt` reports byte-identity vs the already-audited `lazorkit-protocol` per file (13/19 changed files identical). +- Local git tags `audit-baseline-2026-02-accretion` (previous Accretion-audited state, commit `d1eaaeb`) and `audit-pending-v1` (the current consolidated state ready for delta review). - 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. @@ -67,6 +70,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- Secp256r1 auth payload format: replaces the older `typeAndFlags` byte at `auth_payload[13]` with full raw `clientDataJSON` embedded in the payload. The on-chain auth verifier now parses the JSON directly rather than reconstructing it from `typeAndFlags + rpId`. Aligns with `lazorkit-protocol` byte-for-byte and is required for binary-swap compatibility at the shared mainnet slot. +- Secp256r1 authority on-chain layout: replaces the previously stored variable-length raw `rpId` with a precomputed 32-byte `rpIdHash` (SHA-256 digest computed at registration). New layout: `header(48) + cred_hash(32) + pubkey(33) + rpIdHash(32) = 145 bytes`. Saves one `sol_sha256` syscall per `Execute`. Existing wallets created on the upstream commercial binary remain readable after binary swap. +- Shank IDL declarations on the `ProgramIx` enum (account metadata: `writable` modifiers, account positions, descriptions) resynced with `lazorkit-protocol`. Five fee-related variants (disc 10–14: `InitializeProtocol`, `UpdateProtocol`, `RegisterPayer`, `WithdrawTreasury`, `InitializeTreasuryShard`) stripped — `program-v2` keeps disc 0–9 only. Runtime not affected (`@lazorkit/sdk-legacy` uses hand-written builders rather than the generated IDL). - SDK API: unified all methods via discriminated unions (breaking: removed `createWalletEd25519`, `createWalletSecp256r1`, `addAuthoritySecp256r1`, `removeAuthoritySecp256r1`, `executeEd25519`, `executeSecp256r1`, `executeSession`, `createSessionSecp256r1`, `transferOwnershipSecp256r1`, `authorizeSecp256r1`) - SDK API: all methods now return `{ instructions: TransactionInstruction[]; ...extraPdas }` consistently - SDK API: `createSession` now takes `sessionKey: PublicKey` instead of `Uint8Array` @@ -87,6 +93,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- `tests-sdk` integration tests now pass `PROGRAM_ID` explicitly to the `LazorKitClient` constructor. `@lazorkit/sdk-legacy`'s URL-based program-ID inference defaulted localhost to the commercial devnet ID (`4h3X…`); against a local validator loading the foundation binary at the keypair's pubkey this caused all txs to fail with "Attempt to load a program that does not exist". `tests/common.ts` now resolves `PROGRAM_ID` from (1) `PROGRAM_ID` env override, (2) the keypair file at `target/deploy/lazorkit_program-keypair.json`, or (3) the foundation devnet fallback `FLb7…`. +- `tests-sdk/tests/08-deferred.test.ts` builds the `Authorize` `signed_payload` as `instructions_hash || accounts_hash || expiry_offset (u16 LE)` to match what the on-chain verifier hashes. The test code was missing the 2-byte expiry buffer at all 6 sign sites, causing all 7 deferred tests to fail with `InvalidMessageHash` (3005). After the fix, all 65 vitest E2E tests pass against a live validator. - Authorize signed payload now includes `expiry_offset` (66 bytes total), preventing relayers from modifying the expiry window - `sol_assert_bytes_eq` now uses the `len` parameter instead of `left.len()` (latent OOB read on-chain) - `reclaim_deferred` uses `checked_add` for lamports (consistent with `execute_deferred` and `manage_authority`) From 1abb98ccc1292bdfb5e670b5f0f9c53d35d606bb Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Wed, 6 May 2026 18:50:26 +0700 Subject: [PATCH 24/25] chore: remove Solana Foundation references from content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cite the audit firm as 'Accretion' / 'Accretion Labs' only — README, SECURITY policy, on-chain security_txt, and the audit delta brief. Audit PDF filename retained (already an immutable artifact). --- README.md | 2 +- SECURITY.md | 2 +- docs/audit/DELTA_BRIEF.md | 2 +- docs/audit/program-src.diff | 4 ++-- program/src/lib.rs | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c9f9219..406abbb 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ See [DEVELOPMENT.md](DEVELOPMENT.md) for full development workflow. ## Security -LazorKit V2 has been audited by **Accretion** (Solana Foundation funded). +LazorKit V2 has been audited by **Accretion**. **Status**: 17/17 security issues resolved diff --git a/SECURITY.md b/SECURITY.md index e672c44..0fc1e5b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,7 +31,7 @@ The following are out of scope: ## Audit Status -LazorKit V2 has been audited by Accretion (Solana Foundation funded). +LazorKit V2 has been audited by Accretion. **Status**: 17/17 security issues resolved diff --git a/docs/audit/DELTA_BRIEF.md b/docs/audit/DELTA_BRIEF.md index d568176..5ee0e52 100644 --- a/docs/audit/DELTA_BRIEF.md +++ b/docs/audit/DELTA_BRIEF.md @@ -1,7 +1,7 @@ # Accretion Audit Delta Brief — `program-v2` `audit-baseline-2026-02-accretion` → `audit-pending-v1` **Repository:** `lazor-kit/program-v2` -**Previous audit:** Accretion Labs, Solana Foundation, February 2026, A26SFR1 +**Previous audit:** Accretion Labs, February 2026, A26SFR1 ([audits/2026-accretion-solana-foundation-lazorkit-audit-A26SFR1.pdf](../../audits/2026-accretion-solana-foundation-lazorkit-audit-A26SFR1.pdf)) **Previous baseline tag:** `audit-baseline-2026-02-accretion` → commit `d1eaaeb` (Merge PR #49 fix/audit-hardening, 17/17 findings resolved) **Delta tag:** `audit-pending-v1` → commit `9c97fe2` diff --git a/docs/audit/program-src.diff b/docs/audit/program-src.diff index 4eef8f5..2be1ef1 100644 --- a/docs/audit/program-src.diff +++ b/docs/audit/program-src.diff @@ -1850,7 +1850,7 @@ index 21742cc..0c970f0 100644 new_type: u8, new_pubkey: [u8; 33], diff --git a/program/src/lib.rs b/program/src/lib.rs -index aad119c..2a41038 100644 +index aad119c..0017e02 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -1,5 +1,22 @@ @@ -1870,7 +1870,7 @@ index aad119c..2a41038 100644 + 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" ++ auditors: "Accretion Labs — https://github.com/lazor-kit/program-v2/blob/main/audits/2026-accretion-solana-foundation-lazorkit-audit-A26SFR1.pdf" +} + pub mod auth; diff --git a/program/src/lib.rs b/program/src/lib.rs index 2a41038..0017e02 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -14,7 +14,7 @@ security_txt! { 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" + auditors: "Accretion Labs — https://github.com/lazor-kit/program-v2/blob/main/audits/2026-accretion-solana-foundation-lazorkit-audit-A26SFR1.pdf" } pub mod auth; From e00778872687558a095e29bf0dc4bb82c994e9f7 Mon Sep 17 00:00:00 2001 From: onspeedhp Date: Wed, 6 May 2026 21:48:04 +0700 Subject: [PATCH 25/25] docs: refresh markdown to match current codebase + dual-cluster program IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates README, DEVELOPMENT, Architecture, and Costs docs: - README: explicit dual-cluster program ID table (mainnet LazorjRF…, devnet FLb7…), slot-share note pointing at lazorkit-protocol; build commands now show --features mainnet/devnet; test count 56 → 65; instruction count 9 → 10; authority size 125 → 145 bytes (rpIdHash); session description expanded to cover action permissions - DEVELOPMENT: test count 56 → 65 in run-tests command - Architecture: SessionAccount section now covers actions buffer + table of all 8 action types; Secp256r1 authority data layout updated to rpIdHash 32 bytes (total 145B); WebAuthn description updated to embedded raw clientDataJSON (no longer 'reconstructs from packed flags'); processor tree updated for execute_actions.rs + revoke_session.rs; AuthError range 3001-3018 → 3001-3032; project structure mentions action.rs + zero-copy compact ref variants; entrypoint mentions disc 0–9 - Costs: program ID line now lists both mainnet and devnet; Secp256r1 authority size updated to 145 bytes / rpIdHash; smoke test exercises 10 instructions --- DEVELOPMENT.md | 2 +- README.md | 37 ++++++++++++++++++++++++++----------- docs/Architecture.md | 43 ++++++++++++++++++++++++++++++++----------- docs/Costs.md | 8 ++++---- 4 files changed, 63 insertions(+), 27 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 6591d89..b73fbb1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -87,7 +87,7 @@ Once published to npm, consumers do `npm install @lazorkit/sdk-legacy`. # Terminal 1: Start local validator with program loaded cd tests-sdk && npm run validator:start -# Terminal 2: Run all 56 tests +# Terminal 2: Run all 65 tests cd tests-sdk && npm test ``` diff --git a/README.md b/README.md index 406abbb..facdbaf 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,15 @@ # LazorKit Smart Wallet (V2) -A high-performance smart wallet program on Solana with passkey (WebAuthn/Secp256r1) authentication, role-based access control, session keys, and replay-safe odometer counters. Built with [pinocchio](https://github.com/febo/pinocchio) for zero-copy serialization. +A high-performance smart wallet program on Solana with passkey (WebAuthn/Secp256r1) authentication, role-based access control, session keys with action permissions, and replay-safe odometer counters. Built with [pinocchio](https://github.com/febo/pinocchio) for zero-copy serialization. -**Program ID**: `FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao` +**Program IDs** (chosen at compile time via `--features mainnet` / `--features devnet`): + +| Cluster | Program ID | +|---|---| +| Mainnet | `LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi` | +| Devnet | `FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao` | + +The mainnet slot is shared with [`lazorkit-protocol`](https://github.com/lazor-kit/lazorkit-protocol) (the commercial sibling build with protocol fees). dApp integrators use the same mainnet program ID for both — `@lazorkit/sdk-legacy` probes the on-chain `ProtocolConfig` PDA on first use and transparently appends fee accounts only when present. See [DEVELOPMENT.md → Mainnet Deploy Strategy](DEVELOPMENT.md#h-mainnet-deploy-strategy-foundation-build). --- @@ -10,7 +17,7 @@ A high-performance smart wallet program on Solana with passkey (WebAuthn/Secp256 - **Multi-Protocol Authentication**: Ed25519 (native Solana) + Secp256r1 (WebAuthn/Passkeys/Apple Secure Enclave) - **Role-Based Access Control**: Owner / Admin / Spender with strict permission hierarchy -- **Ephemeral Session Keys**: Time-bound keys with absolute slot-based expiry (max 30 days), revocable by Owner/Admin +- **Ephemeral Session Keys with Action Permissions**: Time-bound keys with absolute slot-based expiry (max 30 days), revocable by Owner/Admin. Each session can carry up to 16 immutable permission rules — SOL/token spending caps (lifetime, recurring window, per-tx), per-mint caps, and program whitelists/blacklists for CPI targets. Enforced atomically around each `Execute` with vault-invariant defenses against `System::Assign` / `SetAuthority` / `Approve` escapes. - **Odometer Replay Protection**: Monotonic u32 counter per authority — works reliably with synced passkeys (iCloud, Google) - **Clock-Based Slot Freshness**: 150-slot window via `Clock::get()` — no SlotHashes sysvar needed - **Zero-Copy Serialization**: Raw byte casting via pinocchio, no Borsh overhead @@ -56,8 +63,9 @@ See [docs/Costs.md](docs/Costs.md) for full cost analysis, session key costs, an |---|---|---| | Wallet PDA | 8 bytes | 0.000947 | | Authority (Ed25519) | 80 bytes | 0.001448 | -| Authority (Secp256r1) | ~125 bytes | 0.001761 | -| Session | 80 bytes | 0.001448 | +| Authority (Secp256r1) | 145 bytes | 0.001893 | +| Session (no actions) | 80 bytes | 0.001448 | +| Session (with actions, e.g. 3 rules) | up to 192 bytes | up to 0.002227 | | DeferredExec | 176 bytes | 0.002116 (temporary, refunded) | ### Total Wallet Creation @@ -97,10 +105,11 @@ See [docs/Architecture.md](docs/Architecture.md) for struct definitions, securit ``` program/src/ Rust smart contract (pinocchio, zero-copy) auth/ Ed25519 + Secp256r1/WebAuthn authentication - processor/ 9 instruction handlers + processor/ 10 instruction handlers (disc 0–9) + execute_actions enforcement engine state/ Account data structures (NoPadding) -tests-sdk/ Integration tests (vitest, 56 tests, uses @lazorkit/sdk-legacy) -docs/ Architecture, cost analysis +tests-sdk/ Integration tests (vitest, 65 tests, uses @lazorkit/sdk-legacy) +scripts/ Build helpers + cherry-pick guardrails (strip-fee.sh, check-no-fee.sh) +docs/ Architecture, cost analysis, audit delta brief, deploy runbook audits/ Audit reports ``` @@ -111,9 +120,15 @@ audits/ Audit reports ### Build ```bash -cargo build-sbf +# Devnet build — embeds FLb7… +cargo build-sbf --features devnet + +# Mainnet build — embeds LazorjRF… (slot shared with lazorkit-protocol) +cargo build-sbf --features mainnet ``` +Building with neither, or both, fails with a clear `compile_error!` — Pattern D feature flags prevent accidental cross-cluster deploys. + ### Install SDK ```bash @@ -181,14 +196,14 @@ See the [@lazorkit/sdk-legacy README](https://github.com/lazor-kit/lazorkit-prot # Start local validator with program loaded cd tests-sdk && npm run validator:start -# Run all 56 tests (integration + security + permission + session) +# Run all 65 tests npm test # Run CU benchmarks npm run benchmark ``` -Tests cover: wallet lifecycle, authority management, execute, deferred execution, sessions, replay protection, counter edge cases, end-to-end workflows, permission boundaries, session-based execution, and security attack vectors (reentrancy, cross-wallet isolation, accounts hash binding). +Tests cover: wallet lifecycle, authority management, execute, deferred execution, sessions, session **action enforcement** (program whitelist/blacklist, SOL spending caps), replay protection, counter edge cases, end-to-end workflows, permission boundaries, session-based execution, and security attack vectors (reentrancy, cross-wallet isolation, accounts hash binding). See [DEVELOPMENT.md](DEVELOPMENT.md) for full development workflow. diff --git a/docs/Architecture.md b/docs/Architecture.md index 3dc4b9b..63d675b 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -33,8 +33,9 @@ SHA256(discriminator || auth_payload || signed_payload || slot || payer || count ### WebAuthn Passkey Support -- Reconstructs clientDataJSON on-chain from packed flags. +- Embeds raw `clientDataJSON` directly in the auth payload; the program parses the JSON on-chain (extracts `type` + `challenge` fields, validates `webauthn.get`, base64url-compares challenge against the recomputed digest). - Verifies authenticatorData flags (User Presence / User Verification). +- Verifies `rpIdHash` against the precomputed digest stored on the authority account at registration (saves one `sol_sha256` syscall per Execute). - Uses Secp256r1SigVerify precompile via sysvar introspection. - Stores 33-byte compressed public keys (not 64-byte uncompressed). @@ -90,9 +91,9 @@ pub struct AuthorityAccountHeader { Variable data after header: - **Ed25519**: `[pubkey: [u8; 32]]` -- total 80 bytes. -- **Secp256r1**: `[credential_id_hash: [u8; 32]] [compressed_pubkey: [u8; 33]] [rpIdLen: u8] [rpId: [u8; N]]` -- total 114+ bytes (rpId stored on-chain to avoid per-tx transmission). +- **Secp256r1**: `[credential_id_hash: [u8; 32]] [compressed_pubkey: [u8; 33]] [rpIdHash: [u8; 32]]` -- total 145 bytes. The rpId is hashed once at creation and the digest stored on-chain so every subsequent `Execute` saves one `sol_sha256` syscall. -### C. SessionAccount (80 bytes) +### C. SessionAccount (80-byte fixed header + optional action buffer) Seeds: `["session", wallet_pubkey, session_key]` @@ -107,9 +108,26 @@ pub struct SessionAccount { pub session_key: Pubkey, // 32 bytes pub expires_at: u64, // Absolute slot height } -// Total: 1+1+1+5+32+32+8 = 80 bytes +// Header: 1+1+1+5+32+32+8 = 80 bytes ``` +Optional **actions buffer** appended after the 80-byte header (max 16 actions, ≤ 2048 bytes). Each action: `[type: u8][data_len: u16 LE][expires_at: u64 LE][data: N]`. + +Action types (must match `state/action.rs::ActionType`): + +| Discriminator | Type | Data | +|---|---|---| +| 1 | `SolLimit` | `remaining: u64` (lifetime SOL spending cap) | +| 2 | `SolRecurringLimit` | `limit: u64, spent: u64, window: u64, last_reset: u64` | +| 3 | `SolMaxPerTx` | `max: u64` (per-execute SOL ceiling) | +| 4 | `TokenLimit` | `mint: [u8;32], remaining: u64` | +| 5 | `TokenRecurringLimit` | `mint: [u8;32], limit, spent, window, last_reset` | +| 6 | `TokenMaxPerTx` | `mint: [u8;32], max: u64` | +| 10 | `ProgramWhitelist` (repeatable) | `program_id: [u8;32]` | +| 11 | `ProgramBlacklist` (repeatable) | `program_id: [u8;32]` | + +Enforcement runs in `processor/execute_actions.rs`: 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, and vault-invariant defenses against `System::Assign` / `SetAuthority` / `Approve` escapes (errors 3030–3032). + ### D. DeferredExecAccount (176 bytes) Seeds: `["deferred", wallet_pubkey, authority_pubkey, counter_le(4)]` @@ -315,27 +333,30 @@ program/ secp256r1/ mod.rs Passkey authenticator with odometer + Clock-based slot check introspection.rs Precompile instruction verification - webauthn.rs ClientDataJSON reconstruction + AuthDataParser + webauthn.rs Raw clientDataJSON validation + AuthDataParser traits.rs Authenticator trait processor/ create_wallet.rs manage_authority.rs AddAuthority + RemoveAuthority execute.rs CompactInstruction execution (immediate) + execute_actions.rs Pre/post action enforcement engine (token snapshots, vault invariants) authorize.rs Deferred execution TX1 (creates DeferredExec PDA) execute_deferred.rs Deferred execution TX2 (verifies + executes) reclaim_deferred.rs Closes expired DeferredExec accounts - create_session.rs + create_session.rs Session creation with optional action buffer + revoke_session.rs Owner/Admin can close session early, refund rent transfer_ownership.rs state/ wallet.rs WalletAccount (8 bytes) authority.rs AuthorityAccountHeader (48 bytes) - session.rs SessionAccount (80 bytes) + session.rs SessionAccount (80-byte header + optional actions buffer) deferred.rs DeferredExecAccount (176 bytes) - compact.rs CompactInstruction serialization + action.rs Session action types + parser + validator (8 types, 11-byte header) + compact.rs CompactInstruction serialization (owned + zero-copy ref variants) utils.rs PDA initialization, stack_height check - error.rs AuthError enum (3001-3018) - entrypoint.rs Instruction routing -tests-sdk/ Integration + security tests (vitest, 56 tests) + error.rs AuthError enum (3001-3032) + entrypoint.rs Instruction routing (disc 0–9) +tests-sdk/ Integration + security tests (vitest, 65 tests) ``` The TypeScript SDK lives outside this repo: `@lazorkit/sdk-legacy` (in diff --git a/docs/Costs.md b/docs/Costs.md index 616f053..f68e298 100644 --- a/docs/Costs.md +++ b/docs/Costs.md @@ -2,7 +2,7 @@ This document provides comprehensive cost data for the LazorKit smart wallet program on Solana. All compute unit (CU) measurements are from real transactions on devnet. Rent costs use Solana's standard formula. -> Program ID: `FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao` +> Program IDs: `LazorjRFNavitUaBu5m3WaNPjU1maipvSW2rZfAFAKi` (mainnet, slot shared with `lazorkit-protocol`) / `FLb7fyAtkfA4TSa2uYcAT8QKHd2pkoMHgmqfnXFXo7ao` (devnet) --- @@ -130,7 +130,7 @@ Solana requires accounts to maintain a minimum balance (rent-exempt) based on da | Vault PDA | 0 | 0 | 0 | **Notes:** -- Secp256r1 authority size is variable: 48 (header) + 32 (cred hash) + 33 (pubkey) + 1 (rpIdLen) + N (rpId). For `rpId = "example.com"` (11 bytes), total = 125 bytes. +- Secp256r1 authority is 145 bytes: 48 (header) + 32 (cred hash) + 33 (pubkey) + 32 (rpIdHash, precomputed SHA256 of rpId). - **DeferredExec** rent is temporary -- refunded when ExecuteDeferred closes the account or when ReclaimDeferred reclaims an expired authorization. - **Vault PDA** is not initialized as a program-owned account. It simply receives SOL via transfer. No rent cost. @@ -192,7 +192,7 @@ At $150/SOL, session setup costs ~$0.22 USD. Each subsequent execute costs $0.00 |---|---|---|---| | WalletAccount | 8 bytes | 0 | **8 bytes** | | Authority (Ed25519) | 48 bytes | 32 bytes (pubkey) | **80 bytes** | -| Authority (Secp256r1) | 48 bytes | 32 (cred_hash) + 33 (pubkey) + 1 (rpIdLen) + N (rpId) | **114+ bytes** | +| Authority (Secp256r1) | 48 bytes | 32 (cred_hash) + 33 (pubkey) + 1 (rpIdLen) + N (rpId) | **145 bytes** | | SessionAccount | 80 bytes | 0 | **80 bytes** | | DeferredExecAccount | 176 bytes | 0 | **176 bytes** | @@ -211,4 +211,4 @@ The compact data sizes are achieved through: cd tests-sdk && npx tsx tests/devnet-smoke.ts ``` -The devnet smoke test exercises all 9 instructions across all authority types (Ed25519, Secp256r1, Session) and roles (Owner, Admin, Spender), reporting CU consumption, TX size, and rent costs from real devnet transactions. +The devnet smoke test exercises all 10 instructions across all authority types (Ed25519, Secp256r1, Session) and roles (Owner, Admin, Spender), reporting CU consumption, TX size, and rent costs from real devnet transactions.