diff --git a/Cargo.lock b/Cargo.lock index edbd0ea7a7..b138c8fa57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5072,7 +5072,7 @@ dependencies = [ [[package]] name = "pinocchio-token-interface" version = "0.0.0" -source = "git+https://github.com/Lightprotocol/token?rev=9ea04560a039d1a44f0411b5eaa7c0b79ed575ab#9ea04560a039d1a44f0411b5eaa7c0b79ed575ab" +source = "git+https://github.com/Lightprotocol/token?rev=f7bee9bbc8039c224a88ea76e9ae2edd78e0f9c3#f7bee9bbc8039c224a88ea76e9ae2edd78e0f9c3" dependencies = [ "pinocchio", "pinocchio-pubkey", @@ -5081,7 +5081,7 @@ dependencies = [ [[package]] name = "pinocchio-token-program" version = "0.1.0" -source = "git+https://github.com/Lightprotocol/token?rev=9ea04560a039d1a44f0411b5eaa7c0b79ed575ab#9ea04560a039d1a44f0411b5eaa7c0b79ed575ab" +source = "git+https://github.com/Lightprotocol/token?rev=f7bee9bbc8039c224a88ea76e9ae2edd78e0f9c3#f7bee9bbc8039c224a88ea76e9ae2edd78e0f9c3" dependencies = [ "pinocchio", "pinocchio-log", diff --git a/Cargo.toml b/Cargo.toml index daef1112c1..54a0b5af41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -232,7 +232,7 @@ groth16-solana = { version = "0.2.0" } bytemuck = { version = "1.19.0" } arrayvec = "0.7" tinyvec = "1.10.0" -pinocchio-token-program = { git= "https://github.com/Lightprotocol/token", rev="9ea04560a039d1a44f0411b5eaa7c0b79ed575ab" } +pinocchio-token-program = { git= "https://github.com/Lightprotocol/token", rev="f7bee9bbc8039c224a88ea76e9ae2edd78e0f9c3" } # Math and crypto num-bigint = "0.4.6" tabled = "0.20" diff --git a/program-tests/compressed-token-test/tests/mint.rs b/program-tests/compressed-token-test/tests/mint.rs index cf70f9db08..4b6c467398 100644 --- a/program-tests/compressed-token-test/tests/mint.rs +++ b/program-tests/compressed-token-test/tests/mint.rs @@ -22,3 +22,6 @@ mod burn; #[path = "mint/ctoken_mint_to.rs"] mod ctoken_mint_to; + +#[path = "mint/cmint_resize.rs"] +mod cmint_resize; diff --git a/program-tests/compressed-token-test/tests/mint/cmint_resize.rs b/program-tests/compressed-token-test/tests/mint/cmint_resize.rs new file mode 100644 index 0000000000..f7fd74c71f --- /dev/null +++ b/program-tests/compressed-token-test/tests/mint/cmint_resize.rs @@ -0,0 +1,1177 @@ +//! Tests for CMint (decompressed mint) resize operations. +//! +//! These tests verify the resize path in `serialize_decompressed_mint` which handles: +//! - Account resize when metadata size changes +//! - Rent exemption calculation and deficit handling +//! - Compressible top-up calculation +//! +//! Two main scenarios: +//! - Scenario A: CMint already exists (decompressed at start of transaction) +//! - Scenario B: CMint decompressed in the same transaction (DecompressMint + other actions) + +use anchor_lang::prelude::borsh::BorshDeserialize; +use light_client::indexer::Indexer; +use light_ctoken_interface::{ + instructions::extensions::token_metadata::TokenMetadataInstructionData, + state::{extensions::AdditionalMetadata, CompressedMint, TokenDataVersion}, +}; +use light_ctoken_sdk::{ + compressed_token::create_compressed_mint::{ + derive_cmint_compressed_address, find_cmint_address, + }, + ctoken::{derive_ctoken_ata, CompressibleParams, CreateAssociatedCTokenAccount}, +}; +use light_program_test::{LightProgramTest, ProgramTestConfig}; +use light_test_utils::{assert_mint_action::assert_mint_action, Rpc}; +use light_token_client::{ + actions::create_mint, + instructions::mint_action::{ + DecompressMintParams, MintActionParams, MintActionType, MintToRecipient, + }, +}; +use serial_test::serial; +use solana_sdk::{signature::Keypair, signer::Signer}; + +// ============================================================================ +// SCENARIO A: CMint Already Exists (Decompressed at Start) +// ============================================================================ + +/// Test UpdateMetadataField with longer value triggers resize grow on existing CMint. +#[tokio::test] +#[serial] +async fn test_cmint_update_metadata_grow() { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + let mint_seed = Keypair::new(); + let authority = Keypair::new(); + rpc.airdrop_lamports(&authority.pubkey(), 10_000_000_000) + .await + .unwrap(); + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + let compressed_mint_address = + derive_cmint_compressed_address(&mint_seed.pubkey(), &address_tree_pubkey); + let (spl_mint_pda, _cmint_bump) = find_cmint_address(&mint_seed.pubkey()); + + // 1. Create compressed mint with small metadata + create_mint( + &mut rpc, + &mint_seed, + 8, + &authority, + Some(authority.pubkey()), + Some(TokenMetadataInstructionData { + update_authority: Some(authority.pubkey().into()), + name: "A".as_bytes().to_vec(), + symbol: "B".as_bytes().to_vec(), + uri: "C".as_bytes().to_vec(), + additional_metadata: None, + }), + &payer, + ) + .await + .unwrap(); + + // 2. Decompress to CMint (creates on-chain account) + light_token_client::actions::mint_action_comprehensive( + &mut rpc, + &mint_seed, + &authority, + &payer, + Some(DecompressMintParams::default()), + false, + vec![], + vec![], + None, + None, + None, + ) + .await + .unwrap(); + + // 3. Get pre-state from CMint on-chain account + let cmint_account_data = rpc + .get_account(spl_mint_pda) + .await + .unwrap() + .expect("CMint should exist"); + let pre_mint: CompressedMint = + BorshDeserialize::deserialize(&mut cmint_account_data.data.as_slice()).unwrap(); + + // 4. UpdateMetadataField with LONGER value (triggers resize grow) + let actions = vec![MintActionType::UpdateMetadataField { + extension_index: 0, + field_type: 0, // Name field + key: vec![], + value: "Much Longer Token Name That Will Cause Account Resize" + .as_bytes() + .to_vec(), + }]; + + light_token_client::actions::mint_action( + &mut rpc, + MintActionParams { + compressed_mint_address, + mint_seed: mint_seed.pubkey(), + authority: authority.pubkey(), + payer: payer.pubkey(), + actions: actions.clone(), + new_mint: None, + }, + &authority, + &payer, + None, + ) + .await + .unwrap(); + + // 5. Verify with assert_mint_action + assert_mint_action(&mut rpc, compressed_mint_address, pre_mint, actions).await; +} + +/// Test UpdateMetadataField with shorter value triggers resize shrink on existing CMint. +#[tokio::test] +#[serial] +async fn test_cmint_update_metadata_shrink() { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + let mint_seed = Keypair::new(); + let authority = Keypair::new(); + rpc.airdrop_lamports(&authority.pubkey(), 10_000_000_000) + .await + .unwrap(); + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + let compressed_mint_address = + derive_cmint_compressed_address(&mint_seed.pubkey(), &address_tree_pubkey); + let (spl_mint_pda, _) = find_cmint_address(&mint_seed.pubkey()); + + // 1. Create compressed mint with large metadata + create_mint( + &mut rpc, + &mint_seed, + 8, + &authority, + Some(authority.pubkey()), + Some(TokenMetadataInstructionData { + update_authority: Some(authority.pubkey().into()), + name: "This Is A Very Long Token Name That Will Be Shortened" + .as_bytes() + .to_vec(), + symbol: "LONGSYMBOL".as_bytes().to_vec(), + uri: "https://example.com/very/long/path/to/token/metadata.json" + .as_bytes() + .to_vec(), + additional_metadata: None, + }), + &payer, + ) + .await + .unwrap(); + + // 2. Decompress to CMint + light_token_client::actions::mint_action_comprehensive( + &mut rpc, + &mint_seed, + &authority, + &payer, + Some(DecompressMintParams::default()), + false, + vec![], + vec![], + None, + None, + None, + ) + .await + .unwrap(); + + // 3. Get pre-state + let cmint_account_data = rpc + .get_account(spl_mint_pda) + .await + .unwrap() + .expect("CMint should exist"); + let pre_mint: CompressedMint = + BorshDeserialize::deserialize(&mut cmint_account_data.data.as_slice()).unwrap(); + + // 4. UpdateMetadataField with SHORTER value (triggers resize shrink) + let actions = vec![MintActionType::UpdateMetadataField { + extension_index: 0, + field_type: 0, // Name field + key: vec![], + value: "Short".as_bytes().to_vec(), + }]; + + light_token_client::actions::mint_action( + &mut rpc, + MintActionParams { + compressed_mint_address, + mint_seed: mint_seed.pubkey(), + authority: authority.pubkey(), + payer: payer.pubkey(), + actions: actions.clone(), + new_mint: None, + }, + &authority, + &payer, + None, + ) + .await + .unwrap(); + + // 5. Verify + assert_mint_action(&mut rpc, compressed_mint_address, pre_mint, actions).await; +} + +/// Test RemoveMetadataKey triggers resize shrink on existing CMint. +#[tokio::test] +#[serial] +async fn test_cmint_remove_metadata_key() { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + let mint_seed = Keypair::new(); + let authority = Keypair::new(); + rpc.airdrop_lamports(&authority.pubkey(), 10_000_000_000) + .await + .unwrap(); + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + let compressed_mint_address = + derive_cmint_compressed_address(&mint_seed.pubkey(), &address_tree_pubkey); + let (spl_mint_pda, _) = find_cmint_address(&mint_seed.pubkey()); + + // 1. Create compressed mint with additional metadata + create_mint( + &mut rpc, + &mint_seed, + 8, + &authority, + Some(authority.pubkey()), + Some(TokenMetadataInstructionData { + update_authority: Some(authority.pubkey().into()), + name: "Test Token".as_bytes().to_vec(), + symbol: "TEST".as_bytes().to_vec(), + uri: "https://example.com".as_bytes().to_vec(), + additional_metadata: Some(vec![ + AdditionalMetadata { + key: vec![1, 2, 3, 4], + value: vec![10u8; 32], + }, + AdditionalMetadata { + key: vec![5, 6, 7, 8], + value: vec![20u8; 32], + }, + ]), + }), + &payer, + ) + .await + .unwrap(); + + // 2. Decompress to CMint + light_token_client::actions::mint_action_comprehensive( + &mut rpc, + &mint_seed, + &authority, + &payer, + Some(DecompressMintParams::default()), + false, + vec![], + vec![], + None, + None, + None, + ) + .await + .unwrap(); + + // 3. Get pre-state + let cmint_account_data = rpc + .get_account(spl_mint_pda) + .await + .unwrap() + .expect("CMint should exist"); + let pre_mint: CompressedMint = + BorshDeserialize::deserialize(&mut cmint_account_data.data.as_slice()).unwrap(); + + // 4. RemoveMetadataKey (triggers resize shrink) + let actions = vec![MintActionType::RemoveMetadataKey { + extension_index: 0, + key: vec![1, 2, 3, 4], + idempotent: 0, + }]; + + light_token_client::actions::mint_action( + &mut rpc, + MintActionParams { + compressed_mint_address, + mint_seed: mint_seed.pubkey(), + authority: authority.pubkey(), + payer: payer.pubkey(), + actions: actions.clone(), + new_mint: None, + }, + &authority, + &payer, + None, + ) + .await + .unwrap(); + + // 5. Verify + assert_mint_action(&mut rpc, compressed_mint_address, pre_mint, actions).await; +} + +/// Test multiple metadata changes on existing CMint. +#[tokio::test] +#[serial] +async fn test_cmint_multiple_metadata_changes() { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + let mint_seed = Keypair::new(); + let authority = Keypair::new(); + rpc.airdrop_lamports(&authority.pubkey(), 10_000_000_000) + .await + .unwrap(); + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + let compressed_mint_address = + derive_cmint_compressed_address(&mint_seed.pubkey(), &address_tree_pubkey); + let (spl_mint_pda, _) = find_cmint_address(&mint_seed.pubkey()); + + // 1. Create compressed mint with metadata + create_mint( + &mut rpc, + &mint_seed, + 8, + &authority, + Some(authority.pubkey()), + Some(TokenMetadataInstructionData { + update_authority: Some(authority.pubkey().into()), + name: "Original Name".as_bytes().to_vec(), + symbol: "ORIG".as_bytes().to_vec(), + uri: "https://original.com".as_bytes().to_vec(), + additional_metadata: Some(vec![ + AdditionalMetadata { + key: vec![1, 2, 3], + value: vec![1u8; 16], + }, + AdditionalMetadata { + key: vec![4, 5, 6], + value: vec![2u8; 16], + }, + ]), + }), + &payer, + ) + .await + .unwrap(); + + // 2. Decompress to CMint + light_token_client::actions::mint_action_comprehensive( + &mut rpc, + &mint_seed, + &authority, + &payer, + Some(DecompressMintParams::default()), + false, + vec![], + vec![], + None, + None, + None, + ) + .await + .unwrap(); + + // 3. Get pre-state + let cmint_account_data = rpc + .get_account(spl_mint_pda) + .await + .unwrap() + .expect("CMint should exist"); + let pre_mint: CompressedMint = + BorshDeserialize::deserialize(&mut cmint_account_data.data.as_slice()).unwrap(); + + // 4. Multiple metadata changes (grow name, shrink symbol, remove key) + let actions = vec![ + MintActionType::UpdateMetadataField { + extension_index: 0, + field_type: 0, // Name - grow + key: vec![], + value: "Much Longer Updated Token Name".as_bytes().to_vec(), + }, + MintActionType::UpdateMetadataField { + extension_index: 0, + field_type: 1, // Symbol - shrink + key: vec![], + value: "UP".as_bytes().to_vec(), + }, + MintActionType::RemoveMetadataKey { + extension_index: 0, + key: vec![1, 2, 3], + idempotent: 0, + }, + ]; + + light_token_client::actions::mint_action( + &mut rpc, + MintActionParams { + compressed_mint_address, + mint_seed: mint_seed.pubkey(), + authority: authority.pubkey(), + payer: payer.pubkey(), + actions: actions.clone(), + new_mint: None, + }, + &authority, + &payer, + None, + ) + .await + .unwrap(); + + // 5. Verify + assert_mint_action(&mut rpc, compressed_mint_address, pre_mint, actions).await; +} + +/// Test ALL operations on existing CMint in a single transaction. +#[tokio::test] +#[serial] +async fn test_cmint_all_operations() { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + let mint_seed = Keypair::new(); + let authority = Keypair::new(); + rpc.airdrop_lamports(&authority.pubkey(), 10_000_000_000) + .await + .unwrap(); + rpc.airdrop_lamports(&payer.pubkey(), 10_000_000_000) + .await + .unwrap(); + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + let compressed_mint_address = + derive_cmint_compressed_address(&mint_seed.pubkey(), &address_tree_pubkey); + let (spl_mint_pda, _) = find_cmint_address(&mint_seed.pubkey()); + + // 1. Create compressed mint with metadata and additional_metadata + create_mint( + &mut rpc, + &mint_seed, + 8, + &authority, + Some(authority.pubkey()), + Some(TokenMetadataInstructionData { + update_authority: Some(authority.pubkey().into()), + name: "Test Token".as_bytes().to_vec(), + symbol: "TEST".as_bytes().to_vec(), + uri: "https://example.com/token.json".as_bytes().to_vec(), + additional_metadata: Some(vec![ + AdditionalMetadata { + key: vec![1, 2, 3, 4], + value: vec![10u8; 16], + }, + AdditionalMetadata { + key: vec![5, 6, 7, 8], + value: vec![20u8; 16], + }, + ]), + }), + &payer, + ) + .await + .unwrap(); + + // 2. Decompress to CMint + light_token_client::actions::mint_action_comprehensive( + &mut rpc, + &mint_seed, + &authority, + &payer, + Some(DecompressMintParams::default()), + false, + vec![], + vec![], + None, + None, + None, + ) + .await + .unwrap(); + + // 3. Create CToken ATA for MintToCToken + let recipient = Keypair::new(); + let compressible_params = CompressibleParams { + compressible_config: rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: rpc.test_accounts.funding_pool_config.rent_sponsor_pda, + pre_pay_num_epochs: 0, + lamports_per_write: Some(1000), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }; + + let create_ata_ix = + CreateAssociatedCTokenAccount::new(payer.pubkey(), recipient.pubkey(), spl_mint_pda) + .with_compressible(compressible_params) + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[create_ata_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // 4. Get pre-state + let cmint_account_data = rpc + .get_account(spl_mint_pda) + .await + .unwrap() + .expect("CMint should exist"); + let pre_mint: CompressedMint = + BorshDeserialize::deserialize(&mut cmint_account_data.data.as_slice()).unwrap(); + + // New authorities + let new_mint_authority = Keypair::new(); + let new_freeze_authority = Keypair::new(); + let new_metadata_authority = Keypair::new(); + + // 5. ALL operations in one tx + let actions = vec![ + // MintTo (compressed recipients) + MintActionType::MintTo { + recipients: vec![MintToRecipient { + recipient: Keypair::new().pubkey(), + amount: 1000, + }], + token_account_version: 2, + }, + // MintToCToken (decompressed recipient) + MintActionType::MintToCToken { + account: derive_ctoken_ata(&recipient.pubkey(), &spl_mint_pda).0, + amount: 2000, + }, + // UpdateMintAuthority + MintActionType::UpdateMintAuthority { + new_authority: Some(new_mint_authority.pubkey()), + }, + // UpdateFreezeAuthority + MintActionType::UpdateFreezeAuthority { + new_authority: Some(new_freeze_authority.pubkey()), + }, + // UpdateMetadataField - name (grow) + MintActionType::UpdateMetadataField { + extension_index: 0, + field_type: 0, + key: vec![], + value: "Updated Token Name That Is Much Longer".as_bytes().to_vec(), + }, + // UpdateMetadataField - symbol + MintActionType::UpdateMetadataField { + extension_index: 0, + field_type: 1, + key: vec![], + value: "UPDATED".as_bytes().to_vec(), + }, + // UpdateMetadataField - uri + MintActionType::UpdateMetadataField { + extension_index: 0, + field_type: 2, + key: vec![], + value: "https://updated.example.com/token.json".as_bytes().to_vec(), + }, + // UpdateMetadataField - custom key + MintActionType::UpdateMetadataField { + extension_index: 0, + field_type: 3, + key: vec![1, 2, 3, 4], + value: "updated_custom_value".as_bytes().to_vec(), + }, + // RemoveMetadataKey + MintActionType::RemoveMetadataKey { + extension_index: 0, + key: vec![5, 6, 7, 8], + idempotent: 0, + }, + // UpdateMetadataAuthority (must be last metadata operation) + MintActionType::UpdateMetadataAuthority { + extension_index: 0, + new_authority: new_metadata_authority.pubkey(), + }, + ]; + + light_token_client::actions::mint_action( + &mut rpc, + MintActionParams { + compressed_mint_address, + mint_seed: mint_seed.pubkey(), + authority: authority.pubkey(), + payer: payer.pubkey(), + actions: actions.clone(), + new_mint: None, + }, + &authority, + &payer, + None, + ) + .await + .unwrap(); + + // 6. Verify + assert_mint_action(&mut rpc, compressed_mint_address, pre_mint, actions).await; +} + +// ============================================================================ +// SCENARIO B: CMint Decompressed in Transaction (DecompressMint + Other Actions) +// ============================================================================ + +/// Test DecompressMint + MintTo in same transaction. +#[tokio::test] +#[serial] +async fn test_decompress_with_mint_to() { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + let mint_seed = Keypair::new(); + let authority = Keypair::new(); + rpc.airdrop_lamports(&authority.pubkey(), 10_000_000_000) + .await + .unwrap(); + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + let compressed_mint_address = + derive_cmint_compressed_address(&mint_seed.pubkey(), &address_tree_pubkey); + let (_, cmint_bump) = find_cmint_address(&mint_seed.pubkey()); + + // 1. Create compressed mint (no decompress yet) + create_mint( + &mut rpc, + &mint_seed, + 8, + &authority, + Some(authority.pubkey()), + None, + &payer, + ) + .await + .unwrap(); + + // 2. Get pre-state from compressed account + let compressed_account = rpc + .indexer() + .unwrap() + .get_compressed_account(compressed_mint_address, None) + .await + .unwrap() + .value + .unwrap(); + let pre_mint: CompressedMint = + BorshDeserialize::deserialize(&mut compressed_account.data.unwrap().data.as_slice()) + .unwrap(); + + // 3. DecompressMint + MintTo in same tx + let actions = vec![ + MintActionType::DecompressMint { + cmint_bump, + rent_payment: 2, + write_top_up: 0, + }, + MintActionType::MintTo { + recipients: vec![MintToRecipient { + recipient: Keypair::new().pubkey(), + amount: 5000, + }], + token_account_version: 2, + }, + ]; + + light_token_client::actions::mint_action( + &mut rpc, + MintActionParams { + compressed_mint_address, + mint_seed: mint_seed.pubkey(), + authority: authority.pubkey(), + payer: payer.pubkey(), + actions: actions.clone(), + new_mint: None, + }, + &authority, + &payer, + Some(&mint_seed), // Required for DecompressMint + ) + .await + .unwrap(); + + // 4. Verify + assert_mint_action(&mut rpc, compressed_mint_address, pre_mint, actions).await; +} + +/// Test DecompressMint + authority updates in same transaction. +#[tokio::test] +#[serial] +async fn test_decompress_with_authority_updates() { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + let mint_seed = Keypair::new(); + let authority = Keypair::new(); + rpc.airdrop_lamports(&authority.pubkey(), 10_000_000_000) + .await + .unwrap(); + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + let compressed_mint_address = + derive_cmint_compressed_address(&mint_seed.pubkey(), &address_tree_pubkey); + let (_, cmint_bump) = find_cmint_address(&mint_seed.pubkey()); + + // 1. Create compressed mint + create_mint( + &mut rpc, + &mint_seed, + 8, + &authority, + Some(authority.pubkey()), + None, + &payer, + ) + .await + .unwrap(); + + // 2. Get pre-state + let compressed_account = rpc + .indexer() + .unwrap() + .get_compressed_account(compressed_mint_address, None) + .await + .unwrap() + .value + .unwrap(); + let pre_mint: CompressedMint = + BorshDeserialize::deserialize(&mut compressed_account.data.unwrap().data.as_slice()) + .unwrap(); + + let new_mint_authority = Keypair::new(); + let new_freeze_authority = Keypair::new(); + + // 3. DecompressMint + UpdateMintAuthority + UpdateFreezeAuthority + let actions = vec![ + MintActionType::DecompressMint { + cmint_bump, + rent_payment: 2, + write_top_up: 0, + }, + MintActionType::UpdateMintAuthority { + new_authority: Some(new_mint_authority.pubkey()), + }, + MintActionType::UpdateFreezeAuthority { + new_authority: Some(new_freeze_authority.pubkey()), + }, + ]; + + light_token_client::actions::mint_action( + &mut rpc, + MintActionParams { + compressed_mint_address, + mint_seed: mint_seed.pubkey(), + authority: authority.pubkey(), + payer: payer.pubkey(), + actions: actions.clone(), + new_mint: None, + }, + &authority, + &payer, + Some(&mint_seed), // Required for DecompressMint + ) + .await + .unwrap(); + + // 4. Verify + assert_mint_action(&mut rpc, compressed_mint_address, pre_mint, actions).await; +} + +/// Test DecompressMint + UpdateMetadataField in same transaction. +#[tokio::test] +#[serial] +async fn test_decompress_with_metadata_update() { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + let mint_seed = Keypair::new(); + let authority = Keypair::new(); + rpc.airdrop_lamports(&authority.pubkey(), 10_000_000_000) + .await + .unwrap(); + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + let compressed_mint_address = + derive_cmint_compressed_address(&mint_seed.pubkey(), &address_tree_pubkey); + let (_, cmint_bump) = find_cmint_address(&mint_seed.pubkey()); + + // 1. Create compressed mint with metadata + create_mint( + &mut rpc, + &mint_seed, + 8, + &authority, + Some(authority.pubkey()), + Some(TokenMetadataInstructionData { + update_authority: Some(authority.pubkey().into()), + name: "Original".as_bytes().to_vec(), + symbol: "ORIG".as_bytes().to_vec(), + uri: "https://original.com".as_bytes().to_vec(), + additional_metadata: None, + }), + &payer, + ) + .await + .unwrap(); + + // 2. Get pre-state + let compressed_account = rpc + .indexer() + .unwrap() + .get_compressed_account(compressed_mint_address, None) + .await + .unwrap() + .value + .unwrap(); + let pre_mint: CompressedMint = + BorshDeserialize::deserialize(&mut compressed_account.data.unwrap().data.as_slice()) + .unwrap(); + + // 3. DecompressMint + UpdateMetadataField + let actions = vec![ + MintActionType::DecompressMint { + cmint_bump, + rent_payment: 2, + write_top_up: 0, + }, + MintActionType::UpdateMetadataField { + extension_index: 0, + field_type: 0, // Name + key: vec![], + value: "Updated Name During Decompress".as_bytes().to_vec(), + }, + ]; + + light_token_client::actions::mint_action( + &mut rpc, + MintActionParams { + compressed_mint_address, + mint_seed: mint_seed.pubkey(), + authority: authority.pubkey(), + payer: payer.pubkey(), + actions: actions.clone(), + new_mint: None, + }, + &authority, + &payer, + Some(&mint_seed), // Required for DecompressMint + ) + .await + .unwrap(); + + // 4. Verify + assert_mint_action(&mut rpc, compressed_mint_address, pre_mint, actions).await; +} + +/// Test DecompressMint + MintToCToken in same transaction. +#[tokio::test] +#[serial] +async fn test_decompress_with_mint_to_ctoken() { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + let mint_seed = Keypair::new(); + let authority = Keypair::new(); + rpc.airdrop_lamports(&authority.pubkey(), 10_000_000_000) + .await + .unwrap(); + rpc.airdrop_lamports(&payer.pubkey(), 10_000_000_000) + .await + .unwrap(); + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + let compressed_mint_address = + derive_cmint_compressed_address(&mint_seed.pubkey(), &address_tree_pubkey); + let (spl_mint_pda, cmint_bump) = find_cmint_address(&mint_seed.pubkey()); + + // 1. Create compressed mint + create_mint( + &mut rpc, + &mint_seed, + 8, + &authority, + Some(authority.pubkey()), + None, + &payer, + ) + .await + .unwrap(); + + // 2. Create CToken ATA for recipient + let recipient = Keypair::new(); + let compressible_params = CompressibleParams { + compressible_config: rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: rpc.test_accounts.funding_pool_config.rent_sponsor_pda, + pre_pay_num_epochs: 0, + lamports_per_write: Some(1000), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }; + + let create_ata_ix = + CreateAssociatedCTokenAccount::new(payer.pubkey(), recipient.pubkey(), spl_mint_pda) + .with_compressible(compressible_params) + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[create_ata_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // 3. Get pre-state + let compressed_account = rpc + .indexer() + .unwrap() + .get_compressed_account(compressed_mint_address, None) + .await + .unwrap() + .value + .unwrap(); + let pre_mint: CompressedMint = + BorshDeserialize::deserialize(&mut compressed_account.data.unwrap().data.as_slice()) + .unwrap(); + + // 4. DecompressMint + MintToCToken + let actions = vec![ + MintActionType::DecompressMint { + cmint_bump, + rent_payment: 2, + write_top_up: 0, + }, + MintActionType::MintToCToken { + account: derive_ctoken_ata(&recipient.pubkey(), &spl_mint_pda).0, + amount: 5000, + }, + ]; + + light_token_client::actions::mint_action( + &mut rpc, + MintActionParams { + compressed_mint_address, + mint_seed: mint_seed.pubkey(), + authority: authority.pubkey(), + payer: payer.pubkey(), + actions: actions.clone(), + new_mint: None, + }, + &authority, + &payer, + Some(&mint_seed), // Required for DecompressMint + ) + .await + .unwrap(); + + // 5. Verify + assert_mint_action(&mut rpc, compressed_mint_address, pre_mint, actions).await; +} + +/// Test DecompressMint + ALL other operations in same transaction. +#[tokio::test] +#[serial] +async fn test_decompress_with_all_operations() { + let mut rpc = LightProgramTest::new(ProgramTestConfig::new_v2(false, None)) + .await + .unwrap(); + let payer = rpc.get_payer().insecure_clone(); + let mint_seed = Keypair::new(); + let authority = Keypair::new(); + rpc.airdrop_lamports(&authority.pubkey(), 10_000_000_000) + .await + .unwrap(); + rpc.airdrop_lamports(&payer.pubkey(), 10_000_000_000) + .await + .unwrap(); + + let address_tree_pubkey = rpc.get_address_tree_v2().tree; + let compressed_mint_address = + derive_cmint_compressed_address(&mint_seed.pubkey(), &address_tree_pubkey); + let (spl_mint_pda, cmint_bump) = find_cmint_address(&mint_seed.pubkey()); + + // 1. Create compressed mint with metadata and additional_metadata + create_mint( + &mut rpc, + &mint_seed, + 8, + &authority, + Some(authority.pubkey()), + Some(TokenMetadataInstructionData { + update_authority: Some(authority.pubkey().into()), + name: "Test Token".as_bytes().to_vec(), + symbol: "TEST".as_bytes().to_vec(), + uri: "https://example.com/token.json".as_bytes().to_vec(), + additional_metadata: Some(vec![ + AdditionalMetadata { + key: vec![1, 2, 3, 4], + value: vec![10u8; 16], + }, + AdditionalMetadata { + key: vec![5, 6, 7, 8], + value: vec![20u8; 16], + }, + ]), + }), + &payer, + ) + .await + .unwrap(); + + // 2. Create CToken ATA for MintToCToken + let recipient = Keypair::new(); + let compressible_params = CompressibleParams { + compressible_config: rpc + .test_accounts + .funding_pool_config + .compressible_config_pda, + rent_sponsor: rpc.test_accounts.funding_pool_config.rent_sponsor_pda, + pre_pay_num_epochs: 0, + lamports_per_write: Some(1000), + compress_to_account_pubkey: None, + token_account_version: TokenDataVersion::ShaFlat, + compression_only: true, + }; + + let create_ata_ix = + CreateAssociatedCTokenAccount::new(payer.pubkey(), recipient.pubkey(), spl_mint_pda) + .with_compressible(compressible_params) + .instruction() + .unwrap(); + + rpc.create_and_send_transaction(&[create_ata_ix], &payer.pubkey(), &[&payer]) + .await + .unwrap(); + + // 3. Get pre-state from compressed account + let compressed_account = rpc + .indexer() + .unwrap() + .get_compressed_account(compressed_mint_address, None) + .await + .unwrap() + .value + .unwrap(); + let pre_mint: CompressedMint = + BorshDeserialize::deserialize(&mut compressed_account.data.unwrap().data.as_slice()) + .unwrap(); + + // New authorities + let new_mint_authority = Keypair::new(); + let new_freeze_authority = Keypair::new(); + let new_metadata_authority = Keypair::new(); + + // 4. DecompressMint + ALL other operations + let actions = vec![ + // DecompressMint + MintActionType::DecompressMint { + cmint_bump, + rent_payment: 2, + write_top_up: 0, + }, + // MintTo (compressed recipients) + MintActionType::MintTo { + recipients: vec![MintToRecipient { + recipient: Keypair::new().pubkey(), + amount: 1000, + }], + token_account_version: 2, + }, + // MintToCToken (decompressed recipient) + MintActionType::MintToCToken { + account: derive_ctoken_ata(&recipient.pubkey(), &spl_mint_pda).0, + amount: 2000, + }, + // UpdateMintAuthority + MintActionType::UpdateMintAuthority { + new_authority: Some(new_mint_authority.pubkey()), + }, + // UpdateFreezeAuthority + MintActionType::UpdateFreezeAuthority { + new_authority: Some(new_freeze_authority.pubkey()), + }, + // UpdateMetadataField - name + MintActionType::UpdateMetadataField { + extension_index: 0, + field_type: 0, + key: vec![], + value: "Updated Name".as_bytes().to_vec(), + }, + // UpdateMetadataField - symbol + MintActionType::UpdateMetadataField { + extension_index: 0, + field_type: 1, + key: vec![], + value: "UPDT".as_bytes().to_vec(), + }, + // UpdateMetadataField - uri + MintActionType::UpdateMetadataField { + extension_index: 0, + field_type: 2, + key: vec![], + value: "https://updated.com".as_bytes().to_vec(), + }, + // RemoveMetadataKey + MintActionType::RemoveMetadataKey { + extension_index: 0, + key: vec![5, 6, 7, 8], + idempotent: 0, + }, + // UpdateMetadataAuthority (must be last metadata operation) + MintActionType::UpdateMetadataAuthority { + extension_index: 0, + new_authority: new_metadata_authority.pubkey(), + }, + ]; + + light_token_client::actions::mint_action( + &mut rpc, + MintActionParams { + compressed_mint_address, + mint_seed: mint_seed.pubkey(), + authority: authority.pubkey(), + payer: payer.pubkey(), + actions: actions.clone(), + new_mint: None, + }, + &authority, + &payer, + Some(&mint_seed), // Required for DecompressMint + ) + .await + .unwrap(); + + // 5. Verify + assert_mint_action(&mut rpc, compressed_mint_address, pre_mint, actions).await; +} diff --git a/program-tests/utils/src/assert_mint_action.rs b/program-tests/utils/src/assert_mint_action.rs index 92968374b6..55f06b726c 100644 --- a/program-tests/utils/src/assert_mint_action.rs +++ b/program-tests/utils/src/assert_mint_action.rs @@ -141,39 +141,20 @@ pub async fn assert_mint_action( .iter() .any(|a| matches!(a, MintActionType::CompressAndCloseCMint { .. })); - if post_decompressed { - // === CASE 1 & 2: CMint is source of truth after actions === - // (Either DecompressMint happened OR was already decompressed) + // Fetch actual mint state from source of truth + let actual_mint: CompressedMint = if post_decompressed { + // CMint PDA is source of truth when decompressed let cmint_pda = Pubkey::from(expected_mint.metadata.mint); - let cmint_account = rpc .get_account(cmint_pda) .await .expect("Failed to fetch CMint account") .expect("CMint PDA account should exist when decompressed"); - let cmint: CompressedMint = - BorshDeserialize::deserialize(&mut cmint_account.data.as_slice()) - .expect("Failed to deserialize CMint account"); - - // CMint base and metadata should match expected - assert_eq!( - cmint.base, expected_mint.base, - "CMint base should match expected mint base" - ); - assert_eq!( - cmint.metadata, expected_mint.metadata, - "CMint metadata should match expected mint metadata" - ); - - // CMint compression info should be set (non-default) when decompressed - assert_ne!( - cmint.compression, - light_compressible::compression_info::CompressionInfo::default(), - "CMint compression info should be set when decompressed" - ); - - // Compressed account should have zero sentinel values + BorshDeserialize::deserialize(&mut cmint_account.data.as_slice()) + .expect("Failed to deserialize CMint account") + } else { + // Compressed account is source of truth when not decompressed let actual_mint_account = rpc .indexer() .unwrap() @@ -182,15 +163,31 @@ pub async fn assert_mint_action( .unwrap() .value .expect("Compressed mint account not found"); - assert_eq!( - *actual_mint_account.data.as_ref().unwrap(), - CompressedAccountData::default(), - "Compressed mint should have zero sentinel values when CMint is source of truth" + + BorshDeserialize::deserialize(&mut actual_mint_account.data.unwrap().data.as_slice()) + .expect("Failed to deserialize compressed mint") + }; + + // When decompressed, copy compression info from actual (slot/rent values are set at runtime) + if post_decompressed { + // Verify compression info is set (non-default) before copying + assert_ne!( + actual_mint.compression, + CompressionInfo::default(), + "CMint compression info should be set when decompressed" ); - } else { - // === CASE 3 & 4: Compressed account is source of truth after actions === - // (Either CompressAndCloseCMint happened OR was never decompressed) - let actual_mint_account = rpc + expected_mint.compression = actual_mint.compression; + } + + // Single assert_eq validates entire mint state (base, metadata, extensions, compression) + assert_eq!( + actual_mint, expected_mint, + "Mint state should match expected after applying actions" + ); + + // Verify compressed account has sentinel values when decompressed + if post_decompressed { + let sentinel_account = rpc .indexer() .unwrap() .get_compressed_account(compressed_mint_address, None) @@ -198,22 +195,10 @@ pub async fn assert_mint_action( .unwrap() .value .expect("Compressed mint account not found"); - - let actual_mint: CompressedMint = - BorshDeserialize::deserialize(&mut actual_mint_account.data.unwrap().data.as_slice()) - .expect("Failed to deserialize compressed mint"); - - // Compressed mint state should match expected - assert_eq!( - actual_mint, expected_mint, - "Compressed mint state after mint_action should match expected" - ); - - // Compressed mint compression info should be default (not set) assert_eq!( - actual_mint.compression, - light_compressible::compression_info::CompressionInfo::default(), - "Compressed mint compression info should be default when compressed" + *sentinel_account.data.as_ref().unwrap(), + CompressedAccountData::default(), + "Compressed mint should have sentinel values when CMint is source of truth" ); } @@ -232,6 +217,11 @@ pub async fn assert_mint_action( ); } // Verify CToken accounts for MintToCToken actions + assert_ctoken_balances(rpc, ctoken_mints).await; +} + +/// Verify CToken account balances after MintToCToken actions +async fn assert_ctoken_balances(rpc: &mut LightProgramTest, ctoken_mints: HashMap) { for (account_pubkey, total_minted_amount) in ctoken_mints { // Get pre-transaction account state let pre_account = rpc @@ -284,7 +274,7 @@ pub async fn assert_mint_action( ); println!( - "✓ Lamport top-up validated: {} lamports transferred to compressible ctoken account {}", + "Lamport top-up validated: {} lamports transferred to compressible ctoken account {}", expected_top_up, account_pubkey ); } diff --git a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs index 490a50eae2..46024deebd 100644 --- a/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs +++ b/programs/compressed-token/program/src/compressed_token/mint_action/mint_output.rs @@ -19,7 +19,7 @@ use crate::{ queue_indices::QueueIndices, }, constants::COMPRESSED_MINT_DISCRIMINATOR, - shared::{convert_program_error, transfer_lamports::transfer_lamports}, + shared::{convert_program_error, transfer_lamports::transfer_lamports_via_cpi}, }; /// Processes the output compressed mint account. @@ -175,14 +175,15 @@ fn serialize_decompressed_mint( deficit = deficit.saturating_add(top_up); } - // STEP 5: Transfer lamports if needed + // STEP 5: Transfer lamports if needed (via CPI since fee_payer is owned by system program) if deficit > 0 { let fee_payer = validated_accounts .executing .as_ref() .map(|exec| exec.system.fee_payer) .ok_or(ProgramError::NotEnoughAccountKeys)?; - transfer_lamports(deficit, fee_payer, cmint_account).map_err(convert_program_error)?; + transfer_lamports_via_cpi(deficit, fee_payer, cmint_account) + .map_err(convert_program_error)?; } // STEP 6: Write serialized data diff --git a/sdk-libs/token-client/src/instructions/mint_action.rs b/sdk-libs/token-client/src/instructions/mint_action.rs index 50c5fcb15f..422e2bbce7 100644 --- a/sdk-libs/token-client/src/instructions/mint_action.rs +++ b/sdk-libs/token-client/src/instructions/mint_action.rs @@ -221,6 +221,9 @@ pub async fn create_mint_action_instruction( ) }; + // Check if mint data is None (for later use in determining CMint accounts) + let mint_data_is_none = compressed_mint_inputs.mint.is_none(); + // Build instruction data using builder pattern let mut instruction_data = if is_creating_mint { MintActionCompressedInstructionData::new_mint( @@ -348,6 +351,9 @@ pub async fn create_mint_action_instruction( config = config.with_ctoken_accounts(ctoken_accounts); } + // Check if mint is already decompressed (compressed account has empty data) + let cmint_decompressed = mint_data_is_none && !is_creating_mint; + // Add compressible CMint accounts if DecompressMint or CompressAndCloseCMint action is present if has_decompress_mint || has_compress_and_close_cmint { let (cmint_pda, _) = find_cmint_address(¶ms.mint_seed); @@ -373,6 +379,10 @@ pub async fn create_mint_action_instruction( if has_decompress_mint && !is_creating_mint { config = config.with_mint_signer(params.mint_seed); } + } else if cmint_decompressed { + // Mint is already decompressed - only need CMint account (no compressible config or rent sponsor) + let (cmint_pda, _) = find_cmint_address(¶ms.mint_seed); + config = config.with_cmint(cmint_pda); } // Get account metas