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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
498 changes: 498 additions & 0 deletions kora-light-client/CLAUDE.md

Large diffs are not rendered by default.

714 changes: 714 additions & 0 deletions kora-light-client/Cargo.lock

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions kora-light-client/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[package]
name = "kora-light-client"
version = "0.1.0"
edition = "2021"
description = "Standalone Light Protocol instruction builders for solana-sdk 3.0 consumers"
license = "Apache-2.0"

[dependencies]
solana-pubkey = { version = "3.0", features = ["std", "sha2", "curve25519"] }
solana-instruction = "3.0"
solana-system-interface = "2.0"
solana-compute-budget-interface = "3.0"
borsh = { version = "1.5", features = ["derive"] }
thiserror = "2.0"
189 changes: 189 additions & 0 deletions kora-light-client/src/account_select.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
//! Greedy descending account selection algorithm.
//!
//! Selects the minimum number of compressed token accounts to satisfy a target amount.

use crate::{error::KoraLightError, types::CompressedTokenAccountInput};

/// Maximum number of compressed accounts per transaction.
pub const MAX_INPUT_ACCOUNTS: usize = 8;

/// Select compressed token accounts to satisfy the given amount.
///
/// Uses a greedy descending algorithm: sorts by amount (largest first),
/// then selects accounts until the cumulative amount meets or exceeds
/// the target. Returns up to `MAX_INPUT_ACCOUNTS` (8) accounts.
///
/// If `target_amount` is 0, returns an empty vec.
/// If total available balance is insufficient, returns an error.
pub fn select_input_accounts(
accounts: &[CompressedTokenAccountInput],
target_amount: u64,
) -> Result<Vec<CompressedTokenAccountInput>, KoraLightError> {
if target_amount == 0 {
return Ok(Vec::new());
}

if accounts.is_empty() {
return Err(KoraLightError::NoCompressedAccounts);
}

// Sort by amount descending (largest first)
let mut sorted: Vec<&CompressedTokenAccountInput> = accounts.iter().collect();
sorted.sort_by(|a, b| b.amount.cmp(&a.amount));

// Greedy selection: take accounts until we have enough
let mut accumulated: u64 = 0;
let mut count_needed: usize = 0;

for acc in &sorted {
count_needed += 1;
accumulated = accumulated
.checked_add(acc.amount)
.ok_or(KoraLightError::ArithmeticOverflow)?;
if accumulated >= target_amount {
break;
}
}

// Check if we have enough
if accumulated < target_amount {
return Err(KoraLightError::InsufficientBalance {
needed: target_amount,
available: accumulated,
});
}

// Clamp to MAX_INPUT_ACCOUNTS
let select_count = count_needed.min(MAX_INPUT_ACCOUNTS).min(sorted.len());

// If we had to clamp, verify the top accounts still satisfy the target
if count_needed > MAX_INPUT_ACCOUNTS {
let top_sum: u64 = sorted[..select_count]
.iter()
.try_fold(0u64, |acc, a| acc.checked_add(a.amount))
.ok_or(KoraLightError::ArithmeticOverflow)?;
if top_sum < target_amount {
return Err(KoraLightError::InsufficientBalance {
needed: target_amount,
available: top_sum,
});
}
}

Ok(sorted[..select_count]
.iter()
.map(|a| (*a).clone())
.collect())
}

#[cfg(test)]
mod tests {
use solana_pubkey::Pubkey;

use super::*;

fn make_account(amount: u64) -> CompressedTokenAccountInput {
CompressedTokenAccountInput {
hash: [0u8; 32],
tree: Pubkey::default(),
queue: Pubkey::default(),
amount,
leaf_index: 0,
prove_by_index: false,
root_index: 0,
version: 0,
owner: Pubkey::default(),
mint: Pubkey::default(),
delegate: None,
}
}

#[test]
fn test_select_exact_amount() {
let accounts = vec![make_account(500), make_account(300), make_account(200)];
let selected = select_input_accounts(&accounts, 500).unwrap();
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].amount, 500);
}

#[test]
fn test_select_multiple_accounts() {
let accounts = vec![make_account(300), make_account(200), make_account(100)];
let selected = select_input_accounts(&accounts, 450).unwrap();
assert_eq!(selected.len(), 2);
// Should pick largest first
assert_eq!(selected[0].amount, 300);
assert_eq!(selected[1].amount, 200);
}

#[test]
fn test_select_all_accounts() {
let accounts = vec![make_account(100), make_account(100), make_account(100)];
let selected = select_input_accounts(&accounts, 300).unwrap();
assert_eq!(selected.len(), 3);
let total: u64 = selected.iter().map(|a| a.amount).sum();
assert_eq!(total, 300);
}

#[test]
fn test_select_insufficient_balance() {
let accounts = vec![make_account(100), make_account(50)];
let result = select_input_accounts(&accounts, 200);
assert!(matches!(
result,
Err(KoraLightError::InsufficientBalance { .. })
));
}

#[test]
fn test_select_zero_amount() {
let accounts = vec![make_account(100)];
let selected = select_input_accounts(&accounts, 0).unwrap();
assert!(selected.is_empty());
}

#[test]
fn test_select_empty_accounts() {
let result = select_input_accounts(&[], 100);
assert!(matches!(result, Err(KoraLightError::NoCompressedAccounts)));
}

#[test]
fn test_select_respects_max_limit() {
// 10 accounts of 100 each, target 900: top 8 = 800 < 900 → InsufficientBalance
let accounts: Vec<_> = (0..10).map(|_| make_account(100)).collect();
let result = select_input_accounts(&accounts, 900);
assert!(matches!(
result,
Err(KoraLightError::InsufficientBalance {
needed: 900,
available: 800,
})
));
}

#[test]
fn test_select_max_limit_sufficient() {
// 10 accounts of 100 each, target 800: top 8 = 800 >= 800 → success
let accounts: Vec<_> = (0..10).map(|_| make_account(100)).collect();
let selected = select_input_accounts(&accounts, 800).unwrap();
assert_eq!(selected.len(), MAX_INPUT_ACCOUNTS);
let total: u64 = selected.iter().map(|a| a.amount).sum();
assert_eq!(total, 800);
}

#[test]
fn test_select_greedy_descending() {
let accounts = vec![
make_account(10),
make_account(1000),
make_account(50),
make_account(500),
];
let selected = select_input_accounts(&accounts, 1200).unwrap();
// Should pick 1000 + 500 = 1500 >= 1200
assert_eq!(selected.len(), 2);
assert_eq!(selected[0].amount, 1000);
assert_eq!(selected[1].amount, 500);
}
}
182 changes: 182 additions & 0 deletions kora-light-client/src/create_ata.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
//! Create Light Token associated token account instruction builder.
//!
//! Ported from `sdk-libs/token-sdk/src/instruction/create_ata.rs`.

use borsh::BorshSerialize;
use solana_instruction::{AccountMeta, Instruction};
use solana_pubkey::Pubkey;

use crate::{
error::KoraLightError,
pda::get_associated_token_address,
program_ids::{LIGHT_TOKEN_CONFIG, LIGHT_TOKEN_PROGRAM_ID, RENT_SPONSOR_V1, SYSTEM_PROGRAM_ID},
types::{CompressibleExtensionInstructionData, CreateAssociatedTokenAccountInstructionData},
};

const CREATE_ATA_DISCRIMINATOR: u8 = 100;
const CREATE_ATA_IDEMPOTENT_DISCRIMINATOR: u8 = 102;

/// Default pre-pay epochs for rent
const DEFAULT_PREPAY_EPOCHS: u8 = 16;
/// Default write top-up in lamports (covers ~3 hours of rent)
const DEFAULT_WRITE_TOP_UP: u32 = 766;

/// Builder for CreateAssociatedTokenAccount instructions.
#[derive(Debug, Clone)]
pub struct CreateAta {
pub payer: Pubkey,
pub owner: Pubkey,
pub mint: Pubkey,
pub idempotent: bool,
/// Compressible config PDA (default: LIGHT_TOKEN_CONFIG)
pub compressible_config: Pubkey,
/// Rent sponsor PDA (default: RENT_SPONSOR_V1)
pub rent_sponsor: Pubkey,
/// Pre-pay rent epochs (default: 16)
pub pre_pay_num_epochs: u8,
/// Write top-up in lamports (default: 766)
pub write_top_up: u32,
/// Compression-only flag (default: true for ATAs)
pub compression_only: bool,
}

impl CreateAta {
/// Create a new CreateAta builder with default rent-free settings.
pub fn new(payer: Pubkey, owner: Pubkey, mint: Pubkey) -> Self {
Self {
payer,
owner,
mint,
idempotent: false,
compressible_config: LIGHT_TOKEN_CONFIG,
rent_sponsor: RENT_SPONSOR_V1,
pre_pay_num_epochs: DEFAULT_PREPAY_EPOCHS,
write_top_up: DEFAULT_WRITE_TOP_UP,
compression_only: true,
}
}

/// Make this an idempotent create (no-op if ATA already exists).
pub fn idempotent(mut self) -> Self {
self.idempotent = true;
self
}

/// Build the instruction.
pub fn instruction(&self) -> Result<Instruction, KoraLightError> {
let ata = get_associated_token_address(&self.owner, &self.mint);

let instruction_data = CreateAssociatedTokenAccountInstructionData {
compressible_config: Some(CompressibleExtensionInstructionData {
token_account_version: 0, // ShaFlat
rent_payment: self.pre_pay_num_epochs,
compression_only: if self.compression_only { 1 } else { 0 },
write_top_up: self.write_top_up,
compress_to_account_pubkey: None,
}),
};

let discriminator = if self.idempotent {
CREATE_ATA_IDEMPOTENT_DISCRIMINATOR
} else {
CREATE_ATA_DISCRIMINATOR
};

let mut data = Vec::new();
data.push(discriminator);
instruction_data.serialize(&mut data)?;

let accounts = vec![
AccountMeta::new_readonly(self.owner, false),
AccountMeta::new_readonly(self.mint, false),
AccountMeta::new(self.payer, true),
AccountMeta::new(ata, false),
AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false),
AccountMeta::new_readonly(self.compressible_config, false),
AccountMeta::new(self.rent_sponsor, false),
];

Ok(Instruction {
program_id: LIGHT_TOKEN_PROGRAM_ID,
accounts,
data,
})
}
}

/// Convenience function: build an idempotent CreateAta instruction with defaults.
pub fn create_ata_idempotent_instruction(
payer: &Pubkey,
owner: &Pubkey,
mint: &Pubkey,
) -> Result<Instruction, KoraLightError> {
CreateAta::new(*payer, *owner, *mint)
.idempotent()
.instruction()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_create_ata_instruction_builds() {
let payer = Pubkey::new_unique();
let owner = Pubkey::new_unique();
let mint = Pubkey::new_unique();

let ix = CreateAta::new(payer, owner, mint).instruction().unwrap();

assert_eq!(ix.program_id, LIGHT_TOKEN_PROGRAM_ID);
assert_eq!(ix.accounts.len(), 7);
// First byte is discriminator
assert_eq!(ix.data[0], CREATE_ATA_DISCRIMINATOR);

// Account order: owner, mint, payer, ata, system, config, sponsor
assert_eq!(ix.accounts[0].pubkey, owner);
assert!(!ix.accounts[0].is_signer);
assert_eq!(ix.accounts[1].pubkey, mint);
assert_eq!(ix.accounts[2].pubkey, payer);
assert!(ix.accounts[2].is_signer);
assert_eq!(ix.accounts[4].pubkey, SYSTEM_PROGRAM_ID);
assert_eq!(ix.accounts[5].pubkey, LIGHT_TOKEN_CONFIG);
assert_eq!(ix.accounts[6].pubkey, RENT_SPONSOR_V1);
}

#[test]
fn test_create_ata_idempotent() {
let payer = Pubkey::new_unique();
let owner = Pubkey::new_unique();
let mint = Pubkey::new_unique();

let ix = CreateAta::new(payer, owner, mint)
.idempotent()
.instruction()
.unwrap();

assert_eq!(ix.data[0], CREATE_ATA_IDEMPOTENT_DISCRIMINATOR);
}

#[test]
fn test_create_ata_ata_address_matches_pda() {
let owner = Pubkey::new_unique();
let mint = Pubkey::new_unique();

let ix = CreateAta::new(Pubkey::new_unique(), owner, mint)
.instruction()
.unwrap();

let expected_ata = get_associated_token_address(&owner, &mint);
assert_eq!(ix.accounts[3].pubkey, expected_ata);
}

#[test]
fn test_convenience_function() {
let payer = Pubkey::new_unique();
let owner = Pubkey::new_unique();
let mint = Pubkey::new_unique();

let ix = create_ata_idempotent_instruction(&payer, &owner, &mint).unwrap();
assert_eq!(ix.data[0], CREATE_ATA_IDEMPOTENT_DISCRIMINATOR);
}
}
Loading
Loading