diff --git a/Cargo.lock b/Cargo.lock index 03057b4669..ab2e8d8107 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -250,6 +250,23 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "anchor-compressible-user" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "borsh 0.10.4", + "light-compressed-account", + "light-hasher", + "light-macros", + "light-program-test", + "light-sdk", + "light-sdk-types", + "solana-program", + "solana-sdk", + "tokio", +] + [[package]] name = "anchor-derive-accounts" version = "0.31.1" @@ -3624,11 +3641,15 @@ dependencies = [ "light-zero-copy", "num-bigint 0.4.6", "solana-account-info", + "solana-clock", "solana-cpi", "solana-instruction", "solana-msg", "solana-program-error", "solana-pubkey", + "solana-rent", + "solana-system-interface", + "solana-sysvar", "thiserror 2.0.12", ] diff --git a/Cargo.toml b/Cargo.toml index 176115adb1..da3bde8684 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ members = [ "forester-utils", "forester", "sparse-merkle-tree", + "program-tests/anchor-compressible-user", ] resolver = "2" @@ -90,6 +91,7 @@ solana-transaction = { version = "2.2" } solana-transaction-error = { version = "2.2" } solana-hash = { version = "2.2" } solana-clock = { version = "2.2" } +solana-rent = { version = "2.2" } solana-signature = { version = "2.2" } solana-commitment-config = { version = "2.2" } solana-account = { version = "2.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c9e247861..6f725ba3f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6510,11 +6510,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@3.3.8: - resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - napi-postinstall@0.2.3: resolution: {integrity: sha512-Mi7JISo/4Ij2tDZ2xBE2WH+/KvVlkhA6juEjpEeRAVPNCpN3nxJo/5FhDNKgBcdmcmhaH6JjgST4xY/23ZYK0w==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -10007,11 +10002,11 @@ snapshots: '@coral-xyz/borsh': 0.29.0(@solana/web3.js@1.98.0) '@noble/hashes': 1.5.0 '@solana/web3.js': 1.98.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) - bn.js: 5.2.1 + bn.js: 5.2.2 bs58: 4.0.1 buffer-layout: 1.2.2 camelcase: 6.3.0 - cross-fetch: 3.1.8 + cross-fetch: 3.2.0 crypto-hash: 1.3.0 eventemitter3: 4.0.7 pako: 2.1.0 @@ -16368,8 +16363,6 @@ snapshots: nanoid@3.3.11: {} - nanoid@3.3.8: {} - napi-postinstall@0.2.3: {} natural-compare-lite@1.4.0: {} @@ -16939,7 +16932,7 @@ snapshots: postcss@8.5.1: dependencies: - nanoid: 3.3.8 + nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 diff --git a/program-tests/anchor-compressible-user/Cargo.toml b/program-tests/anchor-compressible-user/Cargo.toml new file mode 100644 index 0000000000..564f2d6d26 --- /dev/null +++ b/program-tests/anchor-compressible-user/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "anchor-compressible-user" +version = "0.1.0" +description = "Simple Anchor program template with user records" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "anchor_compressible_user" + +[features] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +cpi = ["no-entrypoint"] +default = [] + + +[dependencies] +light-sdk = { workspace = true } +light-sdk-types = { workspace = true } +light-hasher = { workspace = true, features = ["solana"] } +solana-program = { workspace = true } +light-macros = { workspace = true, features = ["solana"] } +borsh = { workspace = true } +light-compressed-account = { workspace = true, features = ["solana"] } +anchor-lang = { workspace = true } + +[dev-dependencies] +light-program-test = { workspace = true, features = ["devenv"] } +tokio = { workspace = true } +solana-sdk = { workspace = true } + +[lints.rust.unexpected_cfgs] +level = "allow" +check-cfg = [ + 'cfg(target_os, values("solana"))', + 'cfg(feature, values("frozen-abi", "no-entrypoint"))', +] diff --git a/program-tests/anchor-compressible-user/Xargo.toml b/program-tests/anchor-compressible-user/Xargo.toml new file mode 100644 index 0000000000..9e7d95be7f --- /dev/null +++ b/program-tests/anchor-compressible-user/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] \ No newline at end of file diff --git a/program-tests/anchor-compressible-user/src/lib.rs b/program-tests/anchor-compressible-user/src/lib.rs new file mode 100644 index 0000000000..5374a94e4e --- /dev/null +++ b/program-tests/anchor-compressible-user/src/lib.rs @@ -0,0 +1,96 @@ +use anchor_lang::prelude::*; + +declare_id!("CompUser11111111111111111111111111111111111"); +pub const ADDRESS_SPACE: Pubkey = pubkey!("CLEuMG7pzJX9xAuKCFzBP154uiG1GaNo4Fq7x6KAcAfG"); +pub const RENT_RECIPIENT: Pubkey = pubkey!("CLEuMG7pzJX9xAuKCFzBP154uiG1GaNo4Fq7x6KAcAfG"); + +// Simple anchor program retrofitted with compressible accounts. +#[program] +pub mod anchor_compressible_user { + use super::*; + + /// Creates a new user record + pub fn create_record( + ctx: Context, + name: String, + proof: ValidityProof, + compressed_address: [u8; 32], + address_tree_info: PackedAddressTreeInfo, + output_state_tree_index: u8, + ) -> Result<()> { + let user_record = &mut ctx.accounts.user_record; + + user_record.owner = ctx.accounts.user.key(); + user_record.name = name; + user_record.score = 0; + + let cpi_accounts = CpiAccounts::new_with_config( + &ctx.accounts.user, // fee_payer + &ctx.remaining_accounts[..], + CpiAccountsConfig::new(crate::LIGHT_CPI_SIGNER), + ); + let new_address_params = + address_tree_info.into_new_address_params_packed(user_record.key.to_bytes()); + + compress_pda_new::( + &user_record, + compressed_address, + new_address_params, + output_state_tree_index, + proof, + cpi_accounts, + &crate::ID, + &ctx.accounts.rent_recipient, + &ADDRESS_SPACE, + )?; + Ok(()) + } + + /// Can be the same because the PDA will be decompressed in a separate instruction. + /// Updates an existing user record + pub fn update_record(ctx: Context, name: String, score: u64) -> Result<()> { + let user_record = &mut ctx.accounts.user_record; + + user_record.name = name; + user_record.score = score; + + Ok(()) + } +} + +#[derive(Accounts)] +pub struct CreateRecord<'info> { + #[account(mut)] + pub user: Signer<'info>, + #[account( + init, + payer = user, + space = 8 + 32 + 4 + 32 + 8, // discriminator + owner + string len + name + score + seeds = [b"user_record", user.key().as_ref()], + bump, + )] + pub user_record: Account<'info, UserRecord>, + pub system_program: Program<'info, System>, + #[account(address = RENT_RECIPIENT)] + pub rent_recipient: AccountInfo<'info>, +} + +#[derive(Accounts)] +pub struct UpdateRecord<'info> { + #[account(mut)] + pub user: Signer<'info>, + #[account( + mut, + seeds = [b"user_record", user.key().as_ref()], + bump, + constraint = user_record.owner == user.key() + )] + pub user_record: Account<'info, UserRecord>, +} + +#[account] +pub struct UserRecord { + pub owner: Pubkey, + pub name: String, + pub score: u64, +} diff --git a/program-tests/anchor-compressible-user/tests/test.rs b/program-tests/anchor-compressible-user/tests/test.rs new file mode 100644 index 0000000000..41bd86eb0d --- /dev/null +++ b/program-tests/anchor-compressible-user/tests/test.rs @@ -0,0 +1,82 @@ +#![cfg(feature = "test-sbf")] + +use anchor_lang::prelude::*; +use anchor_lang::InstructionData; +use anchor_lang::ToAccountMetas; +use solana_program_test::*; +use solana_sdk::{ + instruction::Instruction, + pubkey::Pubkey, + signature::{Keypair, Signer}, + transaction::Transaction, +}; + +#[tokio::test] +async fn test_user_record() { + let program_id = anchor_compressible_user::ID; + let mut program_test = ProgramTest::new( + "anchor_compressible_user", + program_id, + processor!(anchor_compressible_user::entry), + ); + + let (mut banks_client, payer, recent_blockhash) = program_test.start().await; + + // Test create_record + let user = payer; + let (user_record_pda, _bump) = Pubkey::find_program_address( + &[b"user_record", user.pubkey().as_ref()], + &program_id, + ); + + let accounts = anchor_compressible_user::accounts::CreateRecord { + user: user.pubkey(), + user_record: user_record_pda, + system_program: solana_sdk::system_program::ID, + }; + + let instruction_data = anchor_compressible_user::instruction::CreateRecord { + name: "Alice".to_string(), + }; + + let instruction = Instruction { + program_id, + accounts: accounts.to_account_metas(None), + data: instruction_data.data(), + }; + + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&user.pubkey()), + &[&user], + recent_blockhash, + ); + + banks_client.process_transaction(transaction).await.unwrap(); + + // Test update_record + let accounts = anchor_compressible_user::accounts::UpdateRecord { + user: user.pubkey(), + user_record: user_record_pda, + }; + + let instruction_data = anchor_compressible_user::instruction::UpdateRecord { + name: "Alice Updated".to_string(), + score: 100, + }; + + let instruction = Instruction { + program_id, + accounts: accounts.to_account_metas(None), + data: instruction_data.data(), + }; + + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&user.pubkey()), + &[&user], + recent_blockhash, + ); + + banks_client.process_transaction(transaction).await.unwrap(); +} \ No newline at end of file diff --git a/program-tests/sdk-test/src/compress_dynamic_pda.rs b/program-tests/sdk-test/src/compress_dynamic_pda.rs new file mode 100644 index 0000000000..c6c8f151cf --- /dev/null +++ b/program-tests/sdk-test/src/compress_dynamic_pda.rs @@ -0,0 +1,59 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use light_sdk::{ + compressible::compress_pda, + cpi::CpiAccounts, + error::LightSdkError, + instruction::{account_meta::CompressedAccountMeta, ValidityProof}, +}; +use light_sdk_types::CpiAccountsConfig; +use solana_program::account_info::AccountInfo; + +use crate::decompress_dynamic_pda::MyPdaAccount; + +/// Compresses a PDA back into a compressed account +/// Anyone can call this after the timeout period has elapsed +// TODO: add macro that create the full instruction. and takes: programid, account and seeds, rent_recipient (to hardcode). low code solution. +pub fn compress_dynamic_pda( + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> Result<(), LightSdkError> { + let mut instruction_data = instruction_data; + let instruction_data = CompressFromPdaInstructionData::deserialize(&mut instruction_data) + .map_err(|_| LightSdkError::Borsh)?; + + let pda_account = &accounts[1]; + + // CHECK: hardcoded rent recipient. + let rent_recipient = &accounts[2]; + if rent_recipient.key != &crate::create_dynamic_pda::RENT_RECIPIENT { + return Err(LightSdkError::ConstraintViolation); + } + + // Cpi accounts + let config = CpiAccountsConfig::new(crate::LIGHT_CPI_SIGNER); + let cpi_accounts = CpiAccounts::new_with_config( + &accounts[0], + &accounts[instruction_data.system_accounts_offset as usize..], + config, + ); + + compress_pda::( + pda_account, + &instruction_data.compressed_account_meta, + instruction_data.proof, + cpi_accounts, + &crate::ID, + rent_recipient, + )?; + + // any other program logic here... + + Ok(()) +} + +#[derive(Clone, Debug, Default, BorshDeserialize, BorshSerialize)] +pub struct CompressFromPdaInstructionData { + pub proof: ValidityProof, + pub compressed_account_meta: CompressedAccountMeta, + pub system_accounts_offset: u8, +} diff --git a/program-tests/sdk-test/src/create_dynamic_pda.rs b/program-tests/sdk-test/src/create_dynamic_pda.rs new file mode 100644 index 0000000000..6a5d68b952 --- /dev/null +++ b/program-tests/sdk-test/src/create_dynamic_pda.rs @@ -0,0 +1,70 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use light_macros::pubkey; +use light_sdk::{ + compressible::compress_pda_new, + cpi::CpiAccounts, + error::LightSdkError, + instruction::{PackedAddressTreeInfo, ValidityProof}, +}; +use light_sdk_types::CpiAccountsConfig; +use solana_program::account_info::AccountInfo; +use solana_program::pubkey::Pubkey; + +use crate::decompress_dynamic_pda::MyPdaAccount; + +pub const ADDRESS_SPACE: Pubkey = pubkey!("CLEuMG7pzJX9xAuKCFzBP154uiG1GaNo4Fq7x6KAcAfG"); +pub const RENT_RECIPIENT: Pubkey = pubkey!("CLEuMG7pzJX9xAuKCFzBP154uiG1GaNo4Fq7x6KAcAfG"); + +/// INITS a PDA and compresses it into a new compressed account. +pub fn create_dynamic_pda( + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> Result<(), LightSdkError> { + let mut instruction_data = instruction_data; + let instruction_data = CreateDynamicPdaInstructionData::deserialize(&mut instruction_data) + .map_err(|_| LightSdkError::Borsh)?; + + let fee_payer = &accounts[0]; + // UNCHECKED: ...caller program checks this. + let pda_account = &accounts[1]; + // CHECK: hardcoded rent recipient. + let rent_recipient = &accounts[2]; + if rent_recipient.key != &RENT_RECIPIENT { + return Err(LightSdkError::ConstraintViolation); + } + + // Cpi accounts + let cpi_accounts_struct = CpiAccounts::new_with_config( + fee_payer, + &accounts[3..], + CpiAccountsConfig::new(crate::LIGHT_CPI_SIGNER), + ); + + // the onchain PDA is the seed for the cPDA. this way devs don't have to + // change their onchain PDA checks. + let new_address_params = instruction_data + .address_tree_info + .into_new_address_params_packed(pda_account.key.to_bytes()); + + compress_pda_new::( + pda_account, + instruction_data.compressed_address, + new_address_params, + instruction_data.output_state_tree_index, + instruction_data.proof, + cpi_accounts_struct, + &crate::ID, + rent_recipient, + &ADDRESS_SPACE, // TODO: consider passing a slice of pubkeys, and extend to read_only_address_proofs. + )?; + + Ok(()) +} + +#[derive(Clone, Debug, Default, BorshDeserialize, BorshSerialize)] +pub struct CreateDynamicPdaInstructionData { + pub proof: ValidityProof, + pub compressed_address: [u8; 32], + pub address_tree_info: PackedAddressTreeInfo, + pub output_state_tree_index: u8, +} diff --git a/program-tests/sdk-test/src/decompress_dynamic_pda.rs b/program-tests/sdk-test/src/decompress_dynamic_pda.rs new file mode 100644 index 0000000000..842713c830 --- /dev/null +++ b/program-tests/sdk-test/src/decompress_dynamic_pda.rs @@ -0,0 +1,161 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use light_sdk::{ + account::LightAccount, + compressible::{decompress_idempotent, PdaTimingData}, + cpi::{CpiAccounts, CpiAccountsConfig}, + error::LightSdkError, + instruction::{account_meta::CompressedAccountMeta, ValidityProof}, + LightDiscriminator, LightHasher, +}; +use solana_program::account_info::AccountInfo; + +pub const SLOTS_UNTIL_COMPRESSION: u64 = 100; + +/// Decompresses a compressed account into a PDA idempotently. +pub fn decompress_dynamic_pda( + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> Result<(), LightSdkError> { + let mut instruction_data = instruction_data; + let instruction_data = DecompressToPdaInstructionData::deserialize(&mut instruction_data) + .map_err(|_| LightSdkError::Borsh)?; + + // Get accounts + let fee_payer = &accounts[0]; + let pda_account = &accounts[1]; + let rent_payer = &accounts[2]; + let system_program = &accounts[3]; + + // Set up CPI accounts + let config = CpiAccountsConfig::new(crate::LIGHT_CPI_SIGNER); + let cpi_accounts = CpiAccounts::new_with_config( + fee_payer, + &accounts[instruction_data.system_accounts_offset as usize..], + config, + ); + + let compressed_account = LightAccount::<'_, MyPdaAccount>::new_mut( + &crate::ID, + &instruction_data.compressed_account.meta, + instruction_data.compressed_account.data, + )?; + + // Call decompress_idempotent - this should work whether PDA exists or not + decompress_idempotent::( + pda_account, + compressed_account, + instruction_data.proof, + cpi_accounts, + &crate::ID, + rent_payer, + system_program, + )?; + + Ok(()) +} + +/// Example: Decompresses multiple compressed accounts into PDAs in a single transaction. +pub fn decompress_multiple_dynamic_pdas( + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> Result<(), LightSdkError> { + use light_sdk::compressible::decompress_multiple_idempotent; + + #[derive(Clone, Debug, Default, BorshDeserialize, BorshSerialize)] + pub struct DecompressMultipleInstructionData { + pub proof: ValidityProof, + pub compressed_accounts: Vec, + pub system_accounts_offset: u8, + } + + let mut instruction_data = instruction_data; + let instruction_data = DecompressMultipleInstructionData::deserialize(&mut instruction_data) + .map_err(|_| LightSdkError::Borsh)?; + + // Get fixed accounts + let fee_payer = &accounts[0]; + let rent_payer = &accounts[1]; + let system_program = &accounts[2]; + + // Get PDA accounts (after fixed accounts, before system accounts) + let pda_accounts_start = 3; + let pda_accounts_end = instruction_data.system_accounts_offset as usize; + let pda_accounts = &accounts[pda_accounts_start..pda_accounts_end]; + + // Set up CPI accounts + let mut config = CpiAccountsConfig::new(crate::LIGHT_CPI_SIGNER); + config.sol_pool_pda = false; + config.sol_compression_recipient = false; + + let cpi_accounts = CpiAccounts::new_with_config( + fee_payer, + &accounts[instruction_data.system_accounts_offset as usize..], + config, + ); + + // Build inputs for batch decompression + let mut compressed_accounts = Vec::new(); + let mut pda_account_refs = Vec::new(); + + for (i, compressed_account_data) in instruction_data.compressed_accounts.into_iter().enumerate() + { + let compressed_account = LightAccount::<'_, MyPdaAccount>::new_mut( + &crate::ID, + &compressed_account_data.meta, + compressed_account_data.data, + )?; + + compressed_accounts.push(compressed_account); + pda_account_refs.push(&pda_accounts[i]); + } + + // Decompress all accounts in one CPI call + decompress_multiple_idempotent::( + &pda_account_refs, + compressed_accounts, + instruction_data.proof, + cpi_accounts, + &crate::ID, + rent_payer, + system_program, + )?; + + Ok(()) +} + +#[derive(Clone, Debug, Default, BorshDeserialize, BorshSerialize)] +pub struct DecompressToPdaInstructionData { + pub proof: ValidityProof, + pub compressed_account: MyCompressedAccount, + pub system_accounts_offset: u8, +} + +#[derive(Clone, Debug, Default, BorshDeserialize, BorshSerialize)] +pub struct MyCompressedAccount { + pub meta: CompressedAccountMeta, + pub data: MyPdaAccount, +} + +#[derive( + Clone, Debug, Default, LightHasher, LightDiscriminator, BorshDeserialize, BorshSerialize, +)] +pub struct MyPdaAccount { + pub last_written_slot: u64, + pub slots_until_compression: u64, + pub data: [u8; 31], +} + +// Implement the PdaTimingData trait +impl PdaTimingData for MyPdaAccount { + fn last_written_slot(&self) -> u64 { + self.last_written_slot + } + + fn slots_until_compression(&self) -> u64 { + self.slots_until_compression + } + + fn set_last_written_slot(&mut self, slot: u64) { + self.last_written_slot = slot; + } +} diff --git a/program-tests/sdk-test/src/lib.rs b/program-tests/sdk-test/src/lib.rs index 8fb2b71b2c..e2a4ab77ac 100644 --- a/program-tests/sdk-test/src/lib.rs +++ b/program-tests/sdk-test/src/lib.rs @@ -4,7 +4,10 @@ use solana_program::{ account_info::AccountInfo, entrypoint, program_error::ProgramError, pubkey::Pubkey, }; +pub mod compress_dynamic_pda; +pub mod create_dynamic_pda; pub mod create_pda; +pub mod decompress_dynamic_pda; pub mod update_pda; pub const ID: Pubkey = pubkey!("FNt7byTHev1k5x2cXZLBr8TdWiC3zoP5vcnZR4P682Uy"); @@ -17,6 +20,9 @@ entrypoint!(process_instruction); pub enum InstructionType { CreatePdaBorsh = 0, UpdatePdaBorsh = 1, + DecompressToPda = 2, + CompressFromPda = 3, + CompressFromPdaNew = 4, } impl TryFrom for InstructionType { @@ -26,6 +32,9 @@ impl TryFrom for InstructionType { match value { 0 => Ok(InstructionType::CreatePdaBorsh), 1 => Ok(InstructionType::UpdatePdaBorsh), + 2 => Ok(InstructionType::DecompressToPda), + 3 => Ok(InstructionType::CompressFromPda), + 4 => Ok(InstructionType::CompressFromPdaNew), _ => panic!("Invalid instruction discriminator."), } } @@ -44,6 +53,15 @@ pub fn process_instruction( InstructionType::UpdatePdaBorsh => { update_pda::update_pda::(accounts, &instruction_data[1..]) } + InstructionType::DecompressToPda => { + decompress_dynamic_pda::decompress_dynamic_pda(accounts, &instruction_data[1..]) + } + InstructionType::CompressFromPda => { + compress_dynamic_pda::compress_dynamic_pda(accounts, &instruction_data[1..]) + } + InstructionType::CompressFromPdaNew => { + create_dynamic_pda::create_dynamic_pda(accounts, &instruction_data[1..]) + } }?; Ok(()) } diff --git a/sdk-libs/sdk/Cargo.toml b/sdk-libs/sdk/Cargo.toml index 9afeb4af92..b8fc645f02 100644 --- a/sdk-libs/sdk/Cargo.toml +++ b/sdk-libs/sdk/Cargo.toml @@ -28,6 +28,10 @@ solana-msg = { workspace = true } solana-cpi = { workspace = true } solana-program-error = { workspace = true } solana-instruction = { workspace = true } +solana-system-interface = { workspace = true } +solana-clock = { workspace = true } +solana-sysvar = { workspace = true } +solana-rent = { workspace = true } anchor-lang = { workspace = true, optional = true } num-bigint = { workspace = true } diff --git a/sdk-libs/sdk/src/compressible/compress_pda.rs b/sdk-libs/sdk/src/compressible/compress_pda.rs new file mode 100644 index 0000000000..330d5e2320 --- /dev/null +++ b/sdk-libs/sdk/src/compressible/compress_pda.rs @@ -0,0 +1,120 @@ +use crate::{ + account::LightAccount, + cpi::{CpiAccounts, CpiInputs}, + error::LightSdkError, + instruction::{account_meta::CompressedAccountMeta, ValidityProof}, + LightDiscriminator, +}; +#[cfg(feature = "anchor")] +use anchor_lang::{AnchorDeserialize as BorshDeserialize, AnchorSerialize as BorshSerialize}; +#[cfg(not(feature = "anchor"))] +use borsh::{BorshDeserialize, BorshSerialize}; +use light_hasher::DataHasher; +use solana_account_info::AccountInfo; +use solana_clock::Clock; +use solana_msg::msg; +use solana_program_error::ProgramError; +use solana_pubkey::Pubkey; +use solana_sysvar::Sysvar; + +/// Trait for PDA accounts that can be compressed +pub trait PdaTimingData { + fn last_written_slot(&self) -> u64; + fn slots_until_compression(&self) -> u64; + fn set_last_written_slot(&mut self, slot: u64); +} + +/// Helper function to compress a PDA and reclaim rent. +/// +/// 1. closes onchain PDA +/// 2. transfers PDA lamports to rent_recipient +/// 3. updates the empty compressed PDA with onchain PDA data +/// +/// This requires the compressed PDA that is tied to the onchain PDA to already +/// exist. +/// +/// # Arguments +/// * `pda_account` - The PDA account to compress (will be closed) +/// * `compressed_account_meta` - Metadata for the compressed account (must be +/// empty but have an address) +/// * `proof` - Validity proof +/// * `cpi_accounts` - Accounts needed for CPI +/// * `owner_program` - The program that will own the compressed account +/// * `rent_recipient` - The account to receive the PDA's rent +// +// TODO: +// - check if any explicit checks required for compressed account? +// - consider multiple accounts per ix. +pub fn compress_pda( + pda_account: &AccountInfo, + compressed_account_meta: &CompressedAccountMeta, + proof: ValidityProof, + cpi_accounts: CpiAccounts, + owner_program: &Pubkey, + rent_recipient: &AccountInfo, +) -> Result<(), LightSdkError> +where + A: DataHasher + + LightDiscriminator + + BorshSerialize + + BorshDeserialize + + Default + + PdaTimingData, +{ + // Check that the PDA account is owned by the caller program + if pda_account.owner != owner_program { + msg!( + "Invalid PDA owner. Expected: {}. Found: {}.", + owner_program, + pda_account.owner + ); + return Err(LightSdkError::ConstraintViolation); + } + + let current_slot = Clock::get()?.slot; + + // Deserialize the PDA data to check timing fields + let pda_data = pda_account.try_borrow_data()?; + let pda_account_data = A::try_from_slice(&pda_data[8..]).map_err(|_| LightSdkError::Borsh)?; + drop(pda_data); + + let last_written_slot = pda_account_data.last_written_slot(); + let slots_until_compression = pda_account_data.slots_until_compression(); + + if current_slot < last_written_slot + slots_until_compression { + msg!( + "Cannot compress yet. {} slots remaining", + (last_written_slot + slots_until_compression).saturating_sub(current_slot) + ); + return Err(LightSdkError::ConstraintViolation); + } + + // Get the PDA lamports before we close it + let pda_lamports = pda_account.lamports(); + + let mut compressed_account = + LightAccount::<'_, A>::new_mut(owner_program, compressed_account_meta, A::default())?; + + compressed_account.account = pda_account_data; + + // Create CPI inputs + let cpi_inputs = CpiInputs::new(proof, vec![compressed_account.to_account_info()?]); + + // Invoke light system program to create the compressed account + cpi_inputs.invoke_light_system_program(cpi_accounts)?; + + // Close the PDA account + // 1. Transfer all lamports to the rent recipient + let dest_starting_lamports = rent_recipient.lamports(); + **rent_recipient.try_borrow_mut_lamports()? = dest_starting_lamports + .checked_add(pda_lamports) + .ok_or(ProgramError::ArithmeticOverflow)?; + // 2. Decrement source account lamports + **pda_account.try_borrow_mut_lamports()? = 0; + // 3. Clear all account data + pda_account.try_borrow_mut_data()?.fill(0); + // 4. Assign ownership back to the system program + pda_account.assign(&Pubkey::default()); + + Ok(()) +} diff --git a/sdk-libs/sdk/src/compressible/compress_pda_new.rs b/sdk-libs/sdk/src/compressible/compress_pda_new.rs new file mode 100644 index 0000000000..6263786161 --- /dev/null +++ b/sdk-libs/sdk/src/compressible/compress_pda_new.rs @@ -0,0 +1,209 @@ +use crate::{ + account::LightAccount, + address::{v1::derive_address, PackedNewAddressParams}, + cpi::{CpiAccounts, CpiInputs}, + error::LightSdkError, + instruction::ValidityProof, + light_account_checks::AccountInfoTrait, + LightDiscriminator, +}; +#[cfg(feature = "anchor")] +use anchor_lang::{AnchorDeserialize as BorshDeserialize, AnchorSerialize as BorshSerialize}; +#[cfg(not(feature = "anchor"))] +use borsh::{BorshDeserialize, BorshSerialize}; +use light_hasher::DataHasher; +use solana_account_info::AccountInfo; +use solana_clock::Clock; +use solana_msg::msg; +use solana_program_error::ProgramError; +use solana_pubkey::Pubkey; +use solana_sysvar::Sysvar; + +use crate::compressible::compress_pda::PdaTimingData; + +/// Helper function to compress an onchain PDA into a new compressed account. +/// +/// This function handles the entire compression operation: creates a compressed account, +/// copies the PDA data, and closes the onchain PDA. +/// +/// # Arguments +/// * `pda_account` - The PDA account to compress (will be closed) +/// * `address` - The address for the compressed account +/// * `new_address_params` - Address parameters for the compressed account +/// * `output_state_tree_index` - Output state tree index for the compressed account +/// * `proof` - Validity proof +/// * `cpi_accounts` - Accounts needed for CPI +/// * `owner_program` - The program that will own the compressed account +/// * `rent_recipient` - The account to receive the PDA's rent +/// * `expected_address_space` - Optional expected address space pubkey to validate against +/// +/// # Returns +/// * `Ok(())` if the PDA was compressed successfully +/// * `Err(LightSdkError)` if there was an error +pub fn compress_pda_new<'info, A>( + pda_account: &AccountInfo<'info>, + address: [u8; 32], + new_address_params: PackedNewAddressParams, + output_state_tree_index: u8, + proof: ValidityProof, + cpi_accounts: CpiAccounts<'_, 'info>, + owner_program: &Pubkey, + rent_recipient: &AccountInfo<'info>, + expected_address_space: &Pubkey, +) -> Result<(), LightSdkError> +where + A: DataHasher + + LightDiscriminator + + BorshSerialize + + BorshDeserialize + + Default + + PdaTimingData + + Clone, +{ + compress_multiple_pdas_new::( + &[pda_account], + &[address], + vec![new_address_params], + &[output_state_tree_index], + proof, + cpi_accounts, + owner_program, + rent_recipient, + expected_address_space, + ) +} + +/// Helper function to compress multiple onchain PDAs into new compressed accounts. +/// +/// This function handles the entire compression operation for multiple PDAs. +/// +/// # Arguments +/// * `pda_accounts` - The PDA accounts to compress (will be closed) +/// * `addresses` - The addresses for the compressed accounts +/// * `new_address_params` - Address parameters for the compressed accounts +/// * `output_state_tree_indices` - Output state tree indices for the compressed accounts +/// * `proof` - Single validity proof for all accounts +/// * `cpi_accounts` - Accounts needed for CPI +/// * `owner_program` - The program that will own the compressed accounts +/// * `rent_recipient` - The account to receive the PDAs' rent +/// * `expected_address_space` - Optional expected address space pubkey to validate against +/// +/// # Returns +/// * `Ok(())` if all PDAs were compressed successfully +/// * `Err(LightSdkError)` if there was an error +pub fn compress_multiple_pdas_new<'info, A>( + pda_accounts: &[&AccountInfo<'info>], + addresses: &[[u8; 32]], + new_address_params: Vec, + output_state_tree_indices: &[u8], + proof: ValidityProof, + cpi_accounts: CpiAccounts<'_, 'info>, + owner_program: &Pubkey, + rent_recipient: &AccountInfo<'info>, + expected_address_space: &Pubkey, +) -> Result<(), LightSdkError> +where + A: DataHasher + + LightDiscriminator + + BorshSerialize + + BorshDeserialize + + Default + + PdaTimingData + + Clone, +{ + if pda_accounts.len() != addresses.len() + || pda_accounts.len() != new_address_params.len() + || pda_accounts.len() != output_state_tree_indices.len() + { + return Err(LightSdkError::ConstraintViolation); + } + + // CHECK: address space. + for params in &new_address_params { + let address_tree_account = cpi_accounts + .get_tree_account_info(params.address_merkle_tree_account_index as usize)?; + if address_tree_account.pubkey() != *expected_address_space { + msg!( + "Invalid address space. Expected: {}. Found: {}.", + expected_address_space, + address_tree_account.pubkey() + ); + return Err(LightSdkError::ConstraintViolation); + } + } + + let mut total_lamports = 0u64; + let mut compressed_account_infos = Vec::new(); + + for ((pda_account, &address), &output_state_tree_index) in pda_accounts + .iter() + .zip(addresses.iter()) + .zip(output_state_tree_indices.iter()) + { + // Check that the PDA account is owned by the caller program + if pda_account.owner != owner_program { + msg!( + "Invalid PDA owner for {}. Expected: {}. Found: {}.", + pda_account.key, + owner_program, + pda_account.owner + ); + return Err(LightSdkError::ConstraintViolation); + } + + // Deserialize the PDA data to check timing fields + let pda_data = pda_account.try_borrow_data()?; + let pda_account_data = + A::try_from_slice(&pda_data[8..]).map_err(|_| LightSdkError::Borsh)?; + drop(pda_data); + + let last_written_slot = pda_account_data.last_written_slot(); + let slots_until_compression = pda_account_data.slots_until_compression(); + + let current_slot = Clock::get()?.slot; + if current_slot < last_written_slot + slots_until_compression { + msg!( + "Cannot compress {} yet. {} slots remaining", + pda_account.key, + (last_written_slot + slots_until_compression).saturating_sub(current_slot) + ); + return Err(LightSdkError::ConstraintViolation); + } + + // Create the compressed account with the PDA data + let mut compressed_account = + LightAccount::<'_, A>::new_init(owner_program, Some(address), output_state_tree_index); + compressed_account.account = pda_account_data; + + compressed_account_infos.push(compressed_account.to_account_info()?); + + // Accumulate lamports + total_lamports = total_lamports + .checked_add(pda_account.lamports()) + .ok_or(ProgramError::ArithmeticOverflow)?; + } + + // Create CPI inputs with all compressed accounts and new addresses + let cpi_inputs = + CpiInputs::new_with_address(proof, compressed_account_infos, new_address_params); + + // Invoke light system program to create all compressed accounts + cpi_inputs.invoke_light_system_program(cpi_accounts)?; + + // Close all PDA accounts + let dest_starting_lamports = rent_recipient.lamports(); + **rent_recipient.try_borrow_mut_lamports()? = dest_starting_lamports + .checked_add(total_lamports) + .ok_or(ProgramError::ArithmeticOverflow)?; + + for pda_account in pda_accounts { + // Decrement source account lamports + **pda_account.try_borrow_mut_lamports()? = 0; + // Clear all account data + pda_account.try_borrow_mut_data()?.fill(0); + // Assign ownership back to the system program + pda_account.assign(&Pubkey::default()); + } + + Ok(()) +} diff --git a/sdk-libs/sdk/src/compressible/decompress_idempotent.rs b/sdk-libs/sdk/src/compressible/decompress_idempotent.rs new file mode 100644 index 0000000000..c9ce8db4eb --- /dev/null +++ b/sdk-libs/sdk/src/compressible/decompress_idempotent.rs @@ -0,0 +1,199 @@ +use crate::{ + account::LightAccount, + cpi::{CpiAccounts, CpiInputs}, + error::LightSdkError, + instruction::{account_meta::CompressedAccountMeta, ValidityProof}, + LightDiscriminator, +}; +#[cfg(feature = "anchor")] +use anchor_lang::{AnchorDeserialize as BorshDeserialize, AnchorSerialize as BorshSerialize}; +#[cfg(not(feature = "anchor"))] +use borsh::{BorshDeserialize, BorshSerialize}; +use light_hasher::DataHasher; +use solana_account_info::AccountInfo; +use solana_clock::Clock; +use solana_cpi::invoke_signed; +use solana_msg::msg; +use solana_pubkey::Pubkey; +use solana_rent::Rent; +use solana_system_interface::instruction as system_instruction; +use solana_sysvar::Sysvar; + +use crate::compressible::compress_pda::PdaTimingData; + +pub const SLOTS_UNTIL_COMPRESSION: u64 = 100; + +/// Helper function to decompress a compressed account into a PDA idempotently. +/// +/// This function is idempotent, meaning it can be called multiple times with the same compressed account +/// and it will only decompress it once. If the PDA already exists and is initialized, it returns early. +/// +/// # Arguments +/// * `pda_account` - The PDA account to decompress into +/// * `compressed_account` - The compressed account to decompress +/// * `proof` - Validity proof +/// * `cpi_accounts` - Accounts needed for CPI +/// * `owner_program` - The program that will own the PDA +/// * `rent_payer` - The account to pay for PDA rent +/// * `system_program` - The system program +/// +/// # Returns +/// * `Ok(())` if the compressed account was decompressed successfully or PDA already exists +/// * `Err(LightSdkError)` if there was an error +pub fn decompress_idempotent<'info, A>( + pda_account: &AccountInfo<'info>, + compressed_account: LightAccount<'_, A>, + proof: ValidityProof, + cpi_accounts: CpiAccounts<'_, 'info>, + owner_program: &Pubkey, + rent_payer: &AccountInfo<'info>, + system_program: &AccountInfo<'info>, +) -> Result<(), LightSdkError> +where + A: DataHasher + + LightDiscriminator + + BorshSerialize + + BorshDeserialize + + Default + + Clone + + PdaTimingData, +{ + decompress_multiple_idempotent( + &[pda_account], + vec![compressed_account], + proof, + cpi_accounts, + owner_program, + rent_payer, + system_program, + ) +} + +/// Helper function to decompress multiple compressed accounts into PDAs idempotently. +/// +/// This function is idempotent, meaning it can be called multiple times with the same compressed accounts +/// and it will only decompress them once. If a PDA already exists and is initialized, it skips that account. +/// +/// # Arguments +/// * `pda_accounts` - The PDA accounts to decompress into +/// * `compressed_accounts` - The compressed accounts to decompress +/// * `proof` - Single validity proof for all accounts +/// * `cpi_accounts` - Accounts needed for CPI +/// * `owner_program` - The program that will own the PDAs +/// * `rent_payer` - The account to pay for PDA rent +/// * `system_program` - The system program +/// +/// # Returns +/// * `Ok(())` if all compressed accounts were decompressed successfully or PDAs already exist +/// * `Err(LightSdkError)` if there was an error +pub fn decompress_multiple_idempotent<'info, A>( + pda_accounts: &[&AccountInfo<'info>], + compressed_accounts: Vec>, + proof: ValidityProof, + cpi_accounts: CpiAccounts<'_, 'info>, + owner_program: &Pubkey, + rent_payer: &AccountInfo<'info>, + system_program: &AccountInfo<'info>, +) -> Result<(), LightSdkError> +where + A: DataHasher + + LightDiscriminator + + BorshSerialize + + BorshDeserialize + + Default + + Clone + + PdaTimingData, +{ + // Get current slot and rent once for all accounts + let clock = Clock::get().map_err(|_| LightSdkError::Borsh)?; + let current_slot = clock.slot; + let rent = Rent::get().map_err(|_| LightSdkError::Borsh)?; + + // Calculate space needed for PDA (same for all accounts of type A) + let space = std::mem::size_of::() + 8; // +8 for discriminator + let rent_minimum_balance = rent.minimum_balance(space); + + // Collect compressed accounts for CPI + let mut compressed_accounts_for_cpi = Vec::new(); + + for (pda_account, mut compressed_account) in + pda_accounts.iter().zip(compressed_accounts.into_iter()) + { + // Check if PDA is already initialized + if pda_account.data_len() > 0 { + msg!( + "PDA {} already initialized, skipping decompression", + pda_account.key + ); + continue; + } + + // Get the compressed account address + let compressed_address = compressed_account + .address() + .ok_or(LightSdkError::ConstraintViolation)?; + + // Derive onchain PDA using the compressed address as seed + let seeds: Vec<&[u8]> = vec![&compressed_address]; + + let (pda_pubkey, pda_bump) = Pubkey::find_program_address(&seeds, owner_program); + + // Verify PDA matches + if pda_pubkey != *pda_account.key { + msg!("Invalid PDA pubkey for account {}", pda_account.key); + return Err(LightSdkError::ConstraintViolation); + } + + // Create PDA account + let create_account_ix = system_instruction::create_account( + rent_payer.key, + pda_account.key, + rent_minimum_balance, + space as u64, + owner_program, + ); + + // Add bump to seeds for signing + let bump_seed = [pda_bump]; + let mut signer_seeds = seeds.clone(); + signer_seeds.push(&bump_seed); + let signer_seeds_refs: Vec<&[u8]> = signer_seeds.iter().map(|s| *s).collect(); + + invoke_signed( + &create_account_ix, + &[ + rent_payer.clone(), + (*pda_account).clone(), + system_program.clone(), + ], + &[&signer_seeds_refs], + )?; + + // Initialize PDA with decompressed data and update slot + let mut decompressed_pda = compressed_account.account.clone(); + decompressed_pda.set_last_written_slot(current_slot); + + // Write discriminator + let discriminator = A::LIGHT_DISCRIMINATOR; + pda_account.try_borrow_mut_data()?[..8].copy_from_slice(&discriminator); + + // Write data to PDA + decompressed_pda + .serialize(&mut &mut pda_account.try_borrow_mut_data()?[8..]) + .map_err(|_| LightSdkError::Borsh)?; + + // Zero the compressed account + compressed_account.account = A::default(); + + // Add to CPI batch + compressed_accounts_for_cpi.push(compressed_account.to_account_info()?); + } + + // Make single CPI call with all compressed accounts + if !compressed_accounts_for_cpi.is_empty() { + let cpi_inputs = CpiInputs::new(proof, compressed_accounts_for_cpi); + cpi_inputs.invoke_light_system_program(cpi_accounts)?; + } + + Ok(()) +} diff --git a/sdk-libs/sdk/src/compressible/mod.rs b/sdk-libs/sdk/src/compressible/mod.rs new file mode 100644 index 0000000000..488a302543 --- /dev/null +++ b/sdk-libs/sdk/src/compressible/mod.rs @@ -0,0 +1,9 @@ +//! SDK helpers for compressing and decompressing PDAs. + +pub mod compress_pda; +pub mod compress_pda_new; +pub mod decompress_idempotent; + +pub use compress_pda::{compress_pda, PdaTimingData}; +pub use compress_pda_new::{compress_multiple_pdas_new, compress_pda_new}; +pub use decompress_idempotent::{decompress_idempotent, decompress_multiple_idempotent}; diff --git a/sdk-libs/sdk/src/lib.rs b/sdk-libs/sdk/src/lib.rs index b8eef1be97..bb86dc3c3b 100644 --- a/sdk-libs/sdk/src/lib.rs +++ b/sdk-libs/sdk/src/lib.rs @@ -111,6 +111,8 @@ pub mod error; /// Utilities to build instructions for programs with compressed accounts. pub mod instruction; pub mod legacy; +/// SDK helpers for compressing and decompressing PDAs. +pub mod compressible; pub mod token; /// Transfer compressed sol between compressed accounts. pub mod transfer;